feat(payments): report income to «Мой налог»
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Failing after 24s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped

The direct rail runs on НПД, where the provider neither files with the tax
service nor issues a receipt — so nobody was doing it. This registers each
rouble purchase, annuls its receipt on a refund, and hands the buyer the
receipt by email.

Two properties of the (unofficial) lknpd API shape the design. Registering an
income takes no idempotency key, so an error does not mean nothing happened:
the service name is frozen before the call and carries a marker from the tail
of the order id, and after a failure the taxpayer's income list is searched
for that exact name. Found means filed; not found halts the queue for a human,
because declaring an income twice is as wrong as not declaring it. And faults
are classified rather than logged: a token is renewed silently, a throttle
backs off, an outage retries, but three unfixable rejections take the rail out
of service — a changed format must not become thousands of requests overnight.

The console button and the worker share one RunBatch. Automatic mode is armed
from the console, not from configuration, so the operator can watch a run go
through by hand first. A daily watchdog runs whether or not it is armed, since
the case it exists for is the export being off. An idle queue issues no call at
all — not even an authentication.

No payment path changed: the purchase letter rides the existing payment-event
outbox on its own cursor, the receipt and annulment letters ride the export
row. Decisions D53-D60.
This commit is contained in:
Ilia Denisov
2026-07-28 15:40:36 +02:00
parent c13f5cdb1e
commit e3c2e80a0a
35 changed files with 4926 additions and 13 deletions
@@ -49,6 +49,19 @@ func TestRendererRendersEveryPage(t *testing.T) {
{"dictionary", DictionaryView{ActiveVersion: "v1.0.0", Variants: []VariantVersions{{Variant: "scrabble_en", Versions: []string{"v1.0.0"}}}, Changes: []DictChangeRow{{Variant: "scrabble_en", Word: "qi", Action: "add"}}}, "Update dictionaries"},
{"dictionary_preview", DictionaryPreviewView{Version: "v1.1.0", Token: "0123456789abcdef0123456789abcdef", ActiveVersion: "v1.0.0", Variants: []VariantDiffRow{{Variant: "scrabble_en", AddedCount: 2, RemovedCount: 1, AddedSample: []string{"qi", "za"}, RemovedSample: []string{"xqz"}, RemovedTruncated: true}}}, "v1.1.0"},
{"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"},
{"mynalog", MyNalogView{TimeZone: "Europe/Moscow"}, "Not signed in"},
{"mynalog", MyNalogView{
SignedIn: true, INN: "770000000000", AutoEnabled: true, TimeZone: "Europe/Moscow",
LastOK: "2026-07-28 12:00",
Backlog: MyNalogBacklogRow{Count: 2, Amount: "300.00 RUB", Oldest: "2026-06-30", Overdue: true},
Owed: []MyNalogRow{{LedgerID: "l1", AccountID: "a1", At: "2026-07-28 12:00",
Amount: "149.00 RUB", Name: `Внутриигровая валюта: "Фишка", 100 шт. (ID: 0198c1f2)`}},
Cancels: []MyNalogCancelRow{{LedgerID: "l2", AccountID: "a2", ReceiptUUID: "20abcdef01", Failed: true}},
Attention: []MyNalogRow{{LedgerID: "l3", AccountID: "a3", At: "2026-07-27 09:00",
Amount: "99.00 RUB", Name: "x (ID: 0198c1f3)", Attempts: 2, LastError: "timeout"}},
}, "Needs attention"},
{"mynalog", MyNalogView{SignedIn: true, TimeZone: "Europe/Moscow", Paused: true,
PauseReason: "the tax service rejected three receipts in a row"}, "Resume"},
{"message", MessageView{Heading: "Done", Body: "ok", Back: "/_gm/"}, "Done"},
}
for _, tc := range cases {
@@ -24,6 +24,7 @@
<a href="/_gm/banners"{{if eq .ActiveNav "banners"}} class="active"{{end}}>Banners</a>
<a href="/_gm/catalog"{{if eq .ActiveNav "catalog"}} class="active"{{end}}>Catalog</a>
<a href="/_gm/ledger"{{if eq .ActiveNav "ledger"}} class="active"{{end}}>Ledger</a>
<a href="/_gm/mynalog"{{if eq .ActiveNav "mynalog"}} class="active"{{end}}>My Nalog</a>
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
<a href="/_gm/grafana/">Grafana ↗</a>
@@ -0,0 +1,115 @@
{{define "content" -}}
<h1>My Nalog</h1>
{{with .Data}}
<p class="note">Rouble income taken through the direct rail has to be registered with the
professional-income tax service by hand — the payment provider does not do it. Times are shown and
sent in <strong>{{.TimeZone}}</strong>, because the offset decides which tax month an income falls
into. The tax API is unofficial: if it stops working, use “Export CSV” and file by hand, then enter
each receipt number back here so a refund can still annul it.</p>
{{if .Backlog.Count}}
<section class="panel">
<h2>{{if .Backlog.Overdue}}Overdue{{else}}Closed month still unfiled{{end}}</h2>
<p><strong>{{.Backlog.Count}}</strong> income(s) totalling <strong>{{.Backlog.Amount}}</strong> from a
month that has already ended are still not registered. Oldest: {{.Backlog.Oldest}}.
{{if .Backlog.Overdue}}The 9th of the month has passed.{{end}}</p>
</section>
{{end}}
<section class="panel">
<h2>Tax cabinet</h2>
{{if .SignedIn}}
<p>Signed in as <strong>{{.INN}}</strong>.
{{if .LastOK}}Last successful exchange: {{.LastOK}}.{{else}}No successful exchange yet.{{end}}</p>
{{if .Paused}}
<p class="note">The export has taken itself out of service: {{.PauseReason}}</p>
<form class="form" method="post" action="/_gm/mynalog/resume"><button type="submit">Resume</button></form>
{{end}}
<div class="actions">
<form class="form" method="post" action="/_gm/mynalog/run"><button type="submit">Run export now</button></form>
<form class="form" method="post" action="/_gm/mynalog/auto">
<input type="hidden" name="enabled" value="{{if .AutoEnabled}}0{{else}}1{{end}}">
<button type="submit">{{if .AutoEnabled}}Turn the automatic export off{{else}}Turn the automatic export on{{end}}</button>
</form>
<form class="form" method="post" action="/_gm/mynalog/logout" onsubmit="return confirm('Sign out of the tax cabinet? The automatic export switches off with it and you will need the password to sign back in.')"><button type="submit" class="danger">Sign out</button></form>
<a class="export" href="/_gm/mynalog.csv">Export CSV ↓</a>
</div>
<p class="note">Automatic export is <strong>{{if .AutoEnabled}}on{{else}}off{{end}}</strong>. It only
ever runs when there is something to file, so an idle installation sends the tax service nothing.</p>
{{else}}
<p class="note">Not signed in. The password is used once to obtain a refresh token and is not stored.</p>
<form class="form" method="post" action="/_gm/mynalog/login">
<label>Login (INN) <input name="login" autocomplete="off" size="16"></label>
<label>Password <input type="password" name="password" autocomplete="off" size="24"></label>
<button type="submit">Sign in</button>
</form>
{{end}}
</section>
{{if .Attention}}
<h2>Needs attention</h2>
<p class="note">The outcome of these could not be established — the receipt may or may not exist.
New income is not filed while any remain, because filing an income twice is as wrong as not filing
it at all. Re-check asks the tax service again; “Back to queue” means you are sure nothing was
created; entering a receipt number means you filed it by hand.</p>
<table class="list">
<thead><tr><th>Time</th><th>Amount</th><th>Service name</th><th>Tries</th><th>Last error</th><th></th></tr></thead>
<tbody>
{{range .Attention}}
<tr>
<td>{{.At}}</td>
<td class="num">{{.Amount}}</td>
<td><code>{{.Name}}</code></td>
<td class="num">{{.Attempts}}</td>
<td><span class="note">{{.LastError}}</span></td>
<td>
<form class="form" method="post" action="/_gm/mynalog/recheck"><input type="hidden" name="ledger_id" value="{{.LedgerID}}"><button type="submit">Re-check</button></form>
<form class="form" method="post" action="/_gm/mynalog/manual"><input type="hidden" name="ledger_id" value="{{.LedgerID}}"><input name="receipt_uuid" placeholder="receipt number" size="14"><button type="submit">Filed by hand</button></form>
<form class="form" method="post" action="/_gm/mynalog/requeue" onsubmit="return confirm('Only do this if the tax cabinet really has no receipt for this income — otherwise it will be filed twice.')"><input type="hidden" name="ledger_id" value="{{.LedgerID}}"><button type="submit">Back to queue</button></form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
<h2>To annul</h2>
<table class="list">
<thead><tr><th>Account</th><th>Receipt</th><th>State</th></tr></thead>
<tbody>
{{range .Cancels}}
<tr>
<td><a href="/_gm/users/{{.AccountID}}">card</a></td>
<td><code>{{.ReceiptUUID}}</code></td>
<td>{{if .Failed}}<span class="note">a previous attempt was refused</span>{{else}}awaiting{{end}}</td>
</tr>
{{else}}
<tr><td colspan="3"><span class="note">nothing to annul</span></td></tr>
{{end}}
</tbody>
</table>
<h2>To file</h2>
<table class="list">
<thead><tr><th>Time</th><th>Account</th><th>Amount</th><th>Service name</th><th>Tries</th><th>Last error</th><th></th></tr></thead>
<tbody>
{{range .Owed}}
<tr>
<td>{{.At}}</td>
<td><a href="/_gm/users/{{.AccountID}}">card</a></td>
<td class="num">{{.Amount}}</td>
<td><code>{{.Name}}</code></td>
<td class="num">{{.Attempts}}</td>
<td><span class="note">{{.LastError}}</span></td>
<td>
<form class="form" method="post" action="/_gm/mynalog/manual"><input type="hidden" name="ledger_id" value="{{.LedgerID}}"><input name="receipt_uuid" placeholder="receipt number" size="14"><button type="submit">Filed by hand</button></form>
<form class="form" method="post" action="/_gm/mynalog/skip" onsubmit="return confirm('Rule this income out of the export? It will never be filed.')"><input type="hidden" name="ledger_id" value="{{.LedgerID}}"><input name="reason" placeholder="reason" size="16"><button type="submit" class="danger">Rule out</button></form>
</td>
</tr>
{{else}}
<tr><td colspan="7"><span class="note">nothing to file</span></td></tr>
{{end}}
</tbody>
</table>
{{end}}
{{- end}}
+53
View File
@@ -808,3 +808,56 @@ type LedgerTotalsRow struct {
ChipsIn int
ChipsOut int
}
// MyNalogView is the professional-income tax export section: the state of the tax-cabinet session,
// what is still owed to the tax register, and the manual fallback for when its API is unavailable.
type MyNalogView struct {
SignedIn bool
INN string
AutoEnabled bool
Paused bool
PauseReason string
LastOK string
TimeZone string
// Backlog is what a closed month still owes, and is empty when nothing does.
Backlog MyNalogBacklogRow
// Owed, Cancels and Attention are the three working lists: income to file, receipts to annul,
// and rows whose outcome nobody could establish.
Owed []MyNalogRow
Cancels []MyNalogCancelRow
Attention []MyNalogRow
}
// MyNalogBacklogRow summarises unfiled income from a closed month.
type MyNalogBacklogRow struct {
Count int
Amount string
Oldest string
// Overdue reports that the statutory filing date for that month has passed.
Overdue bool
}
// MyNalogRow is one income on the export page. Name is the exact service description that would be
// sent, which is also what an operator must type when filing by hand — the two have to match, or a
// later automatic recovery would not recognise the receipt.
type MyNalogRow struct {
LedgerID string
AccountID string
At string
Amount string
Name string
Status string
Attempts int
LastError string
ReceiptUUID string
ReceiptURL string
}
// MyNalogCancelRow is one registered receipt whose income has been refunded and which is therefore
// owed an annulment.
type MyNalogCancelRow struct {
LedgerID string
AccountID string
ReceiptUUID string
Failed bool
}
+24
View File
@@ -12,6 +12,8 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/game"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/mynalog"
"scrabble/backend/internal/mynalogsync"
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/robokassa"
@@ -79,6 +81,10 @@ type Config struct {
// resolves to YooKassa. Kept wired so restoring Robokassa is a credentials change, not a code
// change — see backend/internal/robokassa/README.md.
Robokassa robokassa.Shops
// MyNalog configures the professional-income tax export. An empty Key leaves the whole rail
// dormant: without somewhere safe to keep the cabinet's refresh token there is no way to stay
// signed in, so the console section and both workers stay unregistered.
MyNalog mynalogsync.Config
}
// Defaults applied when the corresponding environment variable is unset.
@@ -88,6 +94,9 @@ const (
defaultLogLevel = "info"
defaultGuestReapInterval = time.Hour
defaultGuestRetention = 30 * 24 * time.Hour
// defaultMyNalogTZ is the taxpayer's time zone. It decides which tax month a near-midnight
// payment is filed under, so it follows the taxpayer's registration, not the server's clock.
defaultMyNalogTZ = "Europe/Moscow"
)
// Load reads the configuration from the environment, applies defaults for unset
@@ -201,6 +210,20 @@ func Load() (Config, error) {
shops[robokassa.ChannelAndroid] = android
}
// Professional-income tax export. The key seals the cabinet's refresh token at rest; without it
// the rail is dormant, which is the state every deployment starts in.
mn := mynalogsync.Config{BaseURL: os.Getenv("BACKEND_MYNALOG_BASE_URL")}
if raw := os.Getenv("BACKEND_MYNALOG_KEY"); raw != "" {
if mn.Key, err = mynalog.ParseKey(raw); err != nil {
return Config{}, fmt.Errorf("config: BACKEND_MYNALOG_KEY: %w", err)
}
}
// The offset a receipt carries decides which tax period an income falls into, so this is the
// taxpayer's own zone, not the server's.
if mn.Location, err = time.LoadLocation(envOr("BACKEND_MYNALOG_TZ", defaultMyNalogTZ)); err != nil {
return Config{}, fmt.Errorf("config: BACKEND_MYNALOG_TZ: %w", err)
}
c := Config{
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr),
@@ -221,6 +244,7 @@ func Load() (Config, error) {
YooKassa: ykShops,
YooKassaVatCode: vatCode,
Robokassa: shops,
MyNalog: mn,
}
if err := c.validate(); err != nil {
return Config{}, err
+723
View File
@@ -0,0 +1,723 @@
//go:build integration
package inttest
import (
"context"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/mynalog"
"scrabble/backend/internal/mynalogsync"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/server"
)
// taxStub stands in for the professional-income tax cabinet. It records what it was asked and, by
// default, behaves like the real service: it mints a receipt id and remembers the income under the
// exact service name it was filed with, which is what the recovery probe searches by.
type taxStub struct {
srv *httptest.Server
mu sync.Mutex
calls []string
filed map[string]string // service name -> receipt id
annulled map[string]bool // receipt id -> annulled
seq int
// onIncome overrides the default success. Returning a non-zero status makes the stub answer
// that instead; filing is the callback's own business, which is how a "the call failed but the
// receipt exists" case is set up.
onIncome func(s *taxStub, name string) (status int, body string)
}
// newTaxStub starts a stub cabinet and stops it when the test ends.
func newTaxStub(t *testing.T) *taxStub {
t.Helper()
st := &taxStub{filed: map[string]string{}, annulled: map[string]bool{}}
st.srv = httptest.NewServer(http.HandlerFunc(st.serve))
t.Cleanup(st.srv.Close)
return st
}
// serve routes one stub call.
func (s *taxStub) serve(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
s.mu.Lock()
s.calls = append(s.calls, r.URL.Path)
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/lkfl", "/api/v1/auth/token":
_, _ = io.WriteString(w, `{"token":"access","refreshToken":"refresh","profile":{"inn":"770000000000"}}`)
case "/api/v1/income":
s.serveIncome(w, body)
case "/api/v1/cancel":
s.serveCancel(w, body)
case "/api/v1/incomes":
s.serveList(w)
default:
w.WriteHeader(http.StatusNotFound)
}
}
// serveIncome registers an income, or defers to the test's override.
func (s *taxStub) serveIncome(w http.ResponseWriter, body []byte) {
var req struct {
Services []struct {
Name string `json:"name"`
} `json:"services"`
}
_ = json.Unmarshal(body, &req)
name := ""
if len(req.Services) > 0 {
name = req.Services[0].Name
}
if s.onIncome != nil {
if status, out := s.onIncome(s, name); status != 0 {
w.WriteHeader(status)
_, _ = io.WriteString(w, out)
return
}
}
_, _ = io.WriteString(w, fmt.Sprintf(`{"approvedReceiptUuid":%q}`, s.file(name)))
}
// file records an income under name and returns the receipt id the stub assigned.
func (s *taxStub) file(name string) string {
s.mu.Lock()
defer s.mu.Unlock()
s.seq++
id := fmt.Sprintf("receipt-%d", s.seq)
s.filed[name] = id
return id
}
// serveCancel annuls a receipt.
func (s *taxStub) serveCancel(w http.ResponseWriter, body []byte) {
var req struct {
ReceiptUUID string `json:"receiptUuid"`
}
_ = json.Unmarshal(body, &req)
s.mu.Lock()
s.annulled[req.ReceiptUUID] = true
s.mu.Unlock()
_, _ = io.WriteString(w, `{}`)
}
// serveList answers the income list the recovery probe searches, omitting annulled receipts the way
// the real service marks them.
func (s *taxStub) serveList(w http.ResponseWriter) {
s.mu.Lock()
defer s.mu.Unlock()
var items []string
for name, id := range s.filed {
cancellation := "null"
if s.annulled[id] {
cancellation = `{"comment":"Возврат средств"}`
}
items = append(items, fmt.Sprintf(`{"name":%q,"approvedReceiptUuid":%q,"cancellationInfo":%s}`,
name, id, cancellation))
}
_, _ = io.WriteString(w, `{"content":[`+strings.Join(items, ",")+`]}`)
}
// hits reports how many times a path was called.
func (s *taxStub) hits(path string) int {
s.mu.Lock()
defer s.mu.Unlock()
n := 0
for _, c := range s.calls {
if c == path {
n++
}
}
return n
}
// receiptFor returns the receipt id the stub assigned to a service name.
func (s *taxStub) receiptFor(name string) (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
id, ok := s.filed[name]
return id, ok
}
// isAnnulled reports whether a receipt has been annulled.
func (s *taxStub) isAnnulled(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.annulled[id]
}
// recordingAlerter captures operator alerts instead of emailing them.
type recordingAlerter struct {
mu sync.Mutex
kinds []string
}
func (a *recordingAlerter) Alert(_ context.Context, kind, _, _ string) error {
a.mu.Lock()
defer a.mu.Unlock()
a.kinds = append(a.kinds, kind)
return nil
}
// saw reports whether an alert of the given kind was raised.
func (a *recordingAlerter) saw(kind string) bool {
a.mu.Lock()
defer a.mu.Unlock()
for _, k := range a.kinds {
if k == kind {
return true
}
}
return false
}
// taxKey is the encryption key the tests seal the stored refresh token with.
const taxKey = "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY="
// taxServer wires a console server with the tax export pointed at the stub.
func taxServer(t *testing.T, st *taxStub) (*server.Server, *payments.Service, *mynalogsync.Exporter, *recordingAlerter) {
t.Helper()
pay := newPaymentsService()
key, err := mynalog.ParseKey(taxKey)
if err != nil {
t.Fatalf("key: %v", err)
}
alerts := &recordingAlerter{}
exp := mynalogsync.New(pay, mynalogsync.Config{
Key: key,
BaseURL: st.srv.URL,
Location: time.FixedZone("MSK", 3*60*60),
Pace: time.Millisecond, // production paces at 2s; a test must not
}, alerts, zap.NewNop())
if exp == nil {
t.Fatal("exporter not built")
}
srv := server.New(":0", server.Deps{
Logger: zap.NewNop(),
Accounts: account.NewStore(testDB),
Games: newGameService(),
Registry: testRegistry,
DictDir: dictDir(),
Payments: pay,
MyNalog: exp,
})
drainTaxQueue(t, pay)
return srv, pay, exp, alerts
}
// drainTaxQueue rules out every rouble income already in the shared test database, so a test starts
// from an empty queue and can assert on counts. The database is shared across the package, and
// other payment tests fund YooKassa orders that this export would otherwise pick up.
func drainTaxQueue(t *testing.T, pay *payments.Service) {
t.Helper()
ctx := context.Background()
for {
queue, err := pay.MyNalogQueue(ctx, 500)
if err != nil {
t.Fatalf("drain queue: %v", err)
}
pending := append(append([]payments.MyNalogIncome{}, queue.Incomes...), queue.NotRequired...)
pending = append(pending, queue.Attention...)
if len(pending) == 0 {
return
}
for _, in := range pending {
if err := pay.MarkMyNalogNotRequired(ctx, in, "seeded by another test"); err != nil {
t.Fatalf("drain row: %v", err)
}
}
}
}
// fundTaxableOrder funds one YooKassa rouble order and returns the account and the order id.
func fundTaxableOrder(t *testing.T, pay *payments.Service, chips int, minor int64) (uuid.UUID, uuid.UUID) {
t.Helper()
ctx := context.Background()
acc := provisionAccount(t)
prod := seedPackProduct(t, chips, methodPrice{method: "direct", currency: "RUB", amount: minor})
res, err := pay.CreateOrder(ctx, acc, payments.NewContext("direct", "web"),
[]payments.Source{payments.SourceDirect}, prod, "yookassa")
if err != nil {
t.Fatalf("order: %v", err)
}
paid, err := payments.MoneyFromMinor(minor, payments.CurrencyRUB)
if err != nil {
t.Fatalf("money: %v", err)
}
if _, err := pay.Fund(ctx, res.OrderID, "yookassa", "pay-"+res.OrderID.String(), paid); err != nil {
t.Fatalf("fund: %v", err)
}
return acc, res.OrderID
}
// signInTax signs the export into the stub cabinet through the console.
func signInTax(t *testing.T, srv *server.Server) {
t.Helper()
code, body := consoleDo(srv.Handler(), http.MethodPost, "http://admin.test/_gm/mynalog/login",
"login=770000000000&password=secret", "http://admin.test")
if code != http.StatusOK || !strings.Contains(body, "Signed in") {
t.Fatalf("sign in = %d: %s", code, body)
}
}
// onlyIncome returns the single owed income, failing when the queue does not hold exactly one.
func onlyIncome(t *testing.T, pay *payments.Service) payments.MyNalogIncome {
t.Helper()
queue, err := pay.MyNalogQueue(context.Background(), 500)
if err != nil {
t.Fatalf("queue: %v", err)
}
if len(queue.Incomes) != 1 {
t.Fatalf("owed incomes = %d, want 1", len(queue.Incomes))
}
return queue.Incomes[0]
}
// statusOf reads the export status recorded for one income.
func statusOf(t *testing.T, pay *payments.Service, ledgerID uuid.UUID) payments.MyNalogIncome {
t.Helper()
in, ok, err := pay.MyNalogIncomeAt(context.Background(), ledgerID)
if err != nil || !ok {
t.Fatalf("income %s: ok=%v err=%v", ledgerID, ok, err)
}
return in
}
// TestMyNalogFilesIncomeAndAnnulsOnRefund walks the happy path end to end: a rouble purchase is
// filed with the tax service under a name carrying its order marker, and a later refund annuls that
// receipt without any further human involvement.
func TestMyNalogFilesIncomeAndAnnulsOnRefund(t *testing.T) {
ctx := context.Background()
st := newTaxStub(t)
srv, pay, exp, _ := taxServer(t, st)
signInTax(t, srv)
_, orderID := fundTaxableOrder(t, pay, 100, 14900)
owed := onlyIncome(t, pay)
name := exp.ReceiptName(owed)
// The marker is the TAIL of the order id: a UUIDv7's leading digits are a millisecond clock and
// would be shared by every order placed within about a minute, which would make the recovery
// probe unable to tell two purchases apart.
marker := orderID.String()[len(orderID.String())-8:]
if !strings.Contains(name, marker) {
t.Fatalf("service name %q does not carry the order marker %q — the recovery probe depends on it", name, marker)
}
sum, err := exp.RunBatch(ctx, time.Minute)
if err != nil {
t.Fatalf("run: %v", err)
}
if sum.Registered != 1 {
t.Fatalf("registered = %d, want 1 (%+v)", sum.Registered, sum)
}
receipt, ok := st.receiptFor(name)
if !ok {
t.Fatalf("the stub cabinet has no receipt for %q", name)
}
if got := statusOf(t, pay, owed.LedgerID); got.Status != payments.MyNalogSent || got.ReceiptUUID != receipt {
t.Fatalf("status = %q receipt = %q, want sent/%s", got.Status, got.ReceiptUUID, receipt)
}
// Refund the order: the annulment is owed automatically.
if _, err := pay.RefundOrderFull(ctx, orderID); err != nil {
t.Fatalf("refund: %v", err)
}
sum, err = exp.RunBatch(ctx, time.Minute)
if err != nil {
t.Fatalf("run after refund: %v", err)
}
if sum.Cancelled != 1 {
t.Fatalf("cancelled = %d, want 1 (%+v)", sum.Cancelled, sum)
}
if !st.isAnnulled(receipt) {
t.Fatal("the receipt was not annulled at the tax service")
}
}
// TestMyNalogProbeRecoversAnAmbiguousCall covers the failure this whole design exists for: the
// registration call has no idempotency key, so a receipt can exist even though the call reported an
// error. The probe must find it and record it, rather than the income being filed a second time.
func TestMyNalogProbeRecoversAnAmbiguousCall(t *testing.T) {
ctx := context.Background()
st := newTaxStub(t)
// File the income, then answer as if the service fell over.
st.onIncome = func(s *taxStub, name string) (int, string) {
s.file(name)
return http.StatusBadGateway, `{"message":"gateway"}`
}
srv, pay, exp, alerts := taxServer(t, st)
signInTax(t, srv)
fundTaxableOrder(t, pay, 100, 14900)
owed := onlyIncome(t, pay)
sum, err := exp.RunBatch(ctx, time.Minute)
if err != nil {
t.Fatalf("run: %v", err)
}
if sum.Registered != 1 || sum.Unknown != 0 {
t.Fatalf("registered = %d unknown = %d, want the probe to recover it (%+v)", sum.Registered, sum.Unknown, sum)
}
if got := statusOf(t, pay, owed.LedgerID); got.Status != payments.MyNalogSent {
t.Fatalf("status = %q, want sent", got.Status)
}
if st.hits("/api/v1/incomes") == 0 {
t.Fatal("the probe was never issued")
}
if alerts.saw("unknown") {
t.Fatal("a recovered call must not alert the operator")
}
}
// TestMyNalogUnresolvedOutcomeHaltsTheQueue asserts the rule that keeps income from being filed
// twice: when the call fails and the probe finds nothing, the income is left unresolved and NOTHING
// further is filed until a human has looked at it.
func TestMyNalogUnresolvedOutcomeHaltsTheQueue(t *testing.T) {
ctx := context.Background()
st := newTaxStub(t)
st.onIncome = func(*taxStub, string) (int, string) { return http.StatusBadGateway, `{}` }
srv, pay, exp, alerts := taxServer(t, st)
signInTax(t, srv)
fundTaxableOrder(t, pay, 100, 14900)
first := onlyIncome(t, pay)
sum, err := exp.RunBatch(ctx, time.Minute)
if err != nil {
t.Fatalf("run: %v", err)
}
if sum.Unknown != 1 || sum.Registered != 0 {
t.Fatalf("unknown = %d registered = %d, want one unresolved (%+v)", sum.Unknown, sum.Registered, sum)
}
if got := statusOf(t, pay, first.LedgerID); got.Status != payments.MyNalogUnknown {
t.Fatalf("status = %q, want unknown", got.Status)
}
if !alerts.saw("unknown") {
t.Fatal("an unresolved outcome must alert the operator")
}
// A second, perfectly fine purchase must NOT be filed while the first is unresolved.
st.onIncome = nil
fundTaxableOrder(t, pay, 50, 9900)
sum, err = exp.RunBatch(ctx, time.Minute)
if err != nil {
t.Fatalf("second run: %v", err)
}
if sum.Registered != 0 {
t.Fatalf("registered = %d while an outcome is unresolved, want 0", sum.Registered)
}
if st.hits("/api/v1/income") != 1 {
t.Fatalf("income calls = %d, want no new filing attempt", st.hits("/api/v1/income"))
}
// The operator asserts nothing was created; the income goes back into the queue and files.
if err := exp.Requeue(ctx, first.LedgerID); err != nil {
t.Fatalf("requeue: %v", err)
}
sum, err = exp.RunBatch(ctx, time.Minute)
if err != nil {
t.Fatalf("run after requeue: %v", err)
}
if sum.Registered != 2 {
t.Fatalf("registered = %d after requeue, want both (%+v)", sum.Registered, sum)
}
}
// TestMyNalogPausesAfterRepeatedRejections asserts the circuit breaker: an API that rejects
// everything — the shape a format change takes — must take the rail out of service rather than
// producing thousands of futile requests.
func TestMyNalogPausesAfterRepeatedRejections(t *testing.T) {
ctx := context.Background()
st := newTaxStub(t)
st.onIncome = func(*taxStub, string) (int, string) {
return http.StatusBadRequest, `{"message":"unknown field"}`
}
srv, pay, exp, alerts := taxServer(t, st)
signInTax(t, srv)
for i := 0; i < 4; i++ {
fundTaxableOrder(t, pay, 10, 1000)
}
sum, err := exp.RunBatch(ctx, time.Minute)
if err != nil {
t.Fatalf("run: %v", err)
}
if !sum.Paused {
t.Fatalf("the rail did not pause after repeated rejections (%+v)", sum)
}
if st.hits("/api/v1/income") != 3 {
t.Fatalf("income calls = %d, want the run to stop after 3", st.hits("/api/v1/income"))
}
if !alerts.saw("paused") {
t.Fatal("a self-imposed pause must alert the operator")
}
// While paused, a run does nothing at all.
before := st.hits("/api/v1/income")
sum, err = exp.RunBatch(ctx, time.Minute)
if err != nil {
t.Fatalf("run while paused: %v", err)
}
if !sum.Paused || st.hits("/api/v1/income") != before {
t.Fatalf("a paused rail still called the service (%+v)", sum)
}
// Resuming through the console lets it run again.
st.onIncome = nil
if code, _ := consoleDo(srv.Handler(), http.MethodPost, "http://admin.test/_gm/mynalog/resume",
"", "http://admin.test"); code != http.StatusOK {
t.Fatalf("resume = %d", code)
}
sum, err = exp.RunBatch(ctx, time.Minute)
if err != nil {
t.Fatalf("run after resume: %v", err)
}
if sum.Registered != 4 {
t.Fatalf("registered = %d after resume, want 4 (%+v)", sum.Registered, sum)
}
}
// TestMyNalogSkipsIncomeRefundedBeforeFiling asserts that a purchase the buyer got back before any
// receipt was issued is ruled out locally: nothing is filed, nothing is annulled, and the tax
// service is not contacted at all.
func TestMyNalogSkipsIncomeRefundedBeforeFiling(t *testing.T) {
ctx := context.Background()
st := newTaxStub(t)
srv, pay, exp, _ := taxServer(t, st)
signInTax(t, srv)
callsAfterSignIn := len(st.calls)
_, orderID := fundTaxableOrder(t, pay, 100, 14900)
if _, err := pay.RefundOrderFull(ctx, orderID); err != nil {
t.Fatalf("refund: %v", err)
}
sum, err := exp.RunBatch(ctx, time.Minute)
if err != nil {
t.Fatalf("run: %v", err)
}
if sum.NotRequired != 1 || sum.Registered != 0 {
t.Fatalf("not required = %d registered = %d, want it ruled out (%+v)", sum.NotRequired, sum.Registered, sum)
}
if len(st.calls) != callsAfterSignIn {
t.Fatalf("the tax service was contacted for an income that needed nothing: %v", st.calls[callsAfterSignIn:])
}
}
// TestMyNalogIdleRunContactsNobody asserts the property that keeps an idle installation invisible to
// the tax service: with an empty queue a run does not even authenticate.
func TestMyNalogIdleRunContactsNobody(t *testing.T) {
st := newTaxStub(t)
srv, _, exp, _ := taxServer(t, st)
signInTax(t, srv)
before := len(st.calls)
sum, err := exp.RunBatch(context.Background(), time.Minute)
if err != nil {
t.Fatalf("run: %v", err)
}
if sum.Registered != 0 || sum.Cancelled != 0 {
t.Fatalf("an idle run did something: %+v", sum)
}
if len(st.calls) != before {
t.Fatalf("an idle run issued %v", st.calls[before:])
}
}
// TestMyNalogManualEntryStaysAnnullable asserts why the console asks for the receipt number rather
// than a simple "done" tick: an income filed by hand must still have its receipt annulled
// automatically when the money goes back.
func TestMyNalogManualEntryStaysAnnullable(t *testing.T) {
ctx := context.Background()
st := newTaxStub(t)
srv, pay, exp, _ := taxServer(t, st)
signInTax(t, srv)
_, orderID := fundTaxableOrder(t, pay, 100, 14900)
owed := onlyIncome(t, pay)
// The operator files it in the cabinet by hand and types the number back in.
form := "ledger_id=" + owed.LedgerID.String() + "&receipt_uuid=hand-1"
if code, body := consoleDo(srv.Handler(), http.MethodPost, "http://admin.test/_gm/mynalog/manual",
form, "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Recorded") {
t.Fatalf("manual entry = %d: %s", code, body)
}
if got := statusOf(t, pay, owed.LedgerID); got.Status != payments.MyNalogSent || got.ReceiptUUID != "hand-1" {
t.Fatalf("status = %q receipt = %q, want sent/hand-1", got.Status, got.ReceiptUUID)
}
if _, err := pay.RefundOrderFull(ctx, orderID); err != nil {
t.Fatalf("refund: %v", err)
}
sum, err := exp.RunBatch(ctx, time.Minute)
if err != nil {
t.Fatalf("run: %v", err)
}
if sum.Cancelled != 1 || !st.isAnnulled("hand-1") {
t.Fatalf("a hand-filed receipt was not annulled (%+v)", sum)
}
}
// TestMyNalogConsoleExportListsWhatWouldBeSent asserts the manual fallback carries exactly the
// fields the tax cabinet's own form asks for, plus the ledger id needed to enter the receipt number
// back afterwards.
func TestMyNalogConsoleExportListsWhatWouldBeSent(t *testing.T) {
st := newTaxStub(t)
srv, pay, exp, _ := taxServer(t, st)
fundTaxableOrder(t, pay, 100, 14900)
owed := onlyIncome(t, pay)
code, body := consoleDo(srv.Handler(), http.MethodGet, "http://admin.test/_gm/mynalog.csv", "", "")
if code != http.StatusOK {
t.Fatalf("export = %d", code)
}
// Parsed rather than substring-matched: the service name contains quotes, so the CSV encoding of
// it is not the string an operator has to type.
rows, err := csv.NewReader(strings.NewReader(body)).ReadAll()
if err != nil {
t.Fatalf("parse CSV: %v\n%s", err, body)
}
if len(rows) != 2 {
t.Fatalf("CSV rows = %d, want a header and one income\n%s", len(rows), body)
}
wantHeader := []string{"operation_time", "service_name", "amount", "currency", "status", "ledger_id"}
if !slices.Equal(rows[0], wantHeader) {
t.Fatalf("header = %v, want %v", rows[0], wantHeader)
}
got := rows[1]
if got[1] != exp.ReceiptName(owed) {
t.Errorf("service name = %q, want exactly what would be sent (%q)", got[1], exp.ReceiptName(owed))
}
if got[2] != "149.00" || got[3] != "RUB" {
t.Errorf("amount = %q %q, want 149.00 RUB", got[2], got[3])
}
if got[5] != owed.LedgerID.String() {
t.Errorf("ledger id = %q, want %s — without it the receipt number cannot be entered back", got[5], owed.LedgerID)
}
}
// TestMyNalogConsoleRequiresSameOrigin asserts the export actions carry the console's only CSRF
// defence, since a forged sign-in would hand an attacker's credentials to the rail.
func TestMyNalogConsoleRequiresSameOrigin(t *testing.T) {
st := newTaxStub(t)
srv, _, _, _ := taxServer(t, st)
for _, path := range []string{"/_gm/mynalog/login", "/_gm/mynalog/run", "/_gm/mynalog/auto"} {
if code, _ := consoleDo(srv.Handler(), http.MethodPost, "http://admin.test"+path, "", ""); code != http.StatusForbidden {
t.Errorf("%s without an origin = %d, want 403", path, code)
}
}
}
// TestMyNalogBuyerMails asserts the three letters a buyer gets and that none of them is sent twice.
func TestMyNalogBuyerMails(t *testing.T) {
ctx := context.Background()
st := newTaxStub(t)
srv, pay, exp, _ := taxServer(t, st)
signInTax(t, srv)
accounts := account.NewStore(testDB)
acc, orderID := fundTaxableOrder(t, pay, 100, 14900)
// Derived from the account so the address is unique in the shared test database: identities are
// unique on (kind, external_id), and a fixed literal collides with another suite's fixture.
addr := "mynalog-" + acc.String() + "@example.test"
if err := accounts.AttachIdentity(ctx, acc, account.KindEmail, addr, true); err != nil {
t.Fatalf("attach email: %v", err)
}
// The crediting path records the outbox event the purchase letter rides.
payload, _ := json.Marshal(map[string]any{"chips": 100, "source": "direct"})
if err := pay.RecordPaymentEvent(ctx, acc, &orderID, "succeeded", payload); err != nil {
t.Fatalf("record event: %v", err)
}
box := &recordingMailer{}
mailer := mynalogsync.NewMailer(pay, accounts, box, "no-reply@example.test",
st.srv.URL, time.FixedZone("MSK", 3*60*60), zap.NewNop())
if mailer == nil {
t.Fatal("mailer not built")
}
// 1. The purchase confirmation, which must say plainly that it is not the fiscal receipt.
mailer.Tick(ctx)
first := box.to(addr)
if len(first) != 1 {
t.Fatalf("purchase letters = %d, want 1", len(first))
}
if !strings.Contains(first[0].Text, "не чек") {
t.Errorf("the purchase letter does not say it is not the receipt:\n%s", first[0].Text)
}
// 2. The receipt letter, once the income is filed, carrying the printable link.
if _, err := exp.RunBatch(ctx, time.Minute); err != nil {
t.Fatalf("run: %v", err)
}
mailer.Tick(ctx)
if got := len(box.to(addr)); got != 2 {
t.Fatalf("letters after filing = %d, want 2", got)
}
if !strings.Contains(box.to(addr)[1].Text, "/api/v1/receipt/770000000000/") {
t.Errorf("the receipt letter carries no receipt link:\n%s", box.to(addr)[1].Text)
}
// 3. The annulment letter after a refund.
if _, err := pay.RefundOrderFull(ctx, orderID); err != nil {
t.Fatalf("refund: %v", err)
}
if _, err := exp.RunBatch(ctx, time.Minute); err != nil {
t.Fatalf("run after refund: %v", err)
}
mailer.Tick(ctx)
if got := len(box.to(addr)); got != 3 {
t.Fatalf("letters after the refund = %d, want 3", got)
}
if !strings.Contains(box.to(addr)[2].Text, "аннулирован") {
t.Errorf("the third letter is not the annulment notice:\n%s", box.to(addr)[2].Text)
}
// Draining again must send nothing: every letter is decided exactly once.
mailer.Tick(ctx)
if got := len(box.to(addr)); got != 3 {
t.Fatalf("letters after a second drain = %d, want 3 — a letter was repeated", got)
}
}
// recordingMailer captures outgoing mail instead of relaying it.
type recordingMailer struct {
mu sync.Mutex
sent []account.Message
}
func (m *recordingMailer) Send(_ context.Context, msg account.Message) error {
m.mu.Lock()
defer m.mu.Unlock()
m.sent = append(m.sent, msg)
return nil
}
// to returns the messages addressed to one recipient, in order.
func (m *recordingMailer) to(addr string) []account.Message {
m.mu.Lock()
defer m.mu.Unlock()
var out []account.Message
for _, msg := range m.sent {
if msg.To == addr {
out = append(out, msg)
}
}
return out
}
+130
View File
@@ -0,0 +1,130 @@
package mynalog
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"time"
)
// tokenLifetime is the assumed access-token lifetime when the service does not say. The real one is
// about an hour; a short assumption only costs an extra refresh.
const tokenLifetime = time.Hour
// tokenSlack is subtracted from the expiry so a token is renewed slightly early rather than failing
// mid-run and forcing a probe.
const tokenSlack = time.Minute
// deviceIDLength is how many hex characters of the login digest form the device id. It matches what
// the web cabinet issues, and the length is what the service accepts.
const deviceIDLength = 21
// Session is one authenticated cabinet session. Token is the short-lived bearer credential;
// RefreshToken is the long-lived one that renews it and is the only part worth persisting. INN
// identifies the taxpayer and is needed to build a receipt link.
type Session struct {
Token string
RefreshToken string
INN string
ExpiresAt time.Time
}
// Valid reports whether the session still has a usable access token at now.
func (s Session) Valid(now time.Time) bool {
return s.Token != "" && now.Before(s.ExpiresAt)
}
// DeviceIDForLogin derives a stable device identifier from the taxpayer's login. It is
// deterministic so that re-authenticating after a restart presents the same "device" instead of
// looking like a new one every time.
func DeviceIDForLogin(login string) string {
sum := sha256.Sum256([]byte(login))
return hex.EncodeToString(sum[:])[:deviceIDLength]
}
// authResponse is the shape both authentication endpoints answer with.
type authResponse struct {
Token string `json:"token"`
RefreshToken string `json:"refreshToken"`
TokenExpireIn string `json:"tokenExpireIn"`
Profile struct {
INN string `json:"inn"`
} `json:"profile"`
}
// session converts an authentication answer into a Session, defaulting the expiry when the service
// did not state one and falling back to the login for the taxpayer id.
func (a authResponse) session(login string, now time.Time) (Session, error) {
if a.Token == "" {
return Session{}, fmt.Errorf("mynalog: authentication returned no token: %w", ErrPermanent)
}
expires := now.Add(tokenLifetime)
if a.TokenExpireIn != "" {
if t, err := time.Parse(time.RFC3339, a.TokenExpireIn); err == nil {
expires = t
}
}
inn := a.Profile.INN
if inn == "" {
inn = login
}
return Session{
Token: a.Token,
RefreshToken: a.RefreshToken,
INN: inn,
ExpiresAt: expires.Add(-tokenSlack),
}, nil
}
// AuthByPassword exchanges the taxpayer's cabinet login (their INN) and password for a session.
// Callers persist only the returned RefreshToken: the password is never stored, so this runs once,
// interactively, and the session renews itself from then on.
func (c *Client) AuthByPassword(ctx context.Context, login, password string, now time.Time) (Session, error) {
body := struct {
Username string `json:"username"`
Password string `json:"password"`
DeviceInfo deviceInfo `json:"deviceInfo"`
}{Username: login, Password: password, DeviceInfo: c.device()}
var out authResponse
if err := c.do(ctx, http.MethodPost, "/api/v1/auth/lkfl", "", body, &out); err != nil {
// A password login rejected with 401 is bad credentials, not an aged-out token: reporting it
// as ErrAuthExpired would send the caller into a pointless refresh loop.
if errors.Is(err, ErrAuthExpired) {
return Session{}, fmt.Errorf("mynalog: login rejected: %w", ErrPermanent)
}
return Session{}, err
}
return out.session(login, now)
}
// AuthByRefresh renews a session from a stored refresh token. The service sometimes rotates the
// token, so the caller must persist the returned RefreshToken whenever it differs from the one
// supplied — otherwise the next renewal presents a token the service has already retired.
func (c *Client) AuthByRefresh(ctx context.Context, refreshToken, login string, now time.Time) (Session, error) {
body := struct {
DeviceInfo deviceInfo `json:"deviceInfo"`
RefreshToken string `json:"refreshToken"`
}{DeviceInfo: c.device(), RefreshToken: refreshToken}
var out authResponse
if err := c.do(ctx, http.MethodPost, "/api/v1/auth/token", "", body, &out); err != nil {
// The stored token is dead; only a fresh password login can recover, so this is permanent
// for the caller rather than something to retry.
if errors.Is(err, ErrAuthExpired) {
return Session{}, fmt.Errorf("mynalog: refresh token rejected: %w", ErrPermanent)
}
return Session{}, err
}
sess, err := out.session(login, now)
if err != nil {
return Session{}, err
}
if sess.RefreshToken == "" {
sess.RefreshToken = refreshToken
}
return sess, nil
}
+85
View File
@@ -0,0 +1,85 @@
package mynalog
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
)
// KeyLength is the AES-256 key size the stored refresh token is sealed with.
const KeyLength = 32
// ErrUndecryptable is returned when a stored secret cannot be opened with the configured key —
// typically because the key was rotated or lost. Callers treat it as "there is no session" and ask
// the operator to sign in again, which is a nuisance rather than a failure.
var ErrUndecryptable = errors.New("mynalog: stored secret cannot be decrypted")
// ParseKey decodes the configured encryption key. It accepts standard base64 with or without
// padding (so the output of `openssl rand -base64 32` works as-is) and plain hex.
func ParseKey(s string) ([]byte, error) {
for _, decode := range []func(string) ([]byte, error){
base64.StdEncoding.DecodeString,
base64.RawStdEncoding.DecodeString,
hex.DecodeString,
} {
if key, err := decode(s); err == nil && len(key) == KeyLength {
return key, nil
}
}
return nil, fmt.Errorf("mynalog: encryption key must decode to %d bytes (base64 or hex)", KeyLength)
}
// Seal encrypts plaintext with key, returning the nonce followed by the AES-GCM ciphertext.
//
// This exists because the refresh token is a long-lived credential to the operator's personal tax
// cabinet, and it is the one part of the rail that has to sit on disk. Encrypting it at the
// application layer keeps it out of database dumps, which travel further than the database does.
func Seal(key []byte, plaintext string) ([]byte, error) {
gcm, err := newGCM(key)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("mynalog: nonce: %w", err)
}
return gcm.Seal(nonce, nonce, []byte(plaintext), nil), nil
}
// Open decrypts a blob produced by Seal. Any failure — a wrong key, a truncated value, a tampered
// one — comes back as ErrUndecryptable, because the caller's response is the same in every case.
func Open(key, blob []byte) (string, error) {
gcm, err := newGCM(key)
if err != nil {
return "", err
}
if len(blob) < gcm.NonceSize() {
return "", ErrUndecryptable
}
nonce, ciphertext := blob[:gcm.NonceSize()], blob[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", ErrUndecryptable
}
return string(plaintext), nil
}
// newGCM builds the AEAD both directions share.
func newGCM(key []byte) (cipher.AEAD, error) {
if len(key) != KeyLength {
return nil, fmt.Errorf("mynalog: encryption key must be %d bytes, got %d", KeyLength, len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("mynalog: cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("mynalog: gcm: %w", err)
}
return gcm, nil
}
+194
View File
@@ -0,0 +1,194 @@
package mynalog
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"regexp"
"time"
)
// timeLayout is how the service wants a moment: ISO-8601 to the second with an explicit numeric
// offset. The offset is load-bearing — it decides which tax month a near-midnight payment falls
// into — so the layout never renders UTC as "Z".
const timeLayout = "2006-01-02T15:04:05-07:00"
// queryTimeLayout is the same with milliseconds, which is what the income-list filter expects.
const queryTimeLayout = "2006-01-02T15:04:05.000-07:00"
// findLimit is how many recent incomes the probe reads in one page. The probe searches a one-day
// window for a name carrying a unique order marker, so a single page is ample; a larger window
// would need paging.
const findLimit = 50
// cancelReasonRefund is the annulment reason the tax service recognises for money given back.
const cancelReasonRefund = "Возврат средств"
// decimalPattern accepts exactly the shape a JSON number may take here: an integer part with an
// optional fractional part, no exponent and no sign.
var decimalPattern = regexp.MustCompile(`^\d+(\.\d+)?$`)
// decimal is a monetary amount that marshals as a JSON *number* while being carried as an exact
// decimal string. payments.Money.Major() already renders that string, and passing it through
// untouched is what keeps binary floating point out of a tax filing.
type decimal string
// MarshalJSON emits the amount verbatim as a JSON number, refusing anything that is not a plain
// decimal literal rather than silently producing invalid JSON.
func (d decimal) MarshalJSON() ([]byte, error) {
if !decimalPattern.MatchString(string(d)) {
return nil, fmt.Errorf("mynalog: %q is not a decimal amount: %w", string(d), ErrPermanent)
}
return []byte(d), nil
}
// service is one line of a receipt. Quantity is always 1: an income is registered as a single
// service priced at the whole amount, not as a quantity of units.
type service struct {
Name string `json:"name"`
Amount decimal `json:"amount"`
Quantity int `json:"quantity"`
}
// client identifies the payer. For a private individual every field stays null and the type alone
// selects the 4% rate; a company or sole trader would instead need its INN and name here, which is
// why this rail is restricted to individual buyers.
type client struct {
ContactPhone *string `json:"contactPhone"`
DisplayName *string `json:"displayName"`
INN *string `json:"inn"`
IncomeType string `json:"incomeType"`
}
// incomeTypeIndividual marks the payer as a private individual.
const incomeTypeIndividual = "FROM_INDIVIDUAL"
// paymentTypeCash is the settlement type sent for every income. It does not affect the tax owed;
// it is what every known client of this API sends.
const paymentTypeCash = "CASH"
// IncomeRequest is one income to register. Name is the service description shown to the tax service
// and to the buyer, and doubles as the only key by which FindIncome can locate the receipt
// afterwards. Amount is an exact decimal string. OperationTime is when the money was actually
// received — not when we got round to reporting it — and must already be in the taxpayer's time
// zone, because its offset decides the tax period.
type IncomeRequest struct {
Name string
Amount string
OperationTime time.Time
RequestTime time.Time
}
// incomeBody is the wire shape of a registration call.
type incomeBody struct {
OperationTime string `json:"operationTime"`
RequestTime string `json:"requestTime"`
Services []service `json:"services"`
TotalAmount string `json:"totalAmount"`
Client client `json:"client"`
PaymentType string `json:"paymentType"`
// IgnoreMaxTotalIncomeRestriction stays false so that exceeding the annual professional-income
// ceiling is refused loudly instead of being quietly filed.
IgnoreMaxTotalIncomeRestriction bool `json:"ignoreMaxTotalIncomeRestriction"`
}
// incomeResponse carries the receipt identifier the tax service assigns. That uuid is the only
// handle on the receipt afterwards: without it the income can never be annulled.
type incomeResponse struct {
ApprovedReceiptUUID string `json:"approvedReceiptUuid"`
}
// AddIncome registers one income and returns the receipt uuid the tax service assigned.
//
// This call is NOT idempotent — the API takes no idempotency key — so a returned error does not
// mean nothing was recorded. On anything other than a clean success the caller must probe with
// FindIncome before considering a retry, or it risks filing the same income twice.
func (c *Client) AddIncome(ctx context.Context, sess Session, req IncomeRequest) (string, error) {
body := incomeBody{
OperationTime: req.OperationTime.Format(timeLayout),
RequestTime: req.RequestTime.Format(timeLayout),
Services: []service{{Name: req.Name, Amount: decimal(req.Amount), Quantity: 1}},
TotalAmount: req.Amount,
Client: client{IncomeType: incomeTypeIndividual},
PaymentType: paymentTypeCash,
}
var out incomeResponse
if err := c.do(ctx, http.MethodPost, "/api/v1/income", sess.Token, body, &out); err != nil {
return "", err
}
if out.ApprovedReceiptUUID == "" {
// A success with no receipt id leaves us unable to annul the income later, so it counts as
// an ambiguous outcome and the caller must probe rather than assume either way.
return "", fmt.Errorf("mynalog: income accepted without a receipt id: %w", ErrTransient)
}
return out.ApprovedReceiptUUID, nil
}
// cancelBody is the wire shape of an annulment call.
type cancelBody struct {
OperationTime string `json:"operationTime"`
RequestTime string `json:"requestTime"`
Comment string `json:"comment"`
ReceiptUUID string `json:"receiptUuid"`
}
// CancelIncome annuls a previously registered receipt because the money was returned. The tax
// service has no separate refund concept: annulling the receipt is the refund, and it removes the
// income from the tax base. now must already be in the taxpayer's time zone.
func (c *Client) CancelIncome(ctx context.Context, sess Session, receiptUUID string, now time.Time) error {
stamp := now.Format(timeLayout)
body := cancelBody{
OperationTime: stamp,
RequestTime: stamp,
Comment: cancelReasonRefund,
ReceiptUUID: receiptUUID,
}
return c.do(ctx, http.MethodPost, "/api/v1/cancel", sess.Token, body, nil)
}
// incomeListItem is one entry of the taxpayer's income list. A non-empty CancellationInfo marks an
// already-annulled receipt, which must never be matched: annulling it again would fail, and
// treating it as ours would hide a genuinely missing income.
type incomeListItem struct {
Name string `json:"name"`
ApprovedReceiptUUID string `json:"approvedReceiptUuid"`
CancellationInfo json.RawMessage `json:"cancellationInfo"`
}
// incomeListResponse is the paged income list.
type incomeListResponse struct {
Content []incomeListItem `json:"content"`
}
// FindIncome searches the taxpayer's own income list between from and to for a live receipt whose
// service name is exactly name, and reports its uuid.
//
// This is the recovery path for the non-idempotent AddIncome: after a timeout it answers the only
// question that matters — was the receipt created after all? It is exact only because the name
// carries a unique order marker; searching by amount and description alone would confuse two
// identical purchases. from and to must already be in the taxpayer's time zone.
func (c *Client) FindIncome(ctx context.Context, sess Session, name string, from, to time.Time) (string, bool, error) {
q := url.Values{}
q.Set("from", from.Format(queryTimeLayout))
q.Set("to", to.Format(queryTimeLayout))
q.Set("offset", "0")
q.Set("sortBy", "operation_time:desc")
q.Set("limit", fmt.Sprint(findLimit))
var out incomeListResponse
if err := c.do(ctx, http.MethodGet, "/api/v1/incomes?"+q.Encode(), sess.Token, nil, &out); err != nil {
return "", false, err
}
for _, item := range out.Content {
if item.Name != name || item.ApprovedReceiptUUID == "" {
continue
}
if len(item.CancellationInfo) > 0 && string(item.CancellationInfo) != "null" {
continue
}
return item.ApprovedReceiptUUID, true, nil
}
return "", false, nil
}
+239
View File
@@ -0,0 +1,239 @@
// Package mynalog talks to the personal cabinet of the Russian professional-income tax regime
// (НПД) at lknpd.nalog.ru: it registers an income and receives the receipt uuid the tax service
// assigns, annuls that receipt when the money is returned, and searches the taxpayer's own income
// list. It is pure provider glue — no database, no payments-domain coupling — so the payments
// domain stays unaware of it and this layer is unit-testable in isolation.
//
// Three properties of this API shape everything here.
//
// First, it is NOT official. There is no published contract and no sandbox: the endpoints below
// were established by observing the web cabinet, and every call lands on the operator's real tax
// account. The format can change without warning, so errors are classified rather than merely
// logged, and the caller is expected to have a manual fallback.
//
// Second, registering an income is NOT idempotent — the API accepts no idempotency key. A timeout
// therefore does not mean "nothing happened": the receipt may well exist. The only recovery is
// FindIncome, which searches the taxpayer's own income list for the exact service name we sent,
// which is why that name must carry a unique marker (see IncomeName).
//
// Third, the access token lives about an hour while the refresh token is long-lived, so callers
// persist only the refresh token and mint an access token per session.
package mynalog
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// DefaultBaseURL is the production cabinet root. NewClient's baseURL overrides it (tests point it
// at an httptest server).
const DefaultBaseURL = "https://lknpd.nalog.ru"
// RequestTimeout bounds a single API call. It is deliberately generous: the tax service is slow
// under load, and a premature client-side timeout is expensive here — an ambiguous outcome halts
// the export queue until a human resolves it, so waiting is far cheaper than guessing. It is
// exported so a caller sharing one HTTP client across calls applies the same ceiling.
const RequestTimeout = 60 * time.Second
// maxResponseBytes caps how much of a response body is read, so a misbehaving endpoint cannot
// exhaust memory.
const maxResponseBytes = 1 << 20
// Error classes every failure is sorted into. They exist so the caller can act differently on each
// without parsing messages: a silent re-authentication, a backoff, a retry next run, or a hard stop
// with an operator alert.
var (
// ErrAuthExpired is a rejected token. It is routine — the access token simply aged out — and
// the caller re-authenticates instead of alerting.
ErrAuthExpired = errors.New("mynalog: authentication expired")
// ErrThrottled is the service asking us to slow down. The run stops and resumes later; it is
// worth an alert only if it persists.
ErrThrottled = errors.New("mynalog: throttled")
// ErrTransient is a server-side fault, a timeout or a broken connection: the request may or may
// not have been applied, and repeating it later is reasonable.
ErrTransient = errors.New("mynalog: service unavailable")
// ErrPermanent is a rejection that repeating cannot fix — a malformed request, or the API
// having changed shape underneath us. It stops the run and alerts the operator.
ErrPermanent = errors.New("mynalog: request rejected")
// ErrIncomeLimit is the annual professional-income ceiling being exceeded. It means the tax
// regime itself no longer applies and the operator must act rather than debug, so it is
// separated for the sake of the alert wording — but it deliberately WRAPS ErrPermanent, so a
// caller that only asks errors.Is(err, ErrPermanent) still stops. Losing that subsumption would
// let the one fault the operator most needs to hear about slip through as a retryable one.
ErrIncomeLimit = fmt.Errorf("%w: annual income limit exceeded", ErrPermanent)
)
// APIError is a non-2xx answer. Message is the service's own explanation when the body carried
// one; Code is its error code where present. Class is the sentinel this fault was sorted into and
// is what errors.Is matches on.
type APIError struct {
StatusCode int
Code string
Message string
class error
}
// Error renders the status together with the service's own message. It never includes credentials:
// the admin console prints err.Error() verbatim to the operator.
func (e *APIError) Error() string {
msg := e.Message
if msg == "" {
msg = e.Code
}
if msg == "" {
return fmt.Sprintf("mynalog: http %d", e.StatusCode)
}
return fmt.Sprintf("mynalog: http %d: %s", e.StatusCode, msg)
}
// Unwrap exposes the error class so errors.Is(err, ErrPermanent) and friends work.
func (e *APIError) Unwrap() error { return e.class }
// classify sorts an HTTP status and the service's own message into one of the error classes.
//
// The income-limit test is a heuristic over the code and message, because the undocumented API
// gives no stable machine-readable marker for it. Missing it is harmless: the fault stays an
// ErrPermanent, so the run still stops and the operator is still alerted — only the wording of the
// alert is less specific.
func classify(status int, code, message string) error {
switch {
case status == http.StatusUnauthorized:
return ErrAuthExpired
case status == http.StatusTooManyRequests:
return ErrThrottled
case status >= 500:
return ErrTransient
case status >= 400:
if mentionsIncomeLimit(code) || mentionsIncomeLimit(message) {
return ErrIncomeLimit
}
return ErrPermanent
}
return nil
}
// mentionsIncomeLimit reports whether s reads like the annual-ceiling rejection, in either of the
// two languages the service answers in.
func mentionsIncomeLimit(s string) bool {
s = strings.ToLower(s)
return strings.Contains(s, "лимит") || strings.Contains(s, "limit")
}
// Client calls one taxpayer's cabinet. deviceID pins every call to a single synthetic "device", so
// repeated logins look like the same browser rather than a new one each time.
type Client struct {
baseURL string
deviceID string
http *http.Client
}
// NewClient builds a client against baseURL (empty means DefaultBaseURL) identifying itself as
// deviceID. hc may be nil, in which case a client carrying only the ceiling timeout is used;
// per-request cancellation always rides the context.
func NewClient(baseURL, deviceID string, hc *http.Client) *Client {
if baseURL == "" {
baseURL = DefaultBaseURL
}
if hc == nil {
hc = &http.Client{Timeout: RequestTimeout}
}
return &Client{baseURL: strings.TrimSuffix(baseURL, "/"), deviceID: deviceID, http: hc}
}
// DeviceID returns the device identifier this client presents, so callers can persist it alongside
// the refresh token the service issued for it.
func (c *Client) DeviceID() string { return c.deviceID }
// deviceInfo is the device descriptor every authentication call carries.
type deviceInfo struct {
SourceDeviceID string `json:"sourceDeviceId"`
SourceType string `json:"sourceType"`
AppVersion string `json:"appVersion"`
MetaDetails map[string]string `json:"metaDetails"`
}
// userAgent is sent both as the HTTP header and inside deviceInfo, which is what the web cabinet
// does; the service correlates the two.
const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/142.0.0.0 Safari/537.36"
// device describes this client to the authentication endpoints.
func (c *Client) device() deviceInfo {
return deviceInfo{
SourceDeviceID: c.deviceID,
SourceType: "WEB",
AppVersion: "1.0.0",
MetaDetails: map[string]string{"userAgent": userAgent},
}
}
// errorBody is the shape of the service's error responses. Not every fault produces one.
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
// do performs one call and decodes the result into out, which may be nil when the body is not
// needed. token, when non-empty, is sent as the bearer credential. Every failure comes back
// wrapping one of the error classes.
func (c *Client) do(ctx context.Context, method, path, token string, body, out any) error {
var payload io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("mynalog: encode %s %s: %w", method, path, err)
}
payload = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, payload)
if err != nil {
return fmt.Errorf("mynalog: build %s %s: %w", method, path, err)
}
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Referer", c.baseURL+"/")
req.Header.Set("User-Agent", userAgent)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := c.http.Do(req)
if err != nil {
// A refused connection, a reset or a timeout: we cannot tell whether the request was
// applied, so it is transient by definition and the caller must probe before retrying.
return fmt.Errorf("mynalog: %s %s: %w: %v", method, path, ErrTransient, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if err != nil {
return fmt.Errorf("mynalog: read %s %s: %w: %v", method, path, ErrTransient, err)
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
var eb errorBody
// The body is the service's error object on a handled fault and something else entirely on
// an edge failure; either way the status alone is enough to classify.
_ = json.Unmarshal(raw, &eb)
return &APIError{
StatusCode: resp.StatusCode,
Code: eb.Code,
Message: eb.Message,
class: classify(resp.StatusCode, eb.Code, eb.Message),
}
}
if out == nil {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
// A 200 we cannot parse means the contract moved under us: repeating will not help.
return fmt.Errorf("mynalog: decode %s %s: %w: %v", method, path, ErrPermanent, err)
}
return nil
}
+497
View File
@@ -0,0 +1,497 @@
package mynalog
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
)
// msk is the taxpayer's time zone in these tests. It matters: the offset a moment is rendered with
// decides which tax month it lands in.
var msk = time.FixedZone("MSK", 3*60*60)
// capture records what the fake cabinet received, so a test can assert on the request the client
// built. raw is kept alongside the decoded body because some assertions are about JSON *shape* —
// notably that an amount is a number and not a string.
type capture struct {
method string
path string
query string
bearer string
raw string
body map[string]any
}
// fakeCabinet serves one canned response and records the request, returning a client already
// pointed at it.
func fakeCabinet(t *testing.T, status int, response string) (*Client, *capture) {
t.Helper()
got := &capture{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got.method = r.Method
got.path = r.URL.Path
got.query = r.URL.RawQuery
got.bearer = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if data, _ := io.ReadAll(r.Body); len(data) > 0 {
got.raw = string(data)
_ = json.Unmarshal(data, &got.body)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = io.WriteString(w, response)
}))
t.Cleanup(srv.Close)
return NewClient(srv.URL, "device-1", srv.Client()), got
}
// liveSession is a session with a token that has not aged out.
func liveSession() Session {
return Session{Token: "access-1", RefreshToken: "refresh-1", INN: "770000000000",
ExpiresAt: time.Now().Add(time.Hour)}
}
func TestAuthByPasswordBuildsTheRequest(t *testing.T) {
c, got := fakeCabinet(t, http.StatusOK, `{
"token":"access-1","refreshToken":"refresh-1",
"tokenExpireIn":"2026-07-28T15:04:05+03:00",
"profile":{"inn":"770000000000"}}`)
now := time.Date(2026, 7, 28, 14, 0, 0, 0, msk)
sess, err := c.AuthByPassword(context.Background(), "770000000000", "hunter2", now)
if err != nil {
t.Fatalf("auth: %v", err)
}
if got.path != "/api/v1/auth/lkfl" {
t.Fatalf("path = %q", got.path)
}
if got.body["username"] != "770000000000" || got.body["password"] != "hunter2" {
t.Fatalf("credentials not sent: %v", got.body)
}
device, _ := got.body["deviceInfo"].(map[string]any)
if device["sourceDeviceId"] != "device-1" || device["sourceType"] != "WEB" {
t.Fatalf("deviceInfo = %v", device)
}
if sess.Token != "access-1" || sess.RefreshToken != "refresh-1" || sess.INN != "770000000000" {
t.Fatalf("session = %+v", sess)
}
// The stated expiry, less the renewal slack.
want := time.Date(2026, 7, 28, 15, 3, 5, 0, msk)
if !sess.ExpiresAt.Equal(want) {
t.Fatalf("expires = %v, want %v", sess.ExpiresAt, want)
}
}
func TestAuthByPasswordFallsBackToTheLoginForTheINN(t *testing.T) {
c, _ := fakeCabinet(t, http.StatusOK, `{"token":"access-1","refreshToken":"refresh-1"}`)
now := time.Date(2026, 7, 28, 14, 0, 0, 0, msk)
sess, err := c.AuthByPassword(context.Background(), "770000000000", "hunter2", now)
if err != nil {
t.Fatalf("auth: %v", err)
}
if sess.INN != "770000000000" {
t.Fatalf("inn = %q", sess.INN)
}
if !sess.Valid(now) {
t.Fatal("session should be valid right after authenticating")
}
}
// A rejected password is a permanent failure, not an aged-out token: reporting it as ErrAuthExpired
// would send the caller into a pointless refresh loop.
func TestAuthByPasswordRejectionIsPermanent(t *testing.T) {
c, _ := fakeCabinet(t, http.StatusUnauthorized, `{"message":"Неверный логин или пароль"}`)
_, err := c.AuthByPassword(context.Background(), "770000000000", "wrong", time.Now())
if !errors.Is(err, ErrPermanent) {
t.Fatalf("err = %v, want ErrPermanent", err)
}
if errors.Is(err, ErrAuthExpired) {
t.Fatal("a bad password must not look like an expired token")
}
}
func TestAuthByRefreshKeepsTheOldTokenWhenNotRotated(t *testing.T) {
c, got := fakeCabinet(t, http.StatusOK, `{"token":"access-2"}`)
sess, err := c.AuthByRefresh(context.Background(), "refresh-1", "770000000000", time.Now())
if err != nil {
t.Fatalf("refresh: %v", err)
}
if got.path != "/api/v1/auth/token" || got.body["refreshToken"] != "refresh-1" {
t.Fatalf("request = %s %s %v", got.method, got.path, got.body)
}
if sess.RefreshToken != "refresh-1" {
t.Fatalf("refresh token = %q, want the supplied one carried forward", sess.RefreshToken)
}
}
// The service sometimes issues a new refresh token; losing it would strand the next renewal.
func TestAuthByRefreshSurfacesARotatedToken(t *testing.T) {
c, _ := fakeCabinet(t, http.StatusOK, `{"token":"access-2","refreshToken":"refresh-2"}`)
sess, err := c.AuthByRefresh(context.Background(), "refresh-1", "770000000000", time.Now())
if err != nil {
t.Fatalf("refresh: %v", err)
}
if sess.RefreshToken != "refresh-2" {
t.Fatalf("refresh token = %q, want the rotated one", sess.RefreshToken)
}
}
func TestAuthByRefreshRejectionIsPermanent(t *testing.T) {
c, _ := fakeCabinet(t, http.StatusUnauthorized, `{"message":"token is invalid"}`)
_, err := c.AuthByRefresh(context.Background(), "stale", "770000000000", time.Now())
if !errors.Is(err, ErrPermanent) {
t.Fatalf("err = %v, want ErrPermanent — only a fresh password login can recover", err)
}
}
func TestAddIncomeBuildsTheRequest(t *testing.T) {
c, got := fakeCabinet(t, http.StatusOK, `{"approvedReceiptUuid":"20abcdef01"}`)
receipt, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
Name: `Внутриигровая валюта: "Фишка", 100 шт. (ID: 0198c1f2)`,
Amount: "149.50",
OperationTime: time.Date(2026, 7, 28, 23, 30, 0, 0, msk),
RequestTime: time.Date(2026, 7, 29, 0, 5, 0, 0, msk),
})
if err != nil {
t.Fatalf("add income: %v", err)
}
if receipt != "20abcdef01" {
t.Fatalf("receipt = %q", receipt)
}
if got.path != "/api/v1/income" || got.bearer != "access-1" {
t.Fatalf("request = %s %s bearer=%q", got.method, got.path, got.bearer)
}
if got.body["operationTime"] != "2026-07-28T23:30:00+03:00" {
t.Fatalf("operationTime = %v — the offset decides the tax month", got.body["operationTime"])
}
if got.body["requestTime"] != "2026-07-29T00:05:00+03:00" {
t.Fatalf("requestTime = %v", got.body["requestTime"])
}
if got.body["totalAmount"] != "149.50" {
t.Fatalf("totalAmount = %v, want the exact decimal string", got.body["totalAmount"])
}
if got.body["paymentType"] != paymentTypeCash {
t.Fatalf("paymentType = %v", got.body["paymentType"])
}
if got.body["ignoreMaxTotalIncomeRestriction"] != false {
t.Fatal("the annual ceiling must stay enforced")
}
services, _ := got.body["services"].([]any)
if len(services) != 1 {
t.Fatalf("services = %v", got.body["services"])
}
line, _ := services[0].(map[string]any)
if line["quantity"] != float64(1) {
t.Fatalf("quantity = %v", line["quantity"])
}
payer, _ := got.body["client"].(map[string]any)
if payer["incomeType"] != incomeTypeIndividual || payer["inn"] != nil {
t.Fatalf("client = %v — a private buyer carries no INN", payer)
}
}
// The amount must reach the tax service as a JSON number carrying the exact decimal we hold, with
// no float32/float64 round-trip anywhere in between.
func TestAddIncomeSendsTheAmountAsANumber(t *testing.T) {
c, got := fakeCabinet(t, http.StatusOK, `{"approvedReceiptUuid":"20abcdef01"}`)
if _, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
Name: "x", Amount: "149.50",
OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk),
}); err != nil {
t.Fatalf("add income: %v", err)
}
if !strings.Contains(got.raw, `"amount":149.50`) {
t.Fatalf("body = %s, want a bare JSON number for amount", got.raw)
}
}
func TestAddIncomeRejectsAnAmountThatIsNotDecimal(t *testing.T) {
c, _ := fakeCabinet(t, http.StatusOK, `{"approvedReceiptUuid":"20abcdef01"}`)
for _, bad := range []string{"", "1,50", "1e2", "-5", "149.50 RUB"} {
_, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
Name: "x", Amount: bad,
OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk),
})
if !errors.Is(err, ErrPermanent) {
t.Fatalf("amount %q: err = %v, want ErrPermanent", bad, err)
}
}
}
// A 200 with no receipt id leaves the income unannullable, so it counts as ambiguous and the caller
// must probe rather than assume it failed.
func TestAddIncomeWithoutAReceiptIsTransient(t *testing.T) {
c, _ := fakeCabinet(t, http.StatusOK, `{}`)
_, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
Name: "x", Amount: "10",
OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk),
})
if !errors.Is(err, ErrTransient) {
t.Fatalf("err = %v, want ErrTransient", err)
}
}
func TestErrorClassification(t *testing.T) {
cases := []struct {
name string
status int
body string
want error
notWant error
}{
{"expired token", http.StatusUnauthorized, `{"message":"unauthorized"}`, ErrAuthExpired, ErrPermanent},
{"throttled", http.StatusTooManyRequests, `{"message":"slow down"}`, ErrThrottled, ErrPermanent},
{"server fault", http.StatusBadGateway, `{}`, ErrTransient, ErrPermanent},
{"bad request", http.StatusBadRequest, `{"message":"bad operationTime"}`, ErrPermanent, ErrTransient},
{"annual ceiling", http.StatusBadRequest, `{"message":"Превышен лимит дохода"}`, ErrIncomeLimit, ErrTransient},
}
// The ceiling refines a permanent rejection rather than replacing it: a caller that only knows
// about ErrPermanent must still stop on it.
if !errors.Is(ErrIncomeLimit, ErrPermanent) {
t.Fatal("ErrIncomeLimit must subsume ErrPermanent")
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, _ := fakeCabinet(t, tc.status, tc.body)
_, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
Name: "x", Amount: "10",
OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk),
})
if !errors.Is(err, tc.want) {
t.Fatalf("err = %v, want %v", err, tc.want)
}
if errors.Is(err, tc.notWant) {
t.Fatalf("err = %v must not also be %v", err, tc.notWant)
}
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != tc.status {
t.Fatalf("err does not carry the status: %v", err)
}
})
}
}
// A connection that never answers is indistinguishable from one whose request was applied, so it
// has to be transient — the caller probes before retrying.
func TestUnreachableServiceIsTransient(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
url := srv.URL
srv.Close()
c := NewClient(url, "device-1", &http.Client{Timeout: time.Second})
_, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
Name: "x", Amount: "10",
OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk),
})
if !errors.Is(err, ErrTransient) {
t.Fatalf("err = %v, want ErrTransient", err)
}
}
func TestCancelIncomeBuildsTheRequest(t *testing.T) {
c, got := fakeCabinet(t, http.StatusOK, `{}`)
now := time.Date(2026, 8, 1, 10, 0, 0, 0, msk)
if err := c.CancelIncome(context.Background(), liveSession(), "20abcdef01", now); err != nil {
t.Fatalf("cancel: %v", err)
}
if got.path != "/api/v1/cancel" {
t.Fatalf("path = %q", got.path)
}
if got.body["receiptUuid"] != "20abcdef01" {
t.Fatalf("receiptUuid = %v", got.body["receiptUuid"])
}
if got.body["comment"] != cancelReasonRefund {
t.Fatalf("comment = %v, want the reason the service recognises", got.body["comment"])
}
if got.body["operationTime"] != "2026-08-01T10:00:00+03:00" {
t.Fatalf("operationTime = %v", got.body["operationTime"])
}
}
func TestFindIncomeMatchesTheExactName(t *testing.T) {
c, got := fakeCabinet(t, http.StatusOK, `{"content":[
{"name":"другая покупка","approvedReceiptUuid":"other"},
{"name":"нужный чек","approvedReceiptUuid":"wanted"}]}`)
from := time.Date(2026, 7, 28, 0, 0, 0, 0, msk)
to := time.Date(2026, 7, 29, 0, 0, 0, 0, msk)
uuid, found, err := c.FindIncome(context.Background(), liveSession(), "нужный чек", from, to)
if err != nil {
t.Fatalf("find: %v", err)
}
if !found || uuid != "wanted" {
t.Fatalf("found = %v, uuid = %q", found, uuid)
}
if got.path != "/api/v1/incomes" {
t.Fatalf("path = %q", got.path)
}
if !strings.Contains(got.query, "from=2026-07-28T00%3A00%3A00.000%2B03%3A00") {
t.Fatalf("query = %q", got.query)
}
}
// An annulled receipt must never satisfy the probe: annulling it again would fail, and treating it
// as ours would hide an income that really is missing.
func TestFindIncomeSkipsAnnulledReceipts(t *testing.T) {
c, _ := fakeCabinet(t, http.StatusOK, `{"content":[
{"name":"нужный чек","approvedReceiptUuid":"dead","cancellationInfo":{"comment":"Возврат средств"}}]}`)
_, found, err := c.FindIncome(context.Background(), liveSession(), "нужный чек",
time.Now().In(msk).Add(-24*time.Hour), time.Now().In(msk))
if err != nil {
t.Fatalf("find: %v", err)
}
if found {
t.Fatal("an annulled receipt must not be reported as ours")
}
}
func TestFindIncomeReportsAbsence(t *testing.T) {
c, _ := fakeCabinet(t, http.StatusOK, `{"content":[]}`)
_, found, err := c.FindIncome(context.Background(), liveSession(), "нужный чек",
time.Now().In(msk).Add(-24*time.Hour), time.Now().In(msk))
if err != nil {
t.Fatalf("find: %v", err)
}
if found {
t.Fatal("found = true on an empty list")
}
}
func TestDeviceIDForLoginIsStable(t *testing.T) {
first := DeviceIDForLogin("770000000000")
if first != DeviceIDForLogin("770000000000") {
t.Fatal("the device id must not change between runs")
}
if len(first) != deviceIDLength {
t.Fatalf("device id = %q, want %d characters", first, deviceIDLength)
}
if first == DeviceIDForLogin("770000000001") {
t.Fatal("different logins must yield different devices")
}
}
func TestReceiptURL(t *testing.T) {
c := NewClient("https://lknpd.example.test", "device-1", nil)
got := c.ReceiptURL("770000000000", "20abcdef01")
want := "https://lknpd.example.test/api/v1/receipt/770000000000/20abcdef01/print"
if got != want {
t.Fatalf("url = %q, want %q", got, want)
}
}
func TestIncomeName(t *testing.T) {
order := uuid.MustParse("0198c1f2-1111-7000-8000-0000cafe0001")
got := IncomeName(100, order, "")
want := `Внутриигровая валюта: "Фишка", 100 шт. (ID: cafe0001)`
if got != want {
t.Fatalf("name = %q, want %q", got, want)
}
}
// The marker must come from the random tail of a UUIDv7, not its clock-derived head: the first
// eight hex digits are the high bits of a millisecond timestamp and are shared by every order placed
// within about a minute. Two such orders would be indistinguishable to the recovery probe, which is
// how an income ends up filed twice.
func TestIncomeNameMarkerIsNotClockDerived(t *testing.T) {
// Two ids one millisecond apart, differing only in their random tail.
first := uuid.MustParse("0198c1f2-0001-7000-8000-000000000001")
second := uuid.MustParse("0198c1f2-0002-7000-8000-000000000002")
if IncomeName(100, first, "") == IncomeName(100, second, "") {
t.Fatal("two orders in the same time bucket share a service name")
}
}
func TestIncomeNameFallsBackToTheProductTitle(t *testing.T) {
order := uuid.MustParse("0198c1f2-1111-7000-8000-0000cafe0001")
if got := IncomeName(0, order, "Подсказки"); got != "Подсказки (ID: cafe0001)" {
t.Fatalf("name = %q", got)
}
if got := IncomeName(0, order, " "); got != defaultTitle+" (ID: cafe0001)" {
t.Fatalf("name = %q, want the default description", got)
}
}
// The marker is what makes the post-timeout probe exact, so the description gives way first and the
// marker is never truncated.
func TestIncomeNameCapsLengthButKeepsTheMarker(t *testing.T) {
order := uuid.MustParse("0198c1f2-1111-7000-8000-0000cafe0001")
got := IncomeName(0, order, strings.Repeat("Ы", 400))
if n := len([]rune(got)); n != maxNameRunes {
t.Fatalf("length = %d runes, want %d", n, maxNameRunes)
}
if !strings.HasSuffix(got, "(ID: cafe0001)") {
t.Fatalf("name = %q, the marker must survive truncation", got)
}
}
func TestCryptoRoundTrip(t *testing.T) {
key, err := ParseKey("YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=")
if err != nil {
t.Fatalf("parse key: %v", err)
}
blob, err := Seal(key, "refresh-1")
if err != nil {
t.Fatalf("seal: %v", err)
}
if strings.Contains(string(blob), "refresh-1") {
t.Fatal("the sealed blob still contains the plaintext")
}
got, err := Open(key, blob)
if err != nil {
t.Fatalf("open: %v", err)
}
if got != "refresh-1" {
t.Fatalf("opened %q", got)
}
}
// Losing or rotating the key must be a nuisance — sign in again — not a crash.
func TestOpenWithAWrongKeyIsUndecryptable(t *testing.T) {
good, _ := ParseKey("YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=")
other, _ := ParseKey("MTIzNDU2NzhhYmNkZWZnaGlqa2xtbm9wcXJzdHV2dzA=")
blob, err := Seal(good, "refresh-1")
if err != nil {
t.Fatalf("seal: %v", err)
}
if _, err := Open(other, blob); !errors.Is(err, ErrUndecryptable) {
t.Fatalf("err = %v, want ErrUndecryptable", err)
}
if _, err := Open(good, blob[:4]); !errors.Is(err, ErrUndecryptable) {
t.Fatalf("truncated blob: err = %v, want ErrUndecryptable", err)
}
}
func TestParseKey(t *testing.T) {
if _, err := ParseKey("6162636465666768696a6b6c6d6e6f707172737475767778797a313233343536"); err != nil {
t.Fatalf("hex key rejected: %v", err)
}
for _, bad := range []string{"", "short", "YWJj"} {
if _, err := ParseKey(bad); err == nil {
t.Fatalf("ParseKey(%q) accepted a key of the wrong size", bad)
}
}
}
+85
View File
@@ -0,0 +1,85 @@
package mynalog
import (
"fmt"
"net/url"
"strings"
"unicode/utf8"
"github.com/google/uuid"
)
// maxNameRunes is the tax service's ceiling on a service name, counted in characters rather than
// bytes — the names here are Cyrillic, so the two differ by a factor of two.
const maxNameRunes = 128
// markerRunes is how much of the order id rides in the name.
//
// It is taken from the END of the identifier, and that is not cosmetic. Our order ids are UUIDv7,
// whose leading hex digits are a millisecond timestamp: the first eight of them are the high 32
// bits of that timestamp, so they only change about once a minute and every order placed within the
// same minute would share them. The trailing digits are random. Since this marker is the sole key
// the recovery probe matches on, a shared marker would make two purchases indistinguishable — which
// is precisely the situation that ends in an income filed twice or not at all.
const markerRunes = 8
// defaultTitle describes the goods when the product carries no usable title of its own. It has to
// read as a plain description of what was sold, because the tax service and the buyer both see it.
const defaultTitle = "Цифровые ценности"
// IncomeName builds the service name for one purchase: a human-readable description of what was
// sold, followed by a marker derived from the order id.
//
// The marker is not decoration. Registering an income is not idempotent, so after a timeout the
// only way to discover whether the receipt exists is to search the tax service's income list by
// this exact string — and without the marker two identical chip packs would be indistinguishable,
// which would either duplicate the income or lose it. The result is capped at maxNameRunes with the
// description giving way first: the marker is never truncated.
//
// chips is the pack size; when it is zero the product is not a chip pack and fallbackTitle
// describes it instead.
func IncomeName(chips int, orderID uuid.UUID, fallbackTitle string) string {
desc := strings.TrimSpace(fallbackTitle)
if chips > 0 {
desc = fmt.Sprintf("Внутриигровая валюта: %q, %d шт.", "Фишка", chips)
}
if desc == "" {
desc = defaultTitle
}
suffix := fmt.Sprintf(" (ID: %s)", orderMarker(orderID))
budget := maxNameRunes - utf8.RuneCountInString(suffix)
if budget < 1 {
return suffix
}
if utf8.RuneCountInString(desc) > budget {
desc = strings.TrimSpace(string([]rune(desc)[:budget-1])) + "…"
}
return desc + suffix
}
// orderMarker renders the part of the order id that rides in a service name: its last markerRunes
// hex digits, which for a UUIDv7 are random rather than clock-derived.
func orderMarker(orderID uuid.UUID) string {
s := orderID.String()
if len(s) < markerRunes {
return s
}
return s[len(s)-markerRunes:]
}
// ReceiptURL is where the printable receipt for a registered income lives. It is what the buyer is
// sent as their proof of purchase, so it is a package function rather than a method: the letter is
// composed far from any authenticated client.
func ReceiptURL(baseURL, inn, receiptUUID string) string {
if baseURL == "" {
baseURL = DefaultBaseURL
}
return fmt.Sprintf("%s/api/v1/receipt/%s/%s/print",
strings.TrimSuffix(baseURL, "/"), url.PathEscape(inn), url.PathEscape(receiptUUID))
}
// ReceiptURL is where the printable receipt for a registered income lives, against this client's
// service root.
func (c *Client) ReceiptURL(inn, receiptUUID string) string {
return ReceiptURL(c.baseURL, inn, receiptUUID)
}
+657
View File
@@ -0,0 +1,657 @@
// Package mynalogsync registers the direct rail's rouble income with the professional-income tax
// service and annuls a receipt when the money is returned. It is the orchestration layer between
// the payments domain, which knows what is owed, and the mynalog client, which knows how to say it.
//
// One engine serves both ways of running it. The admin console calls RunBatch with a short budget
// so the page does not hang; the background worker calls the same RunBatch with a long one. There
// is deliberately no second implementation, because the delicate part — deciding what a failed
// registration means — must not exist twice.
//
// Two rules shape the whole design, and both come from the tax API accepting no idempotency key:
//
// - Exactly one run at a time, enforced by an advisory lock. Two concurrent runs could file the
// same income twice, and an over-filed income costs real money to unpick.
// - An outcome that cannot be established stops everything. Where a normal integration would
// retry, this one stops and asks a human, because the two ways of being wrong are "the buyer's
// income was never declared" and "it was declared twice", and no automatic choice between them
// is safe.
package mynalogsync
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/mynalog"
"scrabble/backend/internal/payments"
)
// Pacing and sizing of a run. None of these are configurable at runtime: they are the shape of the
// rail, not an operational preference.
const (
// WorkerInterval is how often the automatic export wakes. A tick with an empty queue costs one
// local query and no traffic at all to the tax service.
WorkerInterval = 15 * time.Minute
// WorkerBudget bounds an automatic run to comfortably less than the interval, so a long queue
// simply continues on the next tick instead of overlapping with it.
WorkerBudget = 13 * time.Minute
// ConsoleBudget bounds a run started from the admin console, so the operator's request returns
// while they are still looking at it.
ConsoleBudget = 100 * time.Second
// defaultPace is the gap between two calls to the tax service. It is the whole of our politeness
// towards an undocumented API: everything else about a run is bounded by time, not by count.
defaultPace = 2 * time.Second
// queueLimit caps how much of the queue one run reads.
queueLimit = 500
// probeWindow is how far back the post-failure probe searches for our own receipt. A day is
// ample: the income being probed was attempted moments ago.
probeWindow = 24 * time.Hour
// permanentStreakLimit is how many consecutive unfixable rejections take the rail out of
// service. A changed API rejects everything, and the alternative to stopping is thousands of
// futile requests overnight.
permanentStreakLimit = 3
// downtimeAlertAfter is how long the tax service may be unreachable before it is worth a letter.
// Below that it is noise: the service is often briefly unavailable and the queue simply waits.
downtimeAlertAfter = 24 * time.Hour
)
// Alert kinds. They double as the deduplication key: a persistent fault produces one letter a day
// per kind rather than one per run.
const (
alertPaused = "paused"
alertUnknown = "unknown"
alertLimit = "limit"
alertDown = "down"
alertSignIn = "signin"
// The three filing-deadline kinds are distinct so that an escalation is never suppressed by the
// previous, gentler letter still being within its deduplication window.
alertBacklog = "backlog"
alertBacklogDue = "backlog-due"
alertBacklogOverdue = "backlog-overdue"
)
// ErrNotSignedIn is returned when the rail has no usable session. Only an operator can fix it, by
// signing in again with the tax cabinet password.
var ErrNotSignedIn = errors.New("mynalogsync: not signed in to the tax cabinet")
// ErrBusy is returned when another run already holds the export lock.
var ErrBusy = errors.New("mynalogsync: an export is already running")
// Alerter delivers an operator alert. kind is the deduplication key, not part of the message.
type Alerter interface {
Alert(ctx context.Context, kind, subject, body string) error
}
// Config is the deployment-time shape of the rail. An empty Key disables it entirely: without
// somewhere safe to keep the refresh token there is no way to stay signed in.
type Config struct {
Key []byte
BaseURL string
Location *time.Location
// Pace overrides the gap between calls. It exists for tests, which would otherwise take
// minutes; production leaves it zero.
Pace time.Duration
}
// Exporter registers income with the tax service on behalf of one taxpayer.
type Exporter struct {
payments *payments.Service
alert Alerter
log *zap.Logger
cfg Config
http *http.Client
now func() time.Time
}
// New builds an Exporter. It returns nil when cfg carries no encryption key, which is how the rail
// stays dormant on a deployment that has not configured it.
func New(svc *payments.Service, cfg Config, alert Alerter, log *zap.Logger) *Exporter {
if svc == nil || len(cfg.Key) == 0 {
return nil
}
if cfg.Location == nil {
cfg.Location = time.UTC
}
if cfg.Pace <= 0 {
cfg.Pace = defaultPace
}
return &Exporter{
payments: svc,
alert: alert,
log: log,
cfg: cfg,
// One client shared across a run so connections pool; the ceiling is the API client's own,
// and per-request cancellation still rides the context.
http: &http.Client{Timeout: mynalog.RequestTimeout},
now: func() time.Time { return time.Now().UTC() },
}
}
// Summary is what one run did. Stopped explains an early exit in words an operator can act on; it
// is empty when the run simply ran out of work.
type Summary struct {
Registered int
Cancelled int
NotRequired int
Failed int
Unknown int
Busy bool
Paused bool
Stopped string
}
// clientFor builds a tax-cabinet client presenting the given device.
func (e *Exporter) clientFor(deviceID string) *mynalog.Client {
return mynalog.NewClient(e.cfg.BaseURL, deviceID, e.http)
}
// Location is the taxpayer's time zone. Every moment sent to the tax service is rendered in it,
// because the offset decides which tax period an income falls into.
func (e *Exporter) Location() *time.Location { return e.cfg.Location }
// ReceiptURL is where a registered receipt can be read, against the configured service root.
func (e *Exporter) ReceiptURL(inn, receiptUUID string) string {
return mynalog.ReceiptURL(e.cfg.BaseURL, inn, receiptUUID)
}
// ReceiptName renders exactly the service description an income would be filed under. The console
// shows it, the manual-entry export lists it, and the recovery probe searches for it — all three
// must agree, so they all come from here.
func (e *Exporter) ReceiptName(in payments.MyNalogIncome) string {
if in.ReceiptName != "" {
return in.ReceiptName // already frozen by an earlier attempt; the probe depends on it
}
return mynalog.IncomeName(in.Chips, in.OrderID, in.Title)
}
// SignIn exchanges the cabinet password for a session and stores it. Only the refresh token is
// kept, sealed with the configured key; the password is not persisted anywhere, which is why this
// is an interactive action rather than a configuration value.
func (e *Exporter) SignIn(ctx context.Context, login, password string) error {
deviceID := mynalog.DeviceIDForLogin(login)
sess, err := e.clientFor(deviceID).AuthByPassword(ctx, login, password, e.now())
if err != nil {
return err
}
if sess.RefreshToken == "" {
return fmt.Errorf("mynalogsync: the tax cabinet issued no refresh token: %w", mynalog.ErrPermanent)
}
sealed, err := mynalog.Seal(e.cfg.Key, sess.RefreshToken)
if err != nil {
return err
}
return e.payments.SaveMyNalogSession(ctx, sess.INN, deviceID, sealed)
}
// SignOut forgets the stored session, which also disarms the automatic export.
func (e *Exporter) SignOut(ctx context.Context) error {
return e.payments.DropMyNalogSession(ctx)
}
// session renews the stored session and returns a client bound to it. A rotated refresh token is
// persisted immediately: losing one would strand the next renewal and force a password login.
func (e *Exporter) session(ctx context.Context) (mynalog.Session, *mynalog.Client, error) {
stored, ok, err := e.payments.MyNalogSession(ctx)
if err != nil {
return mynalog.Session{}, nil, err
}
if !ok {
return mynalog.Session{}, nil, ErrNotSignedIn
}
refresh, err := mynalog.Open(e.cfg.Key, stored.RefreshTokenEnc)
if err != nil {
// The key was rotated or lost. Nothing is broken beyond repair; the operator signs in again.
return mynalog.Session{}, nil, fmt.Errorf("%w: %v", ErrNotSignedIn, err)
}
client := e.clientFor(stored.DeviceID)
sess, err := client.AuthByRefresh(ctx, refresh, stored.INN, e.now())
if err != nil {
return mynalog.Session{}, nil, err
}
if sess.RefreshToken != refresh {
sealed, err := mynalog.Seal(e.cfg.Key, sess.RefreshToken)
if err != nil {
return mynalog.Session{}, nil, err
}
if err := e.payments.UpdateMyNalogRefreshToken(ctx, sealed); err != nil {
return mynalog.Session{}, nil, err
}
}
return sess, client, nil
}
// RunBatch performs one export run within budget and reports what it did.
//
// The order is deliberate. Local bookkeeping happens first, so progress is made even when the tax
// service is unreachable. Annulments go before registrations, because an annulment removes income
// from the tax base and is the more time-sensitive of the two. And nothing authenticates until the
// queue is known to be non-empty, so an idle installation is invisible to the tax service.
func (e *Exporter) RunBatch(ctx context.Context, budget time.Duration) (Summary, error) {
release, ok, err := e.payments.TryLockMyNalog(ctx)
if err != nil {
return Summary{}, err
}
if !ok {
return Summary{Busy: true}, nil
}
defer release()
var sum Summary
deadline := e.now().Add(budget)
// A row still marked as being sent belongs to a run that died mid-call. Under the lock nothing
// is genuinely in flight, so it becomes an unresolved outcome rather than a silent retry.
if stranded, err := e.payments.PromoteStrandedSending(ctx); err != nil {
return sum, err
} else if stranded > 0 {
e.log.Warn("mynalog export found stranded rows", zap.Int("count", stranded))
}
queue, err := e.payments.MyNalogQueue(ctx, queueLimit)
if err != nil {
return sum, err
}
// Refunded before ever being filed: nothing to declare and nothing to annul. Purely local.
for _, in := range queue.NotRequired {
if err := e.payments.MarkMyNalogNotRequired(ctx, in,
"the money was returned before the income was ever filed"); err != nil {
return sum, err
}
sum.NotRequired++
}
stored, ok, err := e.payments.MyNalogSession(ctx)
if err != nil {
return sum, err
}
if !ok {
if len(queue.Incomes) > 0 || len(queue.Cancels) > 0 {
return sum, ErrNotSignedIn
}
return sum, nil
}
if stored.Paused() {
sum.Paused = true
sum.Stopped = stored.PauseReason
return sum, nil
}
if len(queue.Incomes) == 0 && len(queue.Cancels) == 0 {
return sum, nil
}
sess, client, err := e.session(ctx)
if err != nil {
return e.handleSessionFailure(ctx, sum, err)
}
run := &runner{e: e, client: client, sess: sess}
sum, err = run.cancels(ctx, queue.Cancels, deadline, sum)
if err != nil {
return sum, err
}
// An unresolved outcome from an earlier run blocks new filings. It does not block annulments —
// those only ever remove income — but it does mean the operator must look before more income is
// declared, which is the only way the backlog of unknowns stays at zero.
if len(queue.Attention) > 0 {
sum.Unknown = len(queue.Attention)
sum.Stopped = "there are incomes with an unresolved outcome; resolve them before filing more"
e.raise(ctx, alertUnknown, "«Мой налог»: есть чеки с неизвестным исходом",
fmt.Sprintf("Выгрузка остановлена: %d приход(ов) в состоянии «неизвестно». "+
"Откройте раздел «Мой налог» в админ-консоли и разберите их — проверьте повторно "+
"или введите УИД вручную. Пока они не разобраны, новые чеки не отправляются.",
len(queue.Attention)))
return sum, nil
}
return run.incomes(ctx, queue.Incomes, deadline, sum)
}
// runner carries the authenticated session through one run so that a token which ages out mid-queue
// can be renewed in place instead of derailing the income it happened to land on.
type runner struct {
e *Exporter
client *mynalog.Client
sess mynalog.Session
reauthed bool
}
// reauth renews the session once per run. Twice would mean something other than expiry is wrong,
// and looping on it would hammer the authentication endpoint.
func (r *runner) reauth(ctx context.Context) error {
if r.reauthed {
return errors.New("mynalogsync: the session was already renewed once this run")
}
sess, client, err := r.e.session(ctx)
if err != nil {
return err
}
r.sess, r.client, r.reauthed = sess, client, true
return nil
}
// call runs op, renewing the session and retrying once when the access token turns out to have aged
// out mid-run.
//
// The retry is safe precisely because a rejected token is refused before the request is processed:
// no receipt can have been created, so this is the one failure that may be repeated without a probe.
// Treating it as an unknown outcome instead would halt the queue every hour for no reason.
func (r *runner) call(ctx context.Context, op func() error) error {
err := op()
if !errors.Is(err, mynalog.ErrAuthExpired) || r.reauthed {
return err
}
if authErr := r.reauth(ctx); authErr != nil {
return fmt.Errorf("%w: renewing the session failed: %v", mynalog.ErrPermanent, authErr)
}
return op()
}
// handleSessionFailure turns a failed sign-in into an alert and, where the fault is unfixable by
// repetition, takes the rail out of service.
func (e *Exporter) handleSessionFailure(ctx context.Context, sum Summary, err error) (Summary, error) {
switch {
case errors.Is(err, ErrNotSignedIn), errors.Is(err, mynalog.ErrPermanent):
reason := "сессия «Мой налог» недействительна — нужно войти заново"
if pauseErr := e.payments.PauseMyNalog(ctx, reason); pauseErr != nil {
return sum, pauseErr
}
sum.Paused = true
sum.Stopped = reason
e.raise(ctx, alertSignIn, "«Мой налог»: требуется вход",
"Автоматическая выгрузка остановлена: сохранённая сессия больше не действует. "+
"Откройте раздел «Мой налог» в админ-консоли и войдите заново по логину и паролю.")
return sum, nil
default:
sum.Stopped = "сервис «Мой налог» недоступен"
e.noteDowntime(ctx)
return sum, nil
}
}
// cancels annuls receipts whose income was refunded.
func (r *runner) cancels(ctx context.Context, cancels []payments.MyNalogCancel,
deadline time.Time, sum Summary) (Summary, error) {
e := r.e
for _, c := range cancels {
if e.now().After(deadline) {
sum.Stopped = "время прогона вышло; остаток уйдёт следующим прогоном"
return sum, nil
}
err := r.call(ctx, func() error {
return r.client.CancelIncome(ctx, r.sess, c.ReceiptUUID, e.now().In(e.cfg.Location))
})
switch {
case err == nil:
if err := e.payments.MarkMyNalogCancelled(ctx, c.LedgerID, c.RefundLedgerID); err != nil {
return sum, err
}
_ = e.payments.NoteMyNalogOK(ctx)
sum.Cancelled++
case errors.Is(err, mynalog.ErrThrottled):
sum.Stopped = "сервис «Мой налог» просит снизить темп"
return sum, nil
case errors.Is(err, mynalog.ErrPermanent):
// The receipt stands and repeating will not change that; record it and let the operator
// see it rather than hammering the same call.
if markErr := e.payments.MarkMyNalogCancelFailed(ctx, c.LedgerID, c.RefundLedgerID, err.Error()); markErr != nil {
return sum, markErr
}
sum.Failed++
default:
sum.Stopped = "сервис «Мой налог» недоступен"
e.noteDowntime(ctx)
return sum, nil
}
if err := e.pause(ctx); err != nil {
return sum, nil
}
}
return sum, nil
}
// incomes files income, one at a time, until the queue empties or the budget runs out.
func (r *runner) incomes(ctx context.Context, incomes []payments.MyNalogIncome,
deadline time.Time, sum Summary) (Summary, error) {
e := r.e
streak := 0
for _, in := range incomes {
if e.now().After(deadline) {
sum.Stopped = "время прогона вышло; остаток уйдёт следующим прогоном"
return sum, nil
}
name := e.ReceiptName(in)
// Frozen before the call, and once per income: the name is the probe's only handle, and the
// attempt counter should count incomes tried, not tokens renewed.
if err := e.payments.BeginMyNalogSend(ctx, in, name); err != nil {
return sum, err
}
var receipt string
err := r.call(ctx, func() error {
var callErr error
receipt, callErr = r.client.AddIncome(ctx, r.sess, mynalog.IncomeRequest{
Name: name,
Amount: in.Amount.Major(),
OperationTime: in.OperationTime.In(e.cfg.Location),
RequestTime: e.now().In(e.cfg.Location),
})
return callErr
})
switch {
case err == nil:
if err := e.payments.MarkMyNalogSent(ctx, in.LedgerID, receipt, false); err != nil {
return sum, err
}
_ = e.payments.NoteMyNalogOK(ctx)
sum.Registered++
streak = 0
case errors.Is(err, mynalog.ErrThrottled):
// Nothing was filed; the row goes back into the queue for the next run.
if markErr := e.payments.MarkMyNalogOutcome(ctx, in.LedgerID, payments.MyNalogFailed, err.Error()); markErr != nil {
return sum, markErr
}
sum.Stopped = "сервис «Мой налог» просит снизить темп"
return sum, nil
case errors.Is(err, mynalog.ErrPermanent):
if markErr := e.payments.MarkMyNalogOutcome(ctx, in.LedgerID, payments.MyNalogFailed, err.Error()); markErr != nil {
return sum, markErr
}
sum.Failed++
streak++
if errors.Is(err, mynalog.ErrIncomeLimit) {
return e.stop(ctx, sum, alertLimit, "превышен годовой лимит дохода по НПД",
"«Мой налог» отклонил чек: превышен годовой лимит дохода. Это уже не техническая "+
"ошибка — режим НПД больше не применяется. Выгрузка остановлена.")
}
if streak >= permanentStreakLimit {
return e.stop(ctx, sum, alertPaused, "«Мой налог» отклоняет чеки подряд",
fmt.Sprintf("Подряд отклонено %d чеков — похоже, изменился формат API или данные. "+
"Выгрузка поставлена на паузу, чтобы не долбить сервис. Последняя ошибка: %v",
streak, err))
}
default:
// The outcome is genuinely unknown: ask the tax service whether our receipt exists.
if e.resolveByProbe(ctx, r.client, r.sess, in, name) {
_ = e.payments.NoteMyNalogOK(ctx)
sum.Registered++
streak = 0
break
}
if markErr := e.payments.MarkMyNalogOutcome(ctx, in.LedgerID, payments.MyNalogUnknown, err.Error()); markErr != nil {
return sum, markErr
}
sum.Unknown++
sum.Stopped = "исход отправки не удалось установить; дальше — только вручную"
e.raise(ctx, alertUnknown, "«Мой налог»: исход отправки неизвестен",
fmt.Sprintf("Чек на сумму %s не удалось подтвердить: сервис не ответил, а поиск по "+
"названию «%s» ничего не нашёл. Выгрузка остановлена, чтобы не задвоить доход. "+
"В админ-консоли, раздел «Мой налог»: «проверить ещё раз», либо введите УИД "+
"вручную, либо верните приход в очередь. Ошибка: %v", in.Amount, name, err))
return sum, nil
}
if err := e.pause(ctx); err != nil {
return sum, nil
}
}
return sum, nil
}
// resolveByProbe asks the tax service whether the receipt we just failed to confirm exists after
// all, recording it when it does. It reports whether the income ended up filed.
//
// This is the whole recovery story for a non-idempotent write: without it a timeout would leave
// every ambiguous income to be sorted out by hand.
func (e *Exporter) resolveByProbe(ctx context.Context, client *mynalog.Client, sess mynalog.Session,
in payments.MyNalogIncome, name string) bool {
from := in.OperationTime.In(e.cfg.Location).Add(-probeWindow)
to := e.now().In(e.cfg.Location).Add(time.Minute)
receipt, found, err := client.FindIncome(ctx, sess, name, from, to)
if err != nil || !found {
return false
}
if err := e.payments.MarkMyNalogSent(ctx, in.LedgerID, receipt, false); err != nil {
e.log.Error("mynalog probe found the receipt but it could not be recorded",
zap.String("ledger", in.LedgerID.String()), zap.Error(err))
return false
}
e.log.Info("mynalog receipt recovered by probe after an ambiguous call",
zap.String("ledger", in.LedgerID.String()), zap.String("receipt", receipt))
return true
}
// stop takes the rail out of service, alerts, and ends the run.
func (e *Exporter) stop(ctx context.Context, sum Summary, kind, reason, body string) (Summary, error) {
if err := e.payments.PauseMyNalog(ctx, reason); err != nil {
return sum, err
}
sum.Paused = true
sum.Stopped = reason
e.raise(ctx, kind, "«Мой налог»: выгрузка остановлена", body)
return sum, nil
}
// pause waits out the gap between two calls, returning the context's error if it ends first.
func (e *Exporter) pause(ctx context.Context) error {
timer := time.NewTimer(e.cfg.Pace)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
// noteDowntime alerts only once the tax service has been unreachable long enough to be worth a
// letter. Below that threshold an outage is normal and the queue simply waits for it to pass.
func (e *Exporter) noteDowntime(ctx context.Context) {
stored, ok, err := e.payments.MyNalogSession(ctx)
if err != nil || !ok {
return
}
since := stored.UpdatedAt
if stored.LastOKAt != nil {
since = *stored.LastOKAt
}
if e.now().Sub(since) < downtimeAlertAfter {
return
}
e.raise(ctx, alertDown, "«Мой налог»: сервис недоступен больше суток",
fmt.Sprintf("Последняя успешная отправка была %s. Очередь ждёт; если сервис не поднимется, "+
"выгрузите чеки вручную из раздела «Мой налог» в админ-консоли.",
since.In(e.cfg.Location).Format("2006-01-02 15:04")))
}
// raise sends an operator alert unless one of the same kind went out within the last day. A
// persistent fault should be one letter a day, not one per run.
func (e *Exporter) raise(ctx context.Context, kind, subject, body string) {
if e.alert == nil {
return
}
stored, ok, err := e.payments.MyNalogSession(ctx)
if err == nil && ok && stored.LastAlertKind == kind && stored.LastAlertAt != nil &&
e.now().Sub(*stored.LastAlertAt) < 24*time.Hour {
return
}
if err := e.alert.Alert(ctx, kind, subject, body); err != nil {
e.log.Error("mynalog alert could not be delivered", zap.String("kind", kind), zap.Error(err))
return
}
if err := e.payments.NoteMyNalogAlert(ctx, kind); err != nil {
e.log.Error("mynalog alert could not be recorded", zap.String("kind", kind), zap.Error(err))
}
}
// Recheck probes the tax service once for an income whose outcome was never established, and
// reports whether the receipt turned out to exist.
func (e *Exporter) Recheck(ctx context.Context, ledgerID uuid.UUID) (bool, error) {
in, ok, err := e.payments.MyNalogIncomeAt(ctx, ledgerID)
if err != nil {
return false, err
}
if !ok {
return false, fmt.Errorf("mynalogsync: no such income")
}
release, locked, err := e.payments.TryLockMyNalog(ctx)
if err != nil {
return false, err
}
if !locked {
return false, ErrBusy
}
defer release()
sess, client, err := e.session(ctx)
if err != nil {
return false, err
}
return e.resolveByProbe(ctx, client, sess, in, e.ReceiptName(in)), nil
}
// RecordManual records an income the operator filed by hand in the tax cabinet, along with the
// receipt identifier it was given. Such a row is thereafter treated like any other: in particular a
// later refund annuls its receipt automatically, which is the whole reason the identifier is asked
// for rather than merely ticking the income off.
func (e *Exporter) RecordManual(ctx context.Context, ledgerID uuid.UUID, receiptUUID string) error {
in, ok, err := e.payments.MyNalogIncomeAt(ctx, ledgerID)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("mynalogsync: no such income")
}
return e.payments.MarkMyNalogManual(ctx, in, e.ReceiptName(in), receiptUUID)
}
// Requeue puts an income whose outcome was unresolved back into the queue. It is the operator
// asserting what the software may not assume: that the receipt really was never created.
func (e *Exporter) Requeue(ctx context.Context, ledgerID uuid.UUID) error {
return e.payments.MarkMyNalogOutcome(ctx, ledgerID, payments.MyNalogFailed,
"returned to the queue by an operator")
}
// Skip rules an income out of the export for a reason the operator gives.
func (e *Exporter) Skip(ctx context.Context, ledgerID uuid.UUID, reason string) error {
in, ok, err := e.payments.MyNalogIncomeAt(ctx, ledgerID)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("mynalogsync: no such income")
}
return e.payments.MarkMyNalogNotRequired(ctx, in, reason)
}
+260
View File
@@ -0,0 +1,260 @@
package mynalogsync
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/mynalog"
"scrabble/backend/internal/payments"
)
// MailInterval is how often the letter queues are drained. Nothing here talks to the tax service,
// so a short tick costs only a local query.
const MailInterval = time.Minute
// mailBatch caps one drain, so a backlog is delivered over several ticks rather than in one burst
// through the relay.
const mailBatch = 50
// EmailLookup resolves an account's confirmed address. It is the seam onto the account domain,
// which the payments side must not import wholesale.
type EmailLookup interface {
ConfirmedEmail(ctx context.Context, accountID uuid.UUID) (string, bool, error)
}
// Mailer tells buyers what happened to their purchase: that it went through, that a tax receipt
// was issued for it, and — if the money went back — that the receipt has been annulled.
//
// It is driven entirely by durable queues rather than by the payment path. The purchase letter
// rides the existing payment-event outbox on its own cursor, and the two receipt letters ride
// columns on the export row, so nothing in the crediting or refunding code had to change and no
// letter is lost to a relay hiccup.
type Mailer struct {
payments *payments.Service
emails EmailLookup
mail account.Mailer
from string
baseURL string
loc *time.Location
log *zap.Logger
}
// NewMailer builds the buyer mailer. It returns nil when there is no relay or no sender address, so
// an installation without email simply does not write to buyers.
func NewMailer(svc *payments.Service, emails EmailLookup, mail account.Mailer,
from, baseURL string, loc *time.Location, log *zap.Logger) *Mailer {
if svc == nil || emails == nil || mail == nil || from == "" {
return nil
}
if loc == nil {
loc = time.UTC
}
return &Mailer{payments: svc, emails: emails, mail: mail, from: from,
baseURL: baseURL, loc: loc, log: log}
}
// Run drains the letter queues until the context ends.
func (m *Mailer) Run(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
m.Tick(ctx)
}
}
}
// Tick drains one batch of each queue. Run calls it on a cadence; it is exported so a test can
// drive one drain deterministically instead of waiting on a ticker.
func (m *Mailer) Tick(ctx context.Context) {
m.sendPurchaseMails(ctx)
m.sendReceiptMails(ctx, false)
m.sendReceiptMails(ctx, true)
}
// sendPurchaseMails tells buyers their purchase went through.
func (m *Mailer) sendPurchaseMails(ctx context.Context) {
pending, err := m.payments.PendingPurchaseMails(ctx, mailBatch)
if err != nil {
m.log.Error("purchase mail queue could not be read", zap.Error(err))
return
}
for _, p := range pending {
// Only the direct rail writes to buyers: a store purchase is confirmed by the store itself,
// and no tax receipt of ours follows it.
if p.Origin != string(payments.SourceDirect) || p.OrderID == uuid.Nil {
m.stampPurchase(ctx, p.EventID)
continue
}
to, ok, err := m.emails.ConfirmedEmail(ctx, p.AccountID)
if err != nil {
m.log.Error("purchase mail address lookup failed",
zap.String("event", p.EventID.String()), zap.Error(err))
continue
}
if !ok {
// A direct purchase requires a confirmed address, so this should not happen — but a
// later address removal must not leave the event undecided forever.
m.stampPurchase(ctx, p.EventID)
continue
}
msg := account.Message{
From: m.from,
To: to,
Subject: "Эрудит — покупка совершена",
Text: purchaseText(p),
}
if err := m.mail.Send(ctx, msg); err != nil {
m.log.Error("purchase mail could not be delivered",
zap.String("event", p.EventID.String()), zap.Error(err))
continue // left undecided on purpose: the next tick tries again
}
m.stampPurchase(ctx, p.EventID)
}
}
// stampPurchase records that the decision for an event has been made, whether or not a letter went.
func (m *Mailer) stampPurchase(ctx context.Context, eventID uuid.UUID) {
if err := m.payments.MarkPurchaseMailed(ctx, eventID); err != nil {
m.log.Error("purchase mail could not be marked",
zap.String("event", eventID.String()), zap.Error(err))
}
}
// sendReceiptMails delivers the tax-receipt letter, or the annulment letter when cancelled is true.
func (m *Mailer) sendReceiptMails(ctx context.Context, cancelled bool) {
read := m.payments.PendingReceiptMails
mark := m.payments.MarkReceiptMailed
if cancelled {
read = m.payments.PendingCancelMails
mark = m.payments.MarkCancelMailed
}
pending, err := read(ctx, mailBatch)
if err != nil {
m.log.Error("receipt mail queue could not be read", zap.Error(err))
return
}
if len(pending) == 0 {
return
}
stored, ok, err := m.payments.MyNalogSession(ctx)
if err != nil || !ok {
// The taxpayer id lives on the session and the receipt link cannot be built without it.
// Signing back in makes these letters go out; until then they simply wait.
return
}
for _, r := range pending {
to, ok, err := m.emails.ConfirmedEmail(ctx, r.AccountID)
if err != nil {
m.log.Error("receipt mail address lookup failed",
zap.String("ledger", r.LedgerID.String()), zap.Error(err))
continue
}
if !ok {
m.stampReceipt(ctx, mark, r.LedgerID)
continue
}
subject, body := "Эрудит — чек от налоговой", receiptText(r, stored.INN, m.baseURL, m.loc)
if cancelled {
subject, body = "Эрудит — чек аннулирован", cancelText(r, m.loc)
}
if err := m.mail.Send(ctx, account.Message{From: m.from, To: to, Subject: subject, Text: body}); err != nil {
m.log.Error("receipt mail could not be delivered",
zap.String("ledger", r.LedgerID.String()), zap.Error(err))
continue
}
m.stampReceipt(ctx, mark, r.LedgerID)
}
}
// stampReceipt records that the letter decision for an income has been made.
func (m *Mailer) stampReceipt(ctx context.Context, mark func(context.Context, uuid.UUID) error, ledgerID uuid.UUID) {
if err := mark(ctx, ledgerID); err != nil {
m.log.Error("receipt mail could not be marked",
zap.String("ledger", ledgerID.String()), zap.Error(err))
}
}
// describe renders what was bought in one line.
func describe(title string, chips int, amountMinor int64, currency string) string {
what := strings.TrimSpace(title)
if what == "" {
what = "покупка в игре"
}
if chips > 0 {
what = fmt.Sprintf("%s — %d фишек", what, chips)
}
return fmt.Sprintf("%s, %s", what, formatAmount(amountMinor, currency))
}
// formatAmount renders a minor-unit amount for a buyer, in roubles where that is the currency.
func formatAmount(minor int64, currency string) string {
money, err := payments.MoneyFromMinor(minor, payments.Currency(currency))
if err != nil {
return ""
}
if money.Currency() == payments.CurrencyRUB {
return money.Major() + " ₽"
}
return money.String()
}
// purchaseText is the confirmation sent the moment chips are credited.
//
// It says plainly that it is not the fiscal receipt. The tax receipt is issued by a separate
// service on its own schedule, and a buyer who took this letter for the receipt would be misled —
// so the wait is stated up front rather than left to be discovered.
func purchaseText(p payments.PurchaseMail) string {
return fmt.Sprintf(`Здравствуйте!
Покупка совершена: %s.
Номер заказа: %s
Это письмо — подтверждение покупки, а не чек. Чек от налоговой службы придёт
отдельным письмом: обычно в течение часа, но иногда это занимает до нескольких дней.
Спасибо, что играете в «Эрудит».
`, describe(p.Title, p.Chips, p.AmountMinor, p.Currency), p.OrderID)
}
// receiptText is the fiscal receipt letter, which is how the receipt is actually handed to the
// buyer as the professional-income tax regime requires.
func receiptText(r payments.ReceiptMail, inn, baseURL string, loc *time.Location) string {
return fmt.Sprintf(`Здравствуйте!
По вашей покупке сформирован чек в налоговой службе.
%s
Дата: %s
Чек: %s
Продавец применяет налог на профессиональный доход; чек сформирован в сервисе
«Мой налог» Федеральной налоговой службы.
`, describe(r.Title, r.Chips, r.AmountMinor, r.Currency),
r.OperationTime.In(loc).Format("02.01.2006 15:04"),
mynalog.ReceiptURL(baseURL, inn, r.ReceiptUUID))
}
// cancelText tells a buyer their receipt has been annulled after a refund, so that the link they
// were sent earlier does not simply stop working without explanation.
func cancelText(r payments.ReceiptMail, loc *time.Location) string {
return fmt.Sprintf(`Здравствуйте!
Деньги по покупке возвращены, поэтому чек в налоговой службе аннулирован —
ранее присланная ссылка на него больше не действует.
%s
Дата покупки: %s
`, describe(r.Title, r.Chips, r.AmountMinor, r.Currency),
r.OperationTime.In(loc).Format("02.01.2006 15:04"))
}
+148
View File
@@ -0,0 +1,148 @@
package mynalogsync
import (
"context"
"fmt"
"sync"
"time"
"go.uber.org/zap"
"scrabble/backend/internal/payments"
)
// WatchdogInterval is how often the filing deadline is checked. It reads only local tables, so the
// cost is one query a day.
const WatchdogInterval = 24 * time.Hour
// Days of the month at which unfiled income of the previous month escalates.
//
// The professional-income statute gives until the 9th of the following month only for settlements
// that do NOT involve an electronic means of payment; a card payment is one, so the receipt is
// strictly due at the moment of settlement. The automatic export satisfies both readings, and this
// watchdog is the backstop for when it is switched off or has stopped — which is exactly when the
// deadline stops taking care of itself.
const (
warnDay = 1
alarmDay = 5
overdueDay = 9
)
// Watchdog warns when income from a closed month is still not registered with the tax service.
//
// It runs whether or not the automatic export does, because the case it exists for is the automatic
// export being off, paused or broken. Nothing here contacts the tax service.
type Watchdog struct {
payments *payments.Service
alert Alerter
loc *time.Location
log *zap.Logger
now func() time.Time
// mu guards sent, an in-process record of what has already been raised. The watchdog ticks
// daily, so this only prevents a restart from re-alerting within the same day.
mu sync.Mutex
sent map[string]time.Time
}
// NewWatchdog builds the deadline watchdog. It returns nil without a payments service or an alert
// channel, since it has nothing to watch or nowhere to say it.
func NewWatchdog(svc *payments.Service, alert Alerter, loc *time.Location, log *zap.Logger) *Watchdog {
if svc == nil || alert == nil {
return nil
}
if loc == nil {
loc = time.UTC
}
return &Watchdog{
payments: svc, alert: alert, loc: loc, log: log,
now: func() time.Time { return time.Now().UTC() },
sent: map[string]time.Time{},
}
}
// Run checks the deadline now and then once per interval, until the context ends. It checks on
// start too: a deployment that lands on the 6th should not wait a day to mention a month of
// unfiled income.
func (w *Watchdog) Run(ctx context.Context, interval time.Duration) {
w.tick(ctx)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
w.tick(ctx)
}
}
}
// tick raises the appropriate alert for whatever of the previous month is still unfiled.
func (w *Watchdog) tick(ctx context.Context) {
now := w.now().In(w.loc)
kind, heading := escalation(now.Day())
if kind == "" {
return
}
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, w.loc)
backlog, err := w.payments.MyNalogBacklogBefore(ctx, monthStart)
if err != nil {
w.log.Error("fiscal deadline check failed", zap.Error(err))
return
}
if backlog.Count == 0 {
return
}
w.raise(ctx, kind, "«Мой налог»: "+heading, w.body(heading, backlog, monthStart))
}
// escalation maps the day of the month onto an alert kind and its heading, or an empty kind on the
// days between the warning and the alarm, when there is nothing new to say.
func escalation(day int) (kind, heading string) {
switch {
case day >= overdueDay:
return alertBacklogOverdue, "срок подачи вышел"
case day >= alarmDay:
return alertBacklogDue, "чеки за прошлый месяц не отправлены"
case day >= warnDay && day < alarmDay:
if day == warnDay {
return alertBacklog, "остались неотправленные чеки за прошлый месяц"
}
}
return "", ""
}
// body renders the alert, naming what is owed and by when.
func (w *Watchdog) body(heading string, backlog payments.MyNalogBacklog, monthStart time.Time) string {
deadline := time.Date(monthStart.Year(), monthStart.Month(), overdueDay, 0, 0, 0, 0, w.loc)
return fmt.Sprintf(`%s.
Не зарегистрировано в «Мой налог»: %d приход(ов) на сумму %s.
Самый старый — от %s.
Крайний срок по закону — %s. Оплата картой считается расчётом электронным средством
платежа, поэтому чек по-хорошему формируется в момент расчёта, а не к этой дате.
Что сделать: в админ-консоли, раздел «Мой налог», войти в кабинет и запустить выгрузку
(или включить автоматический режим, если он выключен).`,
heading, backlog.Count, formatAmount(backlog.AmountMinor, string(payments.CurrencyRUB)),
backlog.Oldest.In(w.loc).Format("02.01.2006"), deadline.Format("02.01.2006"))
}
// raise sends an alert unless one of the same kind already went out within the day.
func (w *Watchdog) raise(ctx context.Context, kind, subject, body string) {
w.mu.Lock()
last, seen := w.sent[kind]
now := w.now()
if seen && now.Sub(last) < 24*time.Hour {
w.mu.Unlock()
return
}
w.sent[kind] = now
w.mu.Unlock()
if err := w.alert.Alert(ctx, kind, subject, body); err != nil {
w.log.Error("fiscal deadline alert could not be delivered", zap.String("kind", kind), zap.Error(err))
}
}
+84
View File
@@ -0,0 +1,84 @@
package mynalogsync
import (
"context"
"time"
"go.uber.org/zap"
"scrabble/backend/internal/account"
)
// Run drives the automatic export until the context ends.
//
// A tick with nothing to do costs one local query: the queue is read before anything authenticates,
// so an installation with no unfiled income never appears at the tax service at all. The automatic
// mode is armed from the admin console rather than from configuration, so that it can be switched
// on only once the operator has watched a run go through by hand.
func (e *Exporter) Run(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
e.tick(ctx)
}
}
}
// tick performs one automatic run, if the rail is signed in, armed and not paused.
func (e *Exporter) tick(ctx context.Context) {
stored, ok, err := e.payments.MyNalogSession(ctx)
if err != nil {
e.log.Error("mynalog session could not be read", zap.Error(err))
return
}
if !ok || !stored.AutoEnabled || stored.Paused() {
return
}
sum, err := e.RunBatch(ctx, WorkerBudget)
if err != nil {
e.log.Error("mynalog automatic export failed", zap.Error(err))
return
}
if sum.Registered == 0 && sum.Cancelled == 0 && sum.NotRequired == 0 &&
sum.Failed == 0 && sum.Unknown == 0 {
return // nothing happened; not worth a log line every quarter of an hour
}
e.log.Info("mynalog automatic export ran",
zap.Int("registered", sum.Registered), zap.Int("cancelled", sum.Cancelled),
zap.Int("not_required", sum.NotRequired), zap.Int("failed", sum.Failed),
zap.Int("unknown", sum.Unknown), zap.String("stopped", sum.Stopped))
}
// MailAlerter delivers operator alerts by email, reusing the relay the admin digest already uses.
type MailAlerter struct {
mail account.Mailer
from string
to string
}
// NewMailAlerter builds the alert channel. It returns nil unless a distinct admin sender and
// recipient are configured, matching how the existing admin digest stays inert without them.
func NewMailAlerter(mail account.Mailer, from, to string) *MailAlerter {
if mail == nil || from == "" || to == "" {
return nil
}
return &MailAlerter{mail: mail, from: from, to: to}
}
// Alert sends one operator alert. kind is the caller's deduplication key and is deliberately not
// put in the message.
//
// The body never carries a link into the admin console: an admin URL must not travel by email,
// where a provider could cache or index it (the same rule the admin digest follows).
func (a *MailAlerter) Alert(ctx context.Context, _, subject, body string) error {
return a.mail.Send(ctx, account.Message{
From: a.from,
To: a.to,
Subject: subject,
Text: body,
})
}
+326
View File
@@ -0,0 +1,326 @@
package payments
import (
"context"
"encoding/json"
"time"
"github.com/google/uuid"
)
// Export statuses of one income on its way into the professional-income tax register. They are
// deliberately not a subset of the order or ledger statuses: registering an income with the tax
// service is a separate obligation with its own failure modes, and the ledger is append-only, so
// this progress cannot live there.
const (
// MyNalogSending is written before the registration call. It exists so that a process that dies
// mid-call leaves evidence: on the next run the row is promoted to MyNalogUnknown rather than
// being retried blindly, because the call is not idempotent.
MyNalogSending = "sending"
// MyNalogSent means the tax service issued a receipt and we hold its identifier.
MyNalogSent = "sent"
// MyNalogUnknown means the outcome could not be established: the call failed and the follow-up
// probe did not find our receipt. It halts the queue until a human resolves it, because the only
// alternatives are filing the income twice or losing it.
MyNalogUnknown = "unknown"
// MyNalogFailed is a rejection we understand and may retry later.
MyNalogFailed = "failed"
// MyNalogNotRequired marks an income the buyer got back before it was ever registered. Nothing
// is filed and nothing is annulled — the two cancel out.
MyNalogNotRequired = "not_required"
)
// Annulment statuses of a registered receipt whose money was returned.
const (
MyNalogCancelNone = "none"
MyNalogCancelled = "cancelled"
MyNalogCancelFailed = "failed"
)
// myNalogProvider is the only rail this export covers: the direct rouble rail settled through
// YooKassa. Store rails are excluded on purpose — the ledger holds no rouble amount for them.
const myNalogProvider = "yookassa"
// MyNalogIncome is one rouble income the tax register may still be owed. Chips and Title come from
// the ledger snapshot, which is what the receipt description is built from. Status is empty when
// the income has never been acted on.
type MyNalogIncome struct {
LedgerID uuid.UUID
OrderID uuid.UUID
AccountID uuid.UUID
Chips int
Title string
Amount Money
OperationTime time.Time
Status string
ReceiptName string
ReceiptUUID string
Attempts int
LastError string
}
// MyNalogCancel is one registered receipt whose income has since been refunded and which must
// therefore be annulled. LedgerID names the income row, RefundLedgerID the refund that voided it.
type MyNalogCancel struct {
LedgerID uuid.UUID
RefundLedgerID uuid.UUID
OrderID uuid.UUID
AccountID uuid.UUID
ReceiptUUID string
// CancelStatus is MyNalogCancelFailed when a previous attempt was refused, so the console can
// show that this one is a retry rather than a first try.
CancelStatus string
LastError string
}
// MyNalogSession is the stored tax-cabinet session and the rail's operating mode. RefreshTokenEnc
// is opaque here: the payments domain stores the sealed bytes without holding the key, so the
// credential never becomes readable through a query on this schema.
type MyNalogSession struct {
INN string
DeviceID string
RefreshTokenEnc []byte
AutoEnabled bool
PausedAt *time.Time
PauseReason string
LastOKAt *time.Time
LastAlertAt *time.Time
LastAlertKind string
UpdatedAt time.Time
}
// Paused reports whether the rail has taken itself out of service.
func (s MyNalogSession) Paused() bool { return s.PausedAt != nil }
// MyNalogBacklog summarises income still owed to the tax register up to a cut-off. It is what the
// fiscal-period watchdog escalates on, so it carries enough to write a useful alert without a
// second query.
type MyNalogBacklog struct {
Count int
AmountMinor int64
Oldest time.Time
}
// MyNalogQueue is everything one export run has to consider, read in a single pass so the console
// and the worker see the same picture.
type MyNalogQueue struct {
Incomes []MyNalogIncome
Cancels []MyNalogCancel
NotRequired []MyNalogIncome
Attention []MyNalogIncome
}
// Empty reports whether there is nothing to do. The export engine checks this before authenticating,
// so an idle installation sends no traffic to the tax service at all.
func (q MyNalogQueue) Empty() bool {
return len(q.Incomes) == 0 && len(q.Cancels) == 0 && len(q.NotRequired) == 0
}
// PurchaseMail is one credited purchase whose buyer has not yet been told. It rides the existing
// payment-event outbox on its own cursor, so no payment path had to be modified to send it.
type PurchaseMail struct {
EventID uuid.UUID
AccountID uuid.UUID
OrderID uuid.UUID
Origin string
Title string
Chips int
AmountMinor int64
Currency string
CreatedAt time.Time
}
// ReceiptMail is one registered (or annulled) receipt whose buyer has not yet been told.
type ReceiptMail struct {
LedgerID uuid.UUID
AccountID uuid.UUID
ReceiptUUID string
Title string
Chips int
AmountMinor int64
Currency string
OperationTime time.Time
}
// myNalogSnapshot is the part of a fund ledger row's snapshot the receipt description needs.
type myNalogSnapshot struct {
Title string `json:"title"`
Chips int `json:"chips"`
}
// describeSnapshot recovers the product title and pack size a fund row recorded. A snapshot that
// cannot be read yields zeroes, and the caller falls back to a generic description rather than
// refusing to file the income.
func describeSnapshot(snapshot string) (title string, chips int) {
if snapshot == "" {
return "", 0
}
var snap myNalogSnapshot
if err := json.Unmarshal([]byte(snapshot), &snap); err != nil {
return "", 0
}
return snap.Title, snap.Chips
}
// MyNalogQueue reads everything an export run must consider, capped at limit incomes and limit
// cancels. Nothing here calls out to the tax service, so it is safe to run on every tick.
func (s *Service) MyNalogQueue(ctx context.Context, limit int) (MyNalogQueue, error) {
return s.store.myNalogQueue(ctx, limit)
}
// MyNalogIncomeAt reads one taxable income by its ledger row, reporting false when that row is not
// one this export is responsible for. It backs the per-row console actions, which must not depend on
// the row still being inside the paged queue.
func (s *Service) MyNalogIncomeAt(ctx context.Context, ledgerID uuid.UUID) (MyNalogIncome, bool, error) {
return s.store.myNalogIncomeByLedger(ctx, ledgerID)
}
// MyNalogBacklogBefore summarises income received before the cut-off that is still not registered.
// The fiscal-period watchdog uses it to escalate as the filing deadline approaches.
func (s *Service) MyNalogBacklogBefore(ctx context.Context, before time.Time) (MyNalogBacklog, error) {
return s.store.myNalogBacklogBefore(ctx, before)
}
// PromoteStrandedSending turns rows left in MyNalogSending by a process that died mid-call into
// MyNalogUnknown, and reports how many. The caller holds the export lock, so any such row is
// genuinely orphaned rather than in flight — and since the registration call is not idempotent, a
// blind retry is exactly what must not happen.
func (s *Service) PromoteStrandedSending(ctx context.Context) (int, error) {
return s.store.promoteStrandedSending(ctx, s.clock())
}
// BeginMyNalogSend records the intent to register income, freezing the receipt name before the call
// leaves. The name is the only handle the recovery probe has, so it must be durable before the
// request, not after it.
func (s *Service) BeginMyNalogSend(ctx context.Context, in MyNalogIncome, receiptName string) error {
return s.store.beginMyNalogSend(ctx, in, receiptName, s.clock())
}
// MarkMyNalogSent records a receipt the tax service issued. manual is true when an operator filed
// the income by hand and typed the identifier back in — such a row is as good as an automatic one,
// and in particular its receipt can still be annulled automatically on a refund.
func (s *Service) MarkMyNalogSent(ctx context.Context, ledgerID uuid.UUID, receiptUUID string, manual bool) error {
return s.store.markMyNalogSent(ctx, ledgerID, receiptUUID, manual, s.clock())
}
// MarkMyNalogManual records an income an operator filed by hand, creating the row when the export
// never got as far as attempting it.
func (s *Service) MarkMyNalogManual(ctx context.Context, in MyNalogIncome, receiptName, receiptUUID string) error {
return s.store.markMyNalogManual(ctx, in, receiptName, receiptUUID, s.clock())
}
// MarkMyNalogOutcome records a non-final outcome: MyNalogUnknown when the result could not be
// established, MyNalogFailed when it was understood and may be retried.
func (s *Service) MarkMyNalogOutcome(ctx context.Context, ledgerID uuid.UUID, status, reason string) error {
return s.store.markMyNalogOutcome(ctx, ledgerID, status, reason, s.clock())
}
// MarkMyNalogNotRequired records that an income needs no receipt because it was refunded before one
// was ever issued.
func (s *Service) MarkMyNalogNotRequired(ctx context.Context, in MyNalogIncome, reason string) error {
return s.store.markMyNalogNotRequired(ctx, in, reason, s.clock())
}
// MarkMyNalogCancelled records that a receipt was annulled for the given refund.
func (s *Service) MarkMyNalogCancelled(ctx context.Context, ledgerID, refundLedgerID uuid.UUID) error {
return s.store.markMyNalogCancel(ctx, ledgerID, refundLedgerID, MyNalogCancelled, "", s.clock())
}
// MarkMyNalogCancelFailed records that an annulment was refused, leaving the receipt standing.
func (s *Service) MarkMyNalogCancelFailed(ctx context.Context, ledgerID, refundLedgerID uuid.UUID, reason string) error {
return s.store.markMyNalogCancel(ctx, ledgerID, refundLedgerID, MyNalogCancelFailed, reason, s.clock())
}
// SaveMyNalogSession stores the tax-cabinet session, preserving the operating mode across a
// re-login and clearing any self-imposed pause — signing in again is the operator saying the
// problem is dealt with.
func (s *Service) SaveMyNalogSession(ctx context.Context, inn, deviceID string, refreshTokenEnc []byte) error {
return s.store.saveMyNalogSession(ctx, inn, deviceID, refreshTokenEnc, s.clock())
}
// UpdateMyNalogRefreshToken replaces the stored refresh token after the service rotated it. Losing
// a rotated token would strand the next renewal and force a password login.
func (s *Service) UpdateMyNalogRefreshToken(ctx context.Context, refreshTokenEnc []byte) error {
return s.store.updateMyNalogRefreshToken(ctx, refreshTokenEnc, s.clock())
}
// MyNalogSession reads the stored session, reporting false when the rail has never been signed in.
func (s *Service) MyNalogSession(ctx context.Context) (MyNalogSession, bool, error) {
return s.store.myNalogSession(ctx)
}
// DropMyNalogSession signs the rail out. The operating mode goes with it: without a session there
// is nothing for the automatic mode to run on.
func (s *Service) DropMyNalogSession(ctx context.Context) error {
return s.store.dropMyNalogSession(ctx)
}
// SetMyNalogAuto arms or disarms the automatic export.
func (s *Service) SetMyNalogAuto(ctx context.Context, enabled bool) error {
return s.store.setMyNalogAuto(ctx, enabled, s.clock())
}
// PauseMyNalog takes the rail out of service with a reason an operator can read. It is how a run
// reacts to a fault that repeating cannot fix, so that a changed API cannot turn into thousands of
// futile requests overnight.
func (s *Service) PauseMyNalog(ctx context.Context, reason string) error {
return s.store.pauseMyNalog(ctx, reason, s.clock())
}
// ResumeMyNalog clears a self-imposed pause after an operator has dealt with the cause.
func (s *Service) ResumeMyNalog(ctx context.Context) error {
return s.store.resumeMyNalog(ctx, s.clock())
}
// NoteMyNalogOK records a successful exchange with the tax service, which is what the "has it been
// down for a day?" test measures against.
func (s *Service) NoteMyNalogOK(ctx context.Context) error {
return s.store.noteMyNalogOK(ctx, s.clock())
}
// NoteMyNalogAlert records that an alert of the given kind was sent, so a persistent fault produces
// one letter a day rather than one per run.
func (s *Service) NoteMyNalogAlert(ctx context.Context, kind string) error {
return s.store.noteMyNalogAlert(ctx, kind, s.clock())
}
// TryLockMyNalog takes the advisory lock guarding an export run, reporting false when another run
// already holds it. Releasing it is the caller's job; the returned release function is safe to call
// exactly once.
func (s *Service) TryLockMyNalog(ctx context.Context) (release func(), ok bool, err error) {
return s.store.tryLockMyNalog(ctx)
}
// PendingPurchaseMails reads credited purchases whose buyer has not been written to yet.
func (s *Service) PendingPurchaseMails(ctx context.Context, limit int) ([]PurchaseMail, error) {
return s.store.pendingPurchaseMails(ctx, limit)
}
// MarkPurchaseMailed records that the mail decision for an event has been made. It is stamped even
// when no letter was sent — a store-rail purchase, or a buyer with no confirmed address — because
// the column tracks the decision, not the delivery, and an undecided event would be reconsidered
// forever.
func (s *Service) MarkPurchaseMailed(ctx context.Context, eventID uuid.UUID) error {
return s.store.markPurchaseMailed(ctx, eventID, s.clock())
}
// PendingReceiptMails reads registered receipts whose buyer has not been sent the receipt yet.
func (s *Service) PendingReceiptMails(ctx context.Context, limit int) ([]ReceiptMail, error) {
return s.store.pendingMyNalogMails(ctx, limit, false)
}
// PendingCancelMails reads annulled receipts whose buyer has not been told the receipt is void.
func (s *Service) PendingCancelMails(ctx context.Context, limit int) ([]ReceiptMail, error) {
return s.store.pendingMyNalogMails(ctx, limit, true)
}
// MarkReceiptMailed records that the receipt letter decision for an income has been made.
func (s *Service) MarkReceiptMailed(ctx context.Context, ledgerID uuid.UUID) error {
return s.store.markMyNalogMailed(ctx, ledgerID, false, s.clock())
}
// MarkCancelMailed records that the annulment letter decision for an income has been made.
func (s *Service) MarkCancelMailed(ctx context.Context, ledgerID uuid.UUID) error {
return s.store.markMyNalogMailed(ctx, ledgerID, true, s.clock())
}
+575
View File
@@ -0,0 +1,575 @@
package payments
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
)
// myNalogLockKey is the advisory-lock key guarding an export run. One run at a time is a hard
// requirement, not a nicety: the tax service accepts no idempotency key, so two overlapping runs
// could file the same income twice.
const myNalogLockKey int64 = 0x6D796E616C6F67 // "mynalog"
// taxableIncome is the predicate selecting ledger rows this export is responsible for: rouble
// funding on the direct rail. Store rails are excluded because the ledger records their price in
// the store's own currency and holds no rouble amount at all.
const taxableIncome = `l.kind = 'fund' AND l.provider = $1 AND l.order_id IS NOT NULL
AND l.snapshot->>'currency' = $2`
// refundedOrder matches an income whose order has since been refunded.
const refundedOrder = `EXISTS (SELECT 1 FROM payments.ledger x
WHERE x.order_id = l.order_id AND x.kind = 'refund')`
// incomeColumns is the projection every queue row is read through.
const incomeColumns = `l.ledger_id, l.order_id, l.account_id, l.snapshot, l.created_at,
COALESCE(r.status, ''), COALESCE(r.receipt_name, ''), COALESCE(r.receipt_uuid, ''),
COALESCE(r.attempts, 0), COALESCE(r.last_error, '')`
// myNalogQueue reads everything one export run must consider. It is four cheap reads against the
// ledger and the export table; nothing here touches the tax service, which is what lets the engine
// decide it has no work before authenticating.
func (s *Store) myNalogQueue(ctx context.Context, limit int) (MyNalogQueue, error) {
var q MyNalogQueue
var err error
// Owed: never attempted, or attempted and understood to have failed — and not since refunded.
if q.Incomes, err = s.myNalogIncomes(ctx, `(r.status IS NULL OR r.status = '`+MyNalogFailed+`')
AND NOT `+refundedOrder, limit); err != nil {
return MyNalogQueue{}, err
}
// Moot: refunded before a receipt was ever issued, so nothing is filed and nothing is annulled.
if q.NotRequired, err = s.myNalogIncomes(ctx, `(r.status IS NULL OR r.status = '`+MyNalogFailed+`')
AND `+refundedOrder, limit); err != nil {
return MyNalogQueue{}, err
}
// Stuck: an outcome nobody can infer. These halt the run and wait for a human.
if q.Attention, err = s.myNalogIncomes(ctx,
`r.status IN ('`+MyNalogUnknown+`', '`+MyNalogSending+`')`, limit); err != nil {
return MyNalogQueue{}, err
}
if q.Cancels, err = s.myNalogCancels(ctx, limit); err != nil {
return MyNalogQueue{}, err
}
return q, nil
}
// myNalogIncomes reads taxable incomes narrowed by an extra predicate over the export row, oldest
// first — the oldest income is the one closest to its filing deadline.
func (s *Store) myNalogIncomes(ctx context.Context, extra string, limit int) ([]MyNalogIncome, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT `+incomeColumns+`
FROM payments.ledger l
LEFT JOIN payments.mynalog_receipt r ON r.ledger_id = l.ledger_id
WHERE `+taxableIncome+` AND (`+extra+`)
ORDER BY l.created_at
LIMIT $3`, myNalogProvider, string(CurrencyRUB), limit)
if err != nil {
return nil, fmt.Errorf("payments: read mynalog queue: %w", err)
}
defer rows.Close()
var out []MyNalogIncome
for rows.Next() {
var (
in MyNalogIncome
orderID uuid.NullUUID
snapshot sql.NullString
)
if err := rows.Scan(&in.LedgerID, &orderID, &in.AccountID, &snapshot, &in.OperationTime,
&in.Status, &in.ReceiptName, &in.ReceiptUUID, &in.Attempts, &in.LastError); err != nil {
return nil, fmt.Errorf("payments: scan mynalog queue row: %w", err)
}
in.OrderID = orderID.UUID
in.Amount = moneyFromSnapshot(snapshot.String)
in.Title, in.Chips = describeSnapshot(snapshot.String)
out = append(out, in)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("payments: read mynalog queue: %w", err)
}
return out, nil
}
// myNalogIncomeByLedger reads one taxable income by its ledger row, reporting false when that row
// is not one this export is responsible for.
func (s *Store) myNalogIncomeByLedger(ctx context.Context, ledgerID uuid.UUID) (MyNalogIncome, bool, error) {
var (
in MyNalogIncome
orderID uuid.NullUUID
snapshot sql.NullString
)
err := s.db.QueryRowContext(ctx, `
SELECT `+incomeColumns+`
FROM payments.ledger l
LEFT JOIN payments.mynalog_receipt r ON r.ledger_id = l.ledger_id
WHERE `+taxableIncome+` AND l.ledger_id = $3`,
myNalogProvider, string(CurrencyRUB), ledgerID).
Scan(&in.LedgerID, &orderID, &in.AccountID, &snapshot, &in.OperationTime,
&in.Status, &in.ReceiptName, &in.ReceiptUUID, &in.Attempts, &in.LastError)
if err == sql.ErrNoRows {
return MyNalogIncome{}, false, nil
}
if err != nil {
return MyNalogIncome{}, false, fmt.Errorf("payments: read mynalog income: %w", err)
}
in.OrderID = orderID.UUID
in.Amount = moneyFromSnapshot(snapshot.String)
in.Title, in.Chips = describeSnapshot(snapshot.String)
return in, true, nil
}
// myNalogCancels reads receipts whose income has been refunded and which are therefore owed an
// annulment, oldest refund first. A previously failed annulment is included: the receipt still
// stands, so it is still owed.
func (s *Store) myNalogCancels(ctx context.Context, limit int) ([]MyNalogCancel, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT r.ledger_id, x.ledger_id, r.order_id, r.account_id, r.receipt_uuid,
r.cancel_status, r.last_error
FROM payments.mynalog_receipt r
JOIN payments.ledger x ON x.order_id = r.order_id AND x.kind = 'refund'
WHERE r.status = $1 AND r.cancel_status IN ($2, $3)
ORDER BY x.created_at
LIMIT $4`, MyNalogSent, MyNalogCancelNone, MyNalogCancelFailed, limit)
if err != nil {
return nil, fmt.Errorf("payments: read mynalog cancels: %w", err)
}
defer rows.Close()
var out []MyNalogCancel
for rows.Next() {
var c MyNalogCancel
if err := rows.Scan(&c.LedgerID, &c.RefundLedgerID, &c.OrderID, &c.AccountID, &c.ReceiptUUID,
&c.CancelStatus, &c.LastError); err != nil {
return nil, fmt.Errorf("payments: scan mynalog cancel: %w", err)
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("payments: read mynalog cancels: %w", err)
}
return out, nil
}
// myNalogBacklogBefore counts and sums taxable income received before the cut-off that has neither
// been registered nor been ruled out.
func (s *Store) myNalogBacklogBefore(ctx context.Context, before time.Time) (MyNalogBacklog, error) {
var (
out MyNalogBacklog
oldest sql.NullTime
)
err := s.db.QueryRowContext(ctx, `
SELECT count(*),
COALESCE(SUM((l.snapshot->>'amount_minor')::bigint), 0),
MIN(l.created_at)
FROM payments.ledger l
LEFT JOIN payments.mynalog_receipt r ON r.ledger_id = l.ledger_id
WHERE `+taxableIncome+`
AND l.created_at < $3
AND (r.status IS NULL OR r.status NOT IN ($4, $5))`,
myNalogProvider, string(CurrencyRUB), before, MyNalogSent, MyNalogNotRequired,
).Scan(&out.Count, &out.AmountMinor, &oldest)
if err != nil {
return MyNalogBacklog{}, fmt.Errorf("payments: read mynalog backlog: %w", err)
}
out.Oldest = oldest.Time
return out, nil
}
// promoteStrandedSending reclassifies rows left mid-call by a process that died. The caller holds
// the export lock, so nothing is genuinely in flight; and because the registration call is not
// idempotent, the safe reading of "we started and do not know how it ended" is MyNalogUnknown,
// never "try again".
func (s *Store) promoteStrandedSending(ctx context.Context, now time.Time) (int, error) {
res, err := s.db.ExecContext(ctx, `
UPDATE payments.mynalog_receipt
SET status = $1, last_error = $2, updated_at = $3
WHERE status = $4`,
MyNalogUnknown, "the export stopped mid-call; the outcome was never established",
now, MyNalogSending)
if err != nil {
return 0, fmt.Errorf("payments: promote stranded mynalog rows: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("payments: promote stranded mynalog rows: %w", err)
}
return int(n), nil
}
// beginMyNalogSend writes the intent to register, freezing the receipt name. It commits before the
// request leaves so that a crash mid-call is visible afterwards.
func (s *Store) beginMyNalogSend(ctx context.Context, in MyNalogIncome, receiptName string, now time.Time) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO payments.mynalog_receipt (ledger_id, order_id, account_id, status, receipt_name,
operation_time, amount_minor, currency, attempts, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1, $9, $9)
ON CONFLICT (ledger_id) DO UPDATE SET
status = EXCLUDED.status,
receipt_name = EXCLUDED.receipt_name,
attempts = payments.mynalog_receipt.attempts + 1,
last_error = '',
updated_at = EXCLUDED.updated_at`,
in.LedgerID, in.OrderID, in.AccountID, MyNalogSending, receiptName,
in.OperationTime, in.Amount.Minor(), string(in.Amount.Currency()), now)
if err != nil {
return fmt.Errorf("payments: begin mynalog send: %w", err)
}
return nil
}
// markMyNalogSent records the receipt the tax service issued.
func (s *Store) markMyNalogSent(ctx context.Context, ledgerID uuid.UUID, receiptUUID string, manual bool, now time.Time) error {
res, err := s.db.ExecContext(ctx, `
UPDATE payments.mynalog_receipt
SET status = $1, receipt_uuid = $2, entered_manually = $3, sent_at = $4,
last_error = '', updated_at = $4
WHERE ledger_id = $5`,
MyNalogSent, receiptUUID, manual, now, ledgerID)
if err != nil {
return fmt.Errorf("payments: mark mynalog sent: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("payments: mark mynalog sent: no export row for ledger %s", ledgerID)
}
return nil
}
// markMyNalogManual records an income an operator filed by hand, creating the row when the export
// never attempted it. Such a row is deliberately indistinguishable from an automatic one apart from
// the flag, so a later refund still annuls the receipt without further human involvement.
func (s *Store) markMyNalogManual(ctx context.Context, in MyNalogIncome, receiptName, receiptUUID string, now time.Time) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO payments.mynalog_receipt (ledger_id, order_id, account_id, status, receipt_uuid,
receipt_name, operation_time, amount_minor, currency, entered_manually, sent_at,
created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, true, $10, $10, $10)
ON CONFLICT (ledger_id) DO UPDATE SET
status = EXCLUDED.status,
receipt_uuid = EXCLUDED.receipt_uuid,
entered_manually = true,
sent_at = EXCLUDED.sent_at,
last_error = '',
updated_at = EXCLUDED.updated_at`,
in.LedgerID, in.OrderID, in.AccountID, MyNalogSent, receiptUUID, receiptName,
in.OperationTime, in.Amount.Minor(), string(in.Amount.Currency()), now)
if err != nil {
return fmt.Errorf("payments: mark mynalog manual: %w", err)
}
return nil
}
// markMyNalogOutcome records a non-final outcome and why.
func (s *Store) markMyNalogOutcome(ctx context.Context, ledgerID uuid.UUID, status, reason string, now time.Time) error {
res, err := s.db.ExecContext(ctx, `
UPDATE payments.mynalog_receipt
SET status = $1, last_error = $2, updated_at = $3
WHERE ledger_id = $4`, status, reason, now, ledgerID)
if err != nil {
return fmt.Errorf("payments: mark mynalog outcome: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("payments: mark mynalog outcome: no export row for ledger %s", ledgerID)
}
return nil
}
// markMyNalogNotRequired records that an income needs no receipt, because the buyer had the money
// back before one was issued.
func (s *Store) markMyNalogNotRequired(ctx context.Context, in MyNalogIncome, reason string, now time.Time) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO payments.mynalog_receipt (ledger_id, order_id, account_id, status, receipt_name,
operation_time, amount_minor, currency, last_error, created_at, updated_at)
VALUES ($1, $2, $3, $4, '', $5, $6, $7, $8, $9, $9)
ON CONFLICT (ledger_id) DO UPDATE SET
status = EXCLUDED.status,
last_error = EXCLUDED.last_error,
updated_at = EXCLUDED.updated_at`,
in.LedgerID, in.OrderID, in.AccountID, MyNalogNotRequired,
in.OperationTime, in.Amount.Minor(), string(in.Amount.Currency()), reason, now)
if err != nil {
return fmt.Errorf("payments: mark mynalog not required: %w", err)
}
return nil
}
// markMyNalogCancel records the outcome of annulling a receipt.
func (s *Store) markMyNalogCancel(ctx context.Context, ledgerID, refundLedgerID uuid.UUID, status, reason string, now time.Time) error {
var cancelledAt any
if status == MyNalogCancelled {
cancelledAt = now
}
res, err := s.db.ExecContext(ctx, `
UPDATE payments.mynalog_receipt
SET cancel_status = $1, cancel_refund_ledger_id = $2, cancelled_at = $3,
last_error = $4, updated_at = $5
WHERE ledger_id = $6`, status, refundLedgerID, cancelledAt, reason, now, ledgerID)
if err != nil {
return fmt.Errorf("payments: mark mynalog cancel: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("payments: mark mynalog cancel: no export row for ledger %s", ledgerID)
}
return nil
}
// saveMyNalogSession stores the session. Signing in again clears a self-imposed pause — that is the
// operator telling us the cause has been dealt with — while the automatic mode is left as it was,
// so a routine re-login does not silently arm or disarm it.
func (s *Store) saveMyNalogSession(ctx context.Context, inn, deviceID string, refreshTokenEnc []byte, now time.Time) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO payments.mynalog_session (only_row, inn, device_id, refresh_token_enc, updated_at)
VALUES (true, $1, $2, $3, $4)
ON CONFLICT (only_row) DO UPDATE SET
inn = EXCLUDED.inn,
device_id = EXCLUDED.device_id,
refresh_token_enc = EXCLUDED.refresh_token_enc,
paused_at = NULL,
pause_reason = '',
updated_at = EXCLUDED.updated_at`,
inn, deviceID, refreshTokenEnc, now)
if err != nil {
return fmt.Errorf("payments: save mynalog session: %w", err)
}
return nil
}
// updateMyNalogRefreshToken replaces the stored token after the service rotated it.
func (s *Store) updateMyNalogRefreshToken(ctx context.Context, refreshTokenEnc []byte, now time.Time) error {
_, err := s.db.ExecContext(ctx, `
UPDATE payments.mynalog_session SET refresh_token_enc = $1, updated_at = $2
WHERE only_row`, refreshTokenEnc, now)
if err != nil {
return fmt.Errorf("payments: update mynalog refresh token: %w", err)
}
return nil
}
// myNalogSession reads the stored session.
func (s *Store) myNalogSession(ctx context.Context) (MyNalogSession, bool, error) {
var (
out MyNalogSession
pausedAt, lastOKAt, lastAlertAt sql.NullTime
)
err := s.db.QueryRowContext(ctx, `
SELECT inn, device_id, refresh_token_enc, auto_enabled, paused_at, pause_reason,
last_ok_at, last_alert_at, last_alert_kind, updated_at
FROM payments.mynalog_session WHERE only_row`).
Scan(&out.INN, &out.DeviceID, &out.RefreshTokenEnc, &out.AutoEnabled, &pausedAt,
&out.PauseReason, &lastOKAt, &lastAlertAt, &out.LastAlertKind, &out.UpdatedAt)
if err == sql.ErrNoRows {
return MyNalogSession{}, false, nil
}
if err != nil {
return MyNalogSession{}, false, fmt.Errorf("payments: read mynalog session: %w", err)
}
if pausedAt.Valid {
out.PausedAt = &pausedAt.Time
}
if lastOKAt.Valid {
out.LastOKAt = &lastOKAt.Time
}
if lastAlertAt.Valid {
out.LastAlertAt = &lastAlertAt.Time
}
return out, true, nil
}
// dropMyNalogSession signs the rail out.
func (s *Store) dropMyNalogSession(ctx context.Context) error {
if _, err := s.db.ExecContext(ctx, `DELETE FROM payments.mynalog_session WHERE only_row`); err != nil {
return fmt.Errorf("payments: drop mynalog session: %w", err)
}
return nil
}
// setMyNalogAuto arms or disarms the automatic export.
func (s *Store) setMyNalogAuto(ctx context.Context, enabled bool, now time.Time) error {
res, err := s.db.ExecContext(ctx, `
UPDATE payments.mynalog_session SET auto_enabled = $1, updated_at = $2
WHERE only_row`, enabled, now)
if err != nil {
return fmt.Errorf("payments: set mynalog auto: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("payments: set mynalog auto: sign in first")
}
return nil
}
// pauseMyNalog takes the rail out of service with a reason.
func (s *Store) pauseMyNalog(ctx context.Context, reason string, now time.Time) error {
if _, err := s.db.ExecContext(ctx, `
UPDATE payments.mynalog_session SET paused_at = $1, pause_reason = $2, updated_at = $1
WHERE only_row AND paused_at IS NULL`, now, reason); err != nil {
return fmt.Errorf("payments: pause mynalog: %w", err)
}
return nil
}
// resumeMyNalog clears a self-imposed pause.
func (s *Store) resumeMyNalog(ctx context.Context, now time.Time) error {
if _, err := s.db.ExecContext(ctx, `
UPDATE payments.mynalog_session SET paused_at = NULL, pause_reason = '', updated_at = $1
WHERE only_row`, now); err != nil {
return fmt.Errorf("payments: resume mynalog: %w", err)
}
return nil
}
// noteMyNalogOK records a successful exchange with the tax service.
func (s *Store) noteMyNalogOK(ctx context.Context, now time.Time) error {
if _, err := s.db.ExecContext(ctx, `
UPDATE payments.mynalog_session SET last_ok_at = $1, updated_at = $1
WHERE only_row`, now); err != nil {
return fmt.Errorf("payments: note mynalog ok: %w", err)
}
return nil
}
// noteMyNalogAlert records that an alert of the given kind went out.
func (s *Store) noteMyNalogAlert(ctx context.Context, kind string, now time.Time) error {
if _, err := s.db.ExecContext(ctx, `
UPDATE payments.mynalog_session SET last_alert_at = $1, last_alert_kind = $2, updated_at = $1
WHERE only_row`, now, kind); err != nil {
return fmt.Errorf("payments: note mynalog alert: %w", err)
}
return nil
}
// tryLockMyNalog takes the advisory lock guarding an export run.
//
// The lock is held on one pinned connection, because a session-level advisory lock belongs to the
// connection that took it: releasing it from another pooled connection would silently fail and
// leave the rail locked until the process restarted.
func (s *Store) tryLockMyNalog(ctx context.Context) (func(), bool, error) {
conn, err := s.db.Conn(ctx)
if err != nil {
return nil, false, fmt.Errorf("payments: lock mynalog: %w", err)
}
var locked bool
if err := conn.QueryRowContext(ctx, `SELECT pg_try_advisory_lock($1)`, myNalogLockKey).Scan(&locked); err != nil {
_ = conn.Close()
return nil, false, fmt.Errorf("payments: lock mynalog: %w", err)
}
if !locked {
_ = conn.Close()
return nil, false, nil
}
release := func() {
// The caller's context is typically done by now (that is what ended the run), and the lock
// outlives the connection's return to the pool, so the unlock must not inherit that
// cancellation.
_, _ = conn.ExecContext(context.WithoutCancel(ctx), `SELECT pg_advisory_unlock($1)`, myNalogLockKey)
_ = conn.Close()
}
return release, true, nil
}
// pendingPurchaseMails reads credited purchases whose buyer has not been written to yet. Every
// undecided succeeded event is returned, including store-rail ones the caller will merely stamp:
// the cursor tracks the decision, so leaving a row unexamined would mean revisiting it forever.
func (s *Store) pendingPurchaseMails(ctx context.Context, limit int) ([]PurchaseMail, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT e.event_id, e.account_id, e.order_id, COALESCE(o.origin, ''),
COALESCE(o.expected_amount, 0), COALESCE(o.currency, ''),
COALESCE(p.title, ''), COALESCE((e.payload->>'chips')::int, 0), e.created_at
FROM payments.payment_events e
LEFT JOIN payments.orders o ON o.order_id = e.order_id
LEFT JOIN payments.product p ON p.product_id = o.product_id
WHERE e.type = 'succeeded' AND e.mailed_at IS NULL
ORDER BY e.created_at
LIMIT $1`, limit)
if err != nil {
return nil, fmt.Errorf("payments: read purchase mail queue: %w", err)
}
defer rows.Close()
var out []PurchaseMail
for rows.Next() {
var (
m PurchaseMail
orderID uuid.NullUUID
)
if err := rows.Scan(&m.EventID, &m.AccountID, &orderID, &m.Origin, &m.AmountMinor,
&m.Currency, &m.Title, &m.Chips, &m.CreatedAt); err != nil {
return nil, fmt.Errorf("payments: scan purchase mail: %w", err)
}
m.OrderID = orderID.UUID
out = append(out, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("payments: read purchase mail queue: %w", err)
}
return out, nil
}
// markPurchaseMailed stamps the mail decision for an event.
func (s *Store) markPurchaseMailed(ctx context.Context, eventID uuid.UUID, now time.Time) error {
if _, err := s.db.ExecContext(ctx, `
UPDATE payments.payment_events SET mailed_at = $1 WHERE event_id = $2`,
now, eventID); err != nil {
return fmt.Errorf("payments: mark purchase mailed: %w", err)
}
return nil
}
// pendingMyNalogMails reads receipts owed a letter: the annulment letter when cancelled is true,
// the receipt letter otherwise. The product title and pack size come from the ledger snapshot the
// income was filed from, so the letter describes what the buyer actually bought.
func (s *Store) pendingMyNalogMails(ctx context.Context, limit int, cancelled bool) ([]ReceiptMail, error) {
// Both variants bind the same two parameters, so neither leaves a placeholder unreferenced. A
// cancelled receipt is always a sent one, so repeating the status test costs nothing.
where := `r.status = $1 AND r.receipt_mail_sent_at IS NULL`
if cancelled {
where = `r.status = $1 AND r.cancel_status = '` + MyNalogCancelled +
`' AND r.cancel_mail_sent_at IS NULL`
}
rows, err := s.db.QueryContext(ctx, `
SELECT r.ledger_id, r.account_id, r.receipt_uuid, l.snapshot,
r.amount_minor, r.currency, r.operation_time
FROM payments.mynalog_receipt r
JOIN payments.ledger l ON l.ledger_id = r.ledger_id
WHERE `+where+`
ORDER BY r.updated_at
LIMIT $2`, MyNalogSent, limit)
if err != nil {
return nil, fmt.Errorf("payments: read receipt mail queue: %w", err)
}
defer rows.Close()
var out []ReceiptMail
for rows.Next() {
var (
m ReceiptMail
snapshot sql.NullString
)
if err := rows.Scan(&m.LedgerID, &m.AccountID, &m.ReceiptUUID, &snapshot,
&m.AmountMinor, &m.Currency, &m.OperationTime); err != nil {
return nil, fmt.Errorf("payments: scan receipt mail: %w", err)
}
m.Title, m.Chips = describeSnapshot(snapshot.String)
out = append(out, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("payments: read receipt mail queue: %w", err)
}
return out, nil
}
// markMyNalogMailed stamps the letter decision for an income.
func (s *Store) markMyNalogMailed(ctx context.Context, ledgerID uuid.UUID, cancelled bool, now time.Time) error {
column := "receipt_mail_sent_at"
if cancelled {
column = "cancel_mail_sent_at"
}
if _, err := s.db.ExecContext(ctx,
`UPDATE payments.mynalog_receipt SET `+column+` = $1, updated_at = $1 WHERE ledger_id = $2`,
now, ledgerID); err != nil {
return fmt.Errorf("payments: mark receipt mailed: %w", err)
}
return nil
}
@@ -0,0 +1,96 @@
-- «Мой налог» (НПД) export rail: the operator registers each rouble income with the tax service
-- and annuls its receipt on a refund. Two new tables plus one nullable column — strictly additive,
-- so it applies forward via goose with no data rewrite (no contour wipe) and an image rollback
-- simply ignores it. The ledger itself is untouched: it is append-only, so the export's working
-- state cannot live there.
-- +goose Up
-- The single stored «Мой налог» session and the rail's operating mode. The password is never
-- persisted: it is exchanged for a refresh token at login, and only that token is kept, encrypted
-- with BACKEND_MYNALOG_KEY. device_id pins the session to one synthetic "device" so repeated
-- logins do not look like a new one every time. Dropping the row logs the rail out and, by
-- design, also disarms the automatic mode.
CREATE TABLE payments.mynalog_session (
only_row boolean DEFAULT true NOT NULL,
inn text NOT NULL,
device_id text NOT NULL,
refresh_token_enc bytea NOT NULL,
auto_enabled boolean DEFAULT false NOT NULL,
paused_at timestamp with time zone,
pause_reason text DEFAULT '' NOT NULL,
last_ok_at timestamp with time zone,
last_alert_at timestamp with time zone,
last_alert_kind text DEFAULT '' NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT mynalog_session_pkey PRIMARY KEY (only_row),
CONSTRAINT mynalog_session_single_row_chk CHECK (only_row)
);
-- One row per income we act on, keyed on the ledger row it reports. The queue itself is NOT
-- materialised here — it is derived by joining the ledger against this table — so a row appears
-- only once we have decided something about that income.
--
-- receipt_name is the exact services[].name we sent, frozen BEFORE the request: the tax API has no
-- idempotency key, so after a timeout the only way to find out whether our receipt was created is
-- to search the tax service's own income list by that string. It carries the order id for exactly
-- that reason.
CREATE TABLE payments.mynalog_receipt (
ledger_id uuid NOT NULL,
order_id uuid NOT NULL,
account_id uuid NOT NULL,
status text NOT NULL,
receipt_uuid text,
receipt_name text NOT NULL,
operation_time timestamp with time zone NOT NULL,
amount_minor bigint NOT NULL,
currency text NOT NULL,
entered_manually boolean DEFAULT false NOT NULL,
attempts integer DEFAULT 0 NOT NULL,
last_error text DEFAULT '' NOT NULL,
sent_at timestamp with time zone,
receipt_mail_sent_at timestamp with time zone,
cancel_status text DEFAULT 'none' NOT NULL,
cancel_refund_ledger_id uuid,
cancelled_at timestamp with time zone,
cancel_mail_sent_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT mynalog_receipt_pkey PRIMARY KEY (ledger_id),
CONSTRAINT mynalog_receipt_ledger_fkey FOREIGN KEY (ledger_id)
REFERENCES payments.ledger (ledger_id),
CONSTRAINT mynalog_receipt_order_fkey FOREIGN KEY (order_id)
REFERENCES payments.orders (order_id),
CONSTRAINT mynalog_receipt_refund_fkey FOREIGN KEY (cancel_refund_ledger_id)
REFERENCES payments.ledger (ledger_id),
CONSTRAINT mynalog_receipt_status_chk
CHECK ((status = ANY (ARRAY['sending'::text, 'sent'::text, 'unknown'::text,
'failed'::text, 'not_required'::text]))),
CONSTRAINT mynalog_receipt_cancel_status_chk
CHECK ((cancel_status = ANY (ARRAY['none'::text, 'cancelled'::text, 'failed'::text]))),
CONSTRAINT mynalog_receipt_amount_chk CHECK ((amount_minor >= 0)),
CONSTRAINT mynalog_receipt_attempts_chk CHECK ((attempts >= 0)),
-- A sent receipt must carry its uuid: without it the refund could never annul anything.
CONSTRAINT mynalog_receipt_sent_uuid_chk
CHECK ((status <> 'sent') OR (receipt_uuid IS NOT NULL AND receipt_uuid <> ''))
);
CREATE INDEX mynalog_receipt_status_idx ON payments.mynalog_receipt (status);
CREATE INDEX mynalog_receipt_cancel_idx ON payments.mynalog_receipt (cancel_status);
CREATE INDEX mynalog_receipt_order_idx ON payments.mynalog_receipt (order_id);
-- A second, independent cursor over the existing payment-event outbox: dispatched_at drives the
-- in-app wallet-refresh push, mailed_at drives the buyer's purchase-confirmation letter. The
-- column means "the mail decision has been made", not "a letter was sent" — an event that needs
-- no letter (a store rail, or an account with no confirmed address) is stamped and skipped.
ALTER TABLE payments.payment_events ADD COLUMN mailed_at timestamp with time zone;
CREATE INDEX payment_events_mail_idx
ON payments.payment_events (mailed_at)
WHERE mailed_at IS NULL;
-- +goose Down
DROP INDEX payments.payment_events_mail_idx;
ALTER TABLE payments.payment_events DROP COLUMN mailed_at;
DROP TABLE payments.mynalog_receipt;
DROP TABLE payments.mynalog_session;
@@ -123,6 +123,10 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.GET("/ledger", s.consoleLedger)
gm.GET("/ledger.csv", s.consoleLedgerExport)
}
// The professional-income tax export, registered only when an encryption key is configured —
// without one there is nowhere safe to keep the tax cabinet's refresh token, so the whole rail
// stays dormant rather than half-present.
s.registerMyNalogConsole(gm)
}
// consoleDashboard renders the landing page: the top-line counts and the resident
@@ -0,0 +1,334 @@
package server
import (
"encoding/csv"
"errors"
"fmt"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/adminconsole"
"scrabble/backend/internal/mynalogsync"
"scrabble/backend/internal/payments"
)
// myNalogBack is where every action on the tax-export page returns to.
const myNalogBack = "/_gm/mynalog"
// myNalogListLimit caps each of the three working lists on the page.
const myNalogListLimit = 200
// myNalogOverdueDay is the day of the following month by which a closed month's income must have
// been filed at the very latest. It is shown as a warning, not enforced.
const myNalogOverdueDay = 9
// consoleMyNalog renders the tax-export section: the session, what is owed, and the manual fallback.
func (s *Server) consoleMyNalog(c *gin.Context) {
ctx := c.Request.Context()
loc := s.mynalog.Location()
view := adminconsole.MyNalogView{TimeZone: loc.String()}
stored, ok, err := s.payments.MyNalogSession(ctx)
if err != nil {
s.consoleError(c, err)
return
}
if ok {
view.SignedIn = true
view.INN = stored.INN
view.AutoEnabled = stored.AutoEnabled
view.Paused = stored.Paused()
view.PauseReason = stored.PauseReason
if stored.LastOKAt != nil {
view.LastOK = stored.LastOKAt.In(loc).Format("2006-01-02 15:04")
}
}
queue, err := s.payments.MyNalogQueue(ctx, myNalogListLimit)
if err != nil {
s.consoleError(c, err)
return
}
for _, in := range queue.Incomes {
view.Owed = append(view.Owed, s.myNalogRow(in, stored.INN, loc))
}
for _, in := range queue.Attention {
view.Attention = append(view.Attention, s.myNalogRow(in, stored.INN, loc))
}
for _, cn := range queue.Cancels {
view.Cancels = append(view.Cancels, adminconsole.MyNalogCancelRow{
LedgerID: cn.LedgerID.String(),
AccountID: cn.AccountID.String(),
ReceiptUUID: cn.ReceiptUUID,
Failed: cn.CancelStatus == payments.MyNalogCancelFailed,
})
}
now := time.Now().In(loc)
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
backlog, err := s.payments.MyNalogBacklogBefore(ctx, monthStart)
if err != nil {
s.consoleError(c, err)
return
}
if backlog.Count > 0 {
view.Backlog = adminconsole.MyNalogBacklogRow{
Count: backlog.Count,
Amount: fmtMinor(backlog.AmountMinor, payments.CurrencyRUB),
Oldest: backlog.Oldest.In(loc).Format("2006-01-02"),
Overdue: now.Day() >= myNalogOverdueDay,
}
}
s.renderConsole(c, "mynalog", "mynalog", "My Nalog", view)
}
// fmtMinor renders a minor-unit total for display, or "" when the currency is not one we know.
func fmtMinor(minor int64, cur payments.Currency) string {
m, err := payments.MoneyFromMinor(minor, cur)
if err != nil {
return ""
}
return fmtMoney(m)
}
// myNalogRow projects one income into its console row, including the printable receipt link once
// the tax service has issued one.
func (s *Server) myNalogRow(in payments.MyNalogIncome, inn string, loc *time.Location) adminconsole.MyNalogRow {
row := adminconsole.MyNalogRow{
LedgerID: in.LedgerID.String(),
AccountID: in.AccountID.String(),
At: in.OperationTime.In(loc).Format("2006-01-02 15:04"),
Amount: in.Amount.String(),
Name: s.mynalog.ReceiptName(in),
Status: in.Status,
Attempts: in.Attempts,
LastError: in.LastError,
ReceiptUUID: in.ReceiptUUID,
}
if in.ReceiptUUID != "" && inn != "" {
row.ReceiptURL = s.mynalog.ReceiptURL(inn, in.ReceiptUUID)
}
return row
}
// consoleMyNalogSignIn exchanges the tax-cabinet password for a session. The password is read from
// the form, used once and never stored: only the refresh token it yields is kept, encrypted.
func (s *Server) consoleMyNalogSignIn(c *gin.Context) {
login, password := trimForm(c, "login"), c.PostForm("password")
if login == "" || password == "" {
s.renderConsoleMessage(c, "Missing credentials",
"both the tax cabinet login (your INN) and the password are required", myNalogBack)
return
}
if err := s.mynalog.SignIn(c.Request.Context(), login, password); err != nil {
// The error text comes from the tax service and never carries the credentials back.
s.renderConsoleMessage(c, "Sign-in failed", err.Error(), myNalogBack)
return
}
s.renderConsoleMessage(c, "Signed in",
"the tax cabinet session is stored; only its refresh token was kept, not the password",
myNalogBack)
}
// consoleMyNalogSignOut forgets the stored session, which also disarms the automatic export.
func (s *Server) consoleMyNalogSignOut(c *gin.Context) {
if err := s.mynalog.SignOut(c.Request.Context()); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Signed out",
"the stored session is gone and the automatic export is off", myNalogBack)
}
// consoleMyNalogRun performs one export run and reports what it did.
func (s *Server) consoleMyNalogRun(c *gin.Context) {
sum, err := s.mynalog.RunBatch(c.Request.Context(), mynalogsync.ConsoleBudget)
if err != nil {
if errors.Is(err, mynalogsync.ErrNotSignedIn) {
s.renderConsoleMessage(c, "Not signed in",
"sign in to the tax cabinet before running an export", myNalogBack)
return
}
s.consoleError(c, err)
return
}
switch {
case sum.Busy:
s.renderConsoleMessage(c, "Already running",
"another export is in progress; wait for it to finish", myNalogBack)
return
case sum.Paused:
s.renderConsoleMessage(c, "Export is paused", sum.Stopped, myNalogBack)
return
}
body := fmt.Sprintf("filed %d, annulled %d, ruled out %d, failed %d, unresolved %d",
sum.Registered, sum.Cancelled, sum.NotRequired, sum.Failed, sum.Unknown)
if sum.Stopped != "" {
body += ". " + sum.Stopped
}
s.renderConsoleMessage(c, "Export finished", body, myNalogBack)
}
// consoleMyNalogAuto arms or disarms the automatic export.
func (s *Server) consoleMyNalogAuto(c *gin.Context) {
enabled := c.PostForm("enabled") == "1"
if err := s.payments.SetMyNalogAuto(c.Request.Context(), enabled); err != nil {
s.consoleError(c, err)
return
}
body := "the automatic export is off; nothing is filed until you press Run"
if enabled {
body = fmt.Sprintf("the automatic export is on; it wakes every %s and does nothing when "+
"there is nothing to file", mynalogsync.WorkerInterval)
}
s.renderConsoleMessage(c, "Saved", body, myNalogBack)
}
// consoleMyNalogResume clears a self-imposed pause once the operator has dealt with its cause.
func (s *Server) consoleMyNalogResume(c *gin.Context) {
if err := s.payments.ResumeMyNalog(c.Request.Context()); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Resumed", "the export will run again on the next tick", myNalogBack)
}
// consoleMyNalogRecheck asks the tax service once more whether a receipt with an unresolved outcome
// exists after all.
func (s *Server) consoleMyNalogRecheck(c *gin.Context) {
id, ok := s.myNalogLedgerID(c)
if !ok {
return
}
found, err := s.mynalog.Recheck(c.Request.Context(), id)
if err != nil {
s.renderConsoleMessage(c, "Re-check failed", err.Error(), myNalogBack)
return
}
if found {
s.renderConsoleMessage(c, "Found",
"the receipt does exist; the income is now recorded as filed", myNalogBack)
return
}
s.renderConsoleMessage(c, "Not found",
"the tax service still does not list this receipt. If you are sure it was never created, "+
"return the income to the queue; otherwise file it by hand and enter its number here",
myNalogBack)
}
// consoleMyNalogRequeue puts an unresolved income back into the queue. It is the operator asserting
// what the software may not assume on its own: that the receipt really was never created.
func (s *Server) consoleMyNalogRequeue(c *gin.Context) {
id, ok := s.myNalogLedgerID(c)
if !ok {
return
}
if err := s.mynalog.Requeue(c.Request.Context(), id); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Queued", "the income will be filed on the next run", myNalogBack)
}
// consoleMyNalogManual records an income the operator filed by hand, together with the receipt
// number the tax cabinet gave it. That number is what lets a later refund annul the receipt
// automatically, which is why it is asked for rather than a simple "done" tick.
func (s *Server) consoleMyNalogManual(c *gin.Context) {
id, ok := s.myNalogLedgerID(c)
if !ok {
return
}
receipt := trimForm(c, "receipt_uuid")
if receipt == "" {
s.renderConsoleMessage(c, "Missing receipt number",
"enter the receipt number the tax cabinet shows for this income", myNalogBack)
return
}
if err := s.mynalog.RecordManual(c.Request.Context(), id, receipt); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Recorded",
"the income counts as filed; a refund will annul this receipt automatically", myNalogBack)
}
// consoleMyNalogSkip rules an income out of the export for a reason the operator gives.
func (s *Server) consoleMyNalogSkip(c *gin.Context) {
id, ok := s.myNalogLedgerID(c)
if !ok {
return
}
reason := trimForm(c, "reason")
if reason == "" {
reason = "ruled out by an operator"
}
if err := s.mynalog.Skip(c.Request.Context(), id, reason); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Ruled out", "this income will not be filed", myNalogBack)
}
// myNalogLedgerID parses the posted ledger id.
func (s *Server) myNalogLedgerID(c *gin.Context) (uuid.UUID, bool) {
id, err := uuid.Parse(trimForm(c, "ledger_id"))
if err != nil {
s.renderConsoleMessage(c, "Invalid id", "the ledger id is not valid", myNalogBack)
return uuid.UUID{}, false
}
return id, true
}
// consoleMyNalogExport writes the outstanding income as CSV in the shape the tax cabinet's own form
// expects — the date, the service description and the amount.
//
// This is the fallback for the API being unavailable or having changed, which for an undocumented
// interface is a matter of when rather than if. The ledger id travels with each row so that the
// receipt number can be entered back on the page afterwards, which is what keeps a hand-filed
// income annullable on a refund.
func (s *Server) consoleMyNalogExport(c *gin.Context) {
queue, err := s.payments.MyNalogQueue(c.Request.Context(), myNalogListLimit)
if err != nil {
s.consoleError(c, err)
return
}
loc := s.mynalog.Location()
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="mynalog.csv"`)
w := csv.NewWriter(c.Writer)
_ = w.Write([]string{"operation_time", "service_name", "amount", "currency", "status", "ledger_id"})
for _, in := range append(queue.Incomes, queue.Attention...) {
_ = w.Write([]string{
in.OperationTime.In(loc).Format("2006-01-02 15:04:05"),
csvSafe(s.mynalog.ReceiptName(in)),
in.Amount.Major(),
string(in.Amount.Currency()),
in.Status,
in.LedgerID.String(),
})
}
w.Flush()
}
// myNalogEnabled reports whether the tax-export console section should exist.
func (s *Server) myNalogEnabled() bool { return s.mynalog != nil && s.payments != nil }
// registerMyNalogConsole attaches the tax-export section to the console group.
func (s *Server) registerMyNalogConsole(gm *gin.RouterGroup) {
if !s.myNalogEnabled() {
return
}
gm.GET("/mynalog", s.consoleMyNalog)
gm.GET("/mynalog.csv", s.consoleMyNalogExport)
gm.POST("/mynalog/login", s.consoleMyNalogSignIn)
gm.POST("/mynalog/logout", s.consoleMyNalogSignOut)
gm.POST("/mynalog/run", s.consoleMyNalogRun)
gm.POST("/mynalog/auto", s.consoleMyNalogAuto)
gm.POST("/mynalog/resume", s.consoleMyNalogResume)
gm.POST("/mynalog/recheck", s.consoleMyNalogRecheck)
gm.POST("/mynalog/requeue", s.consoleMyNalogRequeue)
gm.POST("/mynalog/manual", s.consoleMyNalogManual)
gm.POST("/mynalog/skip", s.consoleMyNalogSkip)
}
+7
View File
@@ -28,6 +28,7 @@ import (
"scrabble/backend/internal/gamelimits"
"scrabble/backend/internal/link"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/mynalogsync"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/ratewatch"
@@ -130,6 +131,10 @@ type Deps struct {
// deployment sets it, so the set is empty and the direct rail resolves to YooKassa; it stays
// wired so restoring the rail is a credentials change (backend/internal/robokassa/README.md).
Robokassa robokassa.Shops
// MyNalog drives the professional-income tax export from the admin console. A nil MyNalog
// leaves the console section unregistered, which is how a deployment without an encryption key
// runs — the rail is dormant rather than half-present.
MyNalog *mynalogsync.Exporter
}
// Server owns the gin engine, the underlying HTTP server and the readiness
@@ -162,6 +167,7 @@ type Server struct {
vatCode int
publicURL string
robokassa robokassa.Shops
mynalog *mynalogsync.Exporter
notifier notify.Publisher
console *adminconsole.Renderer
exportKey []byte
@@ -220,6 +226,7 @@ func New(addr string, deps Deps) *Server {
vatCode: deps.YooKassaVatCode,
publicURL: deps.PublicBaseURL,
robokassa: deps.Robokassa,
mynalog: deps.MyNalog,
notifier: notifier,
renderer: deps.Renderer,
http: &http.Server{Addr: addr, Handler: engine},