From e3c2e80a0a6b4c3d5f4913686038497c64c66b34 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Tue, 28 Jul 2026 15:40:36 +0200
Subject: [PATCH 1/5] =?UTF-8?q?feat(payments):=20report=20income=20to=20?=
=?UTF-8?q?=C2=AB=D0=9C=D0=BE=D0=B9=20=D0=BD=D0=B0=D0=BB=D0=BE=D0=B3=C2=BB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.claude/CLAUDE.md | 20 +
.gitea/workflows/ci.yaml | 2 +
.gitea/workflows/prod-deploy.yaml | 2 +
.gitea/workflows/prod-rollback.yaml | 2 +
backend/cmd/backend/main.go | 34 +
backend/internal/adminconsole/render_test.go | 13 +
.../adminconsole/templates/layout.gohtml | 1 +
.../templates/pages/mynalog.gohtml | 115 +++
backend/internal/adminconsole/views.go | 53 ++
backend/internal/config/config.go | 24 +
backend/internal/inttest/mynalog_test.go | 723 ++++++++++++++++++
backend/internal/mynalog/auth.go | 130 ++++
backend/internal/mynalog/crypto.go | 85 ++
backend/internal/mynalog/income.go | 194 +++++
backend/internal/mynalog/mynalog.go | 239 ++++++
backend/internal/mynalog/mynalog_test.go | 497 ++++++++++++
backend/internal/mynalog/receipt.go | 85 ++
backend/internal/mynalogsync/exporter.go | 657 ++++++++++++++++
backend/internal/mynalogsync/mailer.go | 260 +++++++
backend/internal/mynalogsync/watchdog.go | 148 ++++
backend/internal/mynalogsync/worker.go | 84 ++
backend/internal/payments/mynalog.go | 326 ++++++++
backend/internal/payments/store_mynalog.go | 575 ++++++++++++++
.../postgres/migrations/00019_mynalog.sql | 96 +++
.../internal/server/handlers_admin_console.go | 4 +
.../internal/server/handlers_admin_mynalog.go | 334 ++++++++
backend/internal/server/server.go | 7 +
deploy/.env.example | 10 +
deploy/README.md | 3 +
deploy/docker-compose.yml | 8 +
deploy/write-prod-env.sh | 2 +
docs/ARCHITECTURE.md | 8 +
docs/PAYMENTS.md | 65 +-
docs/PAYMENTS_DECISIONS_ru.md | 68 +-
docs/PAYMENTS_ru.md | 65 +-
35 files changed, 4926 insertions(+), 13 deletions(-)
create mode 100644 backend/internal/adminconsole/templates/pages/mynalog.gohtml
create mode 100644 backend/internal/inttest/mynalog_test.go
create mode 100644 backend/internal/mynalog/auth.go
create mode 100644 backend/internal/mynalog/crypto.go
create mode 100644 backend/internal/mynalog/income.go
create mode 100644 backend/internal/mynalog/mynalog.go
create mode 100644 backend/internal/mynalog/mynalog_test.go
create mode 100644 backend/internal/mynalog/receipt.go
create mode 100644 backend/internal/mynalogsync/exporter.go
create mode 100644 backend/internal/mynalogsync/mailer.go
create mode 100644 backend/internal/mynalogsync/watchdog.go
create mode 100644 backend/internal/mynalogsync/worker.go
create mode 100644 backend/internal/payments/mynalog.go
create mode 100644 backend/internal/payments/store_mynalog.go
create mode 100644 backend/internal/postgres/migrations/00019_mynalog.sql
create mode 100644 backend/internal/server/handlers_admin_mynalog.go
diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index 7de110e..bfeeaaf 100644
--- a/.claude/CLAUDE.md
+++ b/.claude/CLAUDE.md
@@ -282,6 +282,26 @@ The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded unde
security list shared by both hops either lives in `pkg/` or is enforced backend-side. The YooKassa
sender allowlist is enforced in the backend, with the gateway forwarding the real peer IP as
`X-Forwarded-For` — do not duplicate the CIDR list into the gateway.
+ - **«Мой налог» (НПД) has NO test environment.** Every call lands on the owner's real tax
+ account, so the rail is verified against an `httptest` stub plus one real purchase the owner
+ makes himself. Its API (`lknpd.nalog.ru`) is reverse-engineered — no contract, no sandbox.
+ - **`POST /api/v1/income` is NOT idempotent.** A timeout does not mean nothing was filed. The
+ only recovery is searching the taxpayer's income list by the exact `services[].name`, so that
+ name is frozen in the DB *before* the call and carries a marker from the **tail** of the order
+ id. Do not "simplify" it to the head: our order ids are UUIDv7, whose leading hex digits are a
+ millisecond clock — the first eight are shared by every order in the same ~65 seconds, which
+ would make two purchases indistinguishable to the probe. An unresolved outcome halts the
+ whole queue on purpose; a blind retry is the one thing that must never happen.
+ - **The receipt's time zone decides the tax month.** The ledger is UTC; `BACKEND_MYNALOG_TZ`
+ (default `Europe/Moscow`) is what a payment is filed under, so a 23:30 UTC payment on the last
+ of the month belongs to the *next* one.
+ - **A bank card is an электронное средство платежа**, so ст. 14 ч. 3 ФЗ-422 wants the receipt
+ at settlement; the до-9-го-числа delay applies only to settlements *without* an ЭСП. Do not
+ "relax" the cadence on the strength of the 9th — the automatic mode satisfies both readings,
+ which is why the ambiguity never had to be settled.
+ - **A bank does not auto-register income for an ИП на НПД** unless that service is explicitly
+ enabled with the bank. Money landing on the account is not a чек. VK payouts are therefore a
+ separate, still-manual stream (and VK is a legal entity: 6%, its INN in the receipt).
- **Robokassa is retired but wired.** The direct rail resolves YooKassa → else Robokassa, and no
deployment sets the Robokassa credentials. Reviving it is a credentials change, not a code change;
`backend/internal/robokassa/README.md` holds the retired vars, cabinet URLs and the known gap
diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
index 4da0e68..335e95b 100644
--- a/.gitea/workflows/ci.yaml
+++ b/.gitea/workflows/ci.yaml
@@ -379,6 +379,8 @@ jobs:
YOOKASSA_WEB_SECRET_KEY: ${{ secrets.TEST_BACKEND_YOOKASSA_WEB_SECRET_KEY }}
YOOKASSA_WEB_TEST: "1"
YOOKASSA_VAT_CODE: ${{ vars.TEST_BACKEND_YOOKASSA_VAT_CODE }}
+ MYNALOG_KEY: ${{ secrets.TEST_MYNALOG_KEY }}
+ MYNALOG_TZ: ${{ vars.TEST_MYNALOG_TZ }}
SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }}
# Operator alerts: backend admin emails (new feedback / complaints) + Grafana
# infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS
diff --git a/.gitea/workflows/prod-deploy.yaml b/.gitea/workflows/prod-deploy.yaml
index 2dc88b0..0963cb3 100644
--- a/.gitea/workflows/prod-deploy.yaml
+++ b/.gitea/workflows/prod-deploy.yaml
@@ -111,6 +111,8 @@ jobs:
YOOKASSA_ANDROID_SECRET_KEY: ${{ secrets.PROD_BACKEND_YOOKASSA_ANDROID_SECRET_KEY }}
YOOKASSA_ANDROID_TEST: ${{ vars.PROD_BACKEND_YOOKASSA_ANDROID_TEST }}
YOOKASSA_VAT_CODE: ${{ vars.PROD_BACKEND_YOOKASSA_VAT_CODE }}
+ MYNALOG_KEY: ${{ secrets.PROD_MYNALOG_KEY }}
+ MYNALOG_TZ: ${{ vars.PROD_MYNALOG_TZ }}
# VK ID web login: the "Web" app id (the gateway reuses it as GATEWAY_VK_ID_APP_ID at
# runtime) + the app's protected key. Both shared across contours. The redirect URL is
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
diff --git a/.gitea/workflows/prod-rollback.yaml b/.gitea/workflows/prod-rollback.yaml
index c122cde..a1f4aa8 100644
--- a/.gitea/workflows/prod-rollback.yaml
+++ b/.gitea/workflows/prod-rollback.yaml
@@ -70,6 +70,8 @@ jobs:
YOOKASSA_ANDROID_SECRET_KEY: ${{ secrets.PROD_BACKEND_YOOKASSA_ANDROID_SECRET_KEY }}
YOOKASSA_ANDROID_TEST: ${{ vars.PROD_BACKEND_YOOKASSA_ANDROID_TEST }}
YOOKASSA_VAT_CODE: ${{ vars.PROD_BACKEND_YOOKASSA_VAT_CODE }}
+ MYNALOG_KEY: ${{ secrets.PROD_MYNALOG_KEY }}
+ MYNALOG_TZ: ${{ vars.PROD_MYNALOG_TZ }}
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go
index 0382ee9..2d87fb6 100644
--- a/backend/cmd/backend/main.go
+++ b/backend/cmd/backend/main.go
@@ -31,6 +31,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/postgres"
@@ -303,6 +304,38 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
games.SetHintWallet(paymentsSvc)
merger.SetPayments(paymentsSvc)
+ // Professional-income tax export. The operator files the direct rail's rouble income with the
+ // tax service from the admin console; once armed there, a worker keeps doing it on a cadence.
+ // The whole rail is dormant without BACKEND_MYNALOG_KEY, which is the state a deployment starts
+ // in — there is nowhere safe to keep the cabinet's refresh token without it.
+ //
+ // taxAlerts is declared as the interface, not the concrete type: assigning an unconfigured
+ // (nil) *MailAlerter to it would produce a non-nil interface holding a nil pointer, and every
+ // "is there an alert channel?" test downstream would answer yes and then panic.
+ var taxAlerts mynalogsync.Alerter
+ if a := mynalogsync.NewMailAlerter(mailer, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo); a != nil {
+ taxAlerts = a
+ }
+ taxExport := mynalogsync.New(paymentsSvc, cfg.MyNalog, taxAlerts, logger)
+ if taxExport != nil {
+ go taxExport.Run(ctx, mynalogsync.WorkerInterval)
+ // Buyer letters: the purchase confirmation rides the payment-event outbox on its own
+ // cursor, the receipt and annulment letters ride the export rows. No payment path is
+ // involved, so a relay outage delays letters rather than losing them.
+ if buyerMail := mynalogsync.NewMailer(paymentsSvc, accounts, mailer, cfg.SMTP.From,
+ cfg.MyNalog.BaseURL, cfg.MyNalog.Location, logger); buyerMail != nil {
+ go buyerMail.Run(ctx, mynalogsync.MailInterval)
+ }
+ // The filing-deadline watchdog runs whether or not the automatic export does: the case it
+ // exists for is precisely the export being switched off, paused or broken.
+ if watchdog := mynalogsync.NewWatchdog(paymentsSvc, taxAlerts, cfg.MyNalog.Location, logger); watchdog != nil {
+ go watchdog.Run(ctx, mynalogsync.WatchdogInterval)
+ }
+ logger.Info("mynalog tax export ready",
+ zap.Duration("interval", mynalogsync.WorkerInterval),
+ zap.String("time_zone", cfg.MyNalog.Location.String()))
+ }
+
// The image-render sidecar client for the PNG export artifact; nil (PNG
// download answers 404) when BACKEND_RENDERER_URL is unset.
var renderer *render.Client
@@ -340,6 +373,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
YooKassaVatCode: cfg.YooKassaVatCode,
PublicBaseURL: cfg.PublicBaseURL,
Robokassa: cfg.Robokassa,
+ MyNalog: taxExport,
})
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go
index 05818e2..0bd8117 100644
--- a/backend/internal/adminconsole/render_test.go
+++ b/backend/internal/adminconsole/render_test.go
@@ -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 {
diff --git a/backend/internal/adminconsole/templates/layout.gohtml b/backend/internal/adminconsole/templates/layout.gohtml
index a6fb598..2ab1a61 100644
--- a/backend/internal/adminconsole/templates/layout.gohtml
+++ b/backend/internal/adminconsole/templates/layout.gohtml
@@ -24,6 +24,7 @@
Banners
Catalog
Ledger
+ My Nalog
Dictionary
Broadcast
Grafana ↗
diff --git a/backend/internal/adminconsole/templates/pages/mynalog.gohtml b/backend/internal/adminconsole/templates/pages/mynalog.gohtml
new file mode 100644
index 0000000..5e5c243
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/mynalog.gohtml
@@ -0,0 +1,115 @@
+{{define "content" -}}
+My Nalog
+{{with .Data}}
+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 {{.TimeZone}}, 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.
+
+{{if .Backlog.Count}}
+
+{{if .Backlog.Overdue}}Overdue{{else}}Closed month still unfiled{{end}}
+{{.Backlog.Count}} income(s) totalling {{.Backlog.Amount}} 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}}
+
+{{end}}
+
+
+Tax cabinet
+{{if .SignedIn}}
+Signed in as {{.INN}}.
+{{if .LastOK}}Last successful exchange: {{.LastOK}}.{{else}}No successful exchange yet.{{end}}
+{{if .Paused}}
+The export has taken itself out of service: {{.PauseReason}}
+
+{{end}}
+
+Automatic export is {{if .AutoEnabled}}on{{else}}off{{end}}. It only
+ever runs when there is something to file, so an idle installation sends the tax service nothing.
+{{else}}
+Not signed in. The password is used once to obtain a refresh token and is not stored.
+
+{{end}}
+
+
+{{if .Attention}}
+Needs attention
+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.
+
+| Time | Amount | Service name | Tries | Last error | |
+
+{{range .Attention}}
+
+| {{.At}} |
+{{.Amount}} |
+{{.Name}} |
+{{.Attempts}} |
+{{.LastError}} |
+
+
+
+
+ |
+
+{{end}}
+
+
+{{end}}
+
+To annul
+
+| Account | Receipt | State |
+
+{{range .Cancels}}
+
+| card |
+{{.ReceiptUUID}} |
+{{if .Failed}}a previous attempt was refused{{else}}awaiting{{end}} |
+
+{{else}}
+| nothing to annul |
+{{end}}
+
+
+
+To file
+
+| Time | Account | Amount | Service name | Tries | Last error | |
+
+{{range .Owed}}
+
+| {{.At}} |
+card |
+{{.Amount}} |
+{{.Name}} |
+{{.Attempts}} |
+{{.LastError}} |
+
+
+
+ |
+
+{{else}}
+| nothing to file |
+{{end}}
+
+
+{{end}}
+{{- end}}
diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go
index 617a2dc..f6379db 100644
--- a/backend/internal/adminconsole/views.go
+++ b/backend/internal/adminconsole/views.go
@@ -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
+}
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index dc37edb..308de43 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -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
diff --git a/backend/internal/inttest/mynalog_test.go b/backend/internal/inttest/mynalog_test.go
new file mode 100644
index 0000000..ecb6484
--- /dev/null
+++ b/backend/internal/inttest/mynalog_test.go
@@ -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
+}
diff --git a/backend/internal/mynalog/auth.go b/backend/internal/mynalog/auth.go
new file mode 100644
index 0000000..8c5dbdb
--- /dev/null
+++ b/backend/internal/mynalog/auth.go
@@ -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
+}
diff --git a/backend/internal/mynalog/crypto.go b/backend/internal/mynalog/crypto.go
new file mode 100644
index 0000000..80294ea
--- /dev/null
+++ b/backend/internal/mynalog/crypto.go
@@ -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
+}
diff --git a/backend/internal/mynalog/income.go b/backend/internal/mynalog/income.go
new file mode 100644
index 0000000..023b861
--- /dev/null
+++ b/backend/internal/mynalog/income.go
@@ -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
+}
diff --git a/backend/internal/mynalog/mynalog.go b/backend/internal/mynalog/mynalog.go
new file mode 100644
index 0000000..1920752
--- /dev/null
+++ b/backend/internal/mynalog/mynalog.go
@@ -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
+}
diff --git a/backend/internal/mynalog/mynalog_test.go b/backend/internal/mynalog/mynalog_test.go
new file mode 100644
index 0000000..9bfa36b
--- /dev/null
+++ b/backend/internal/mynalog/mynalog_test.go
@@ -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)
+ }
+ }
+}
diff --git a/backend/internal/mynalog/receipt.go b/backend/internal/mynalog/receipt.go
new file mode 100644
index 0000000..aaa2d02
--- /dev/null
+++ b/backend/internal/mynalog/receipt.go
@@ -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)
+}
diff --git a/backend/internal/mynalogsync/exporter.go b/backend/internal/mynalogsync/exporter.go
new file mode 100644
index 0000000..b869807
--- /dev/null
+++ b/backend/internal/mynalogsync/exporter.go
@@ -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)
+}
diff --git a/backend/internal/mynalogsync/mailer.go b/backend/internal/mynalogsync/mailer.go
new file mode 100644
index 0000000..16e75ee
--- /dev/null
+++ b/backend/internal/mynalogsync/mailer.go
@@ -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"))
+}
diff --git a/backend/internal/mynalogsync/watchdog.go b/backend/internal/mynalogsync/watchdog.go
new file mode 100644
index 0000000..a925b59
--- /dev/null
+++ b/backend/internal/mynalogsync/watchdog.go
@@ -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))
+ }
+}
diff --git a/backend/internal/mynalogsync/worker.go b/backend/internal/mynalogsync/worker.go
new file mode 100644
index 0000000..140c8ef
--- /dev/null
+++ b/backend/internal/mynalogsync/worker.go
@@ -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,
+ })
+}
diff --git a/backend/internal/payments/mynalog.go b/backend/internal/payments/mynalog.go
new file mode 100644
index 0000000..29c6f46
--- /dev/null
+++ b/backend/internal/payments/mynalog.go
@@ -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())
+}
diff --git a/backend/internal/payments/store_mynalog.go b/backend/internal/payments/store_mynalog.go
new file mode 100644
index 0000000..a0b0b74
--- /dev/null
+++ b/backend/internal/payments/store_mynalog.go
@@ -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
+}
diff --git a/backend/internal/postgres/migrations/00019_mynalog.sql b/backend/internal/postgres/migrations/00019_mynalog.sql
new file mode 100644
index 0000000..632282a
--- /dev/null
+++ b/backend/internal/postgres/migrations/00019_mynalog.sql
@@ -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;
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go
index 449baec..e308521 100644
--- a/backend/internal/server/handlers_admin_console.go
+++ b/backend/internal/server/handlers_admin_console.go
@@ -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
diff --git a/backend/internal/server/handlers_admin_mynalog.go b/backend/internal/server/handlers_admin_mynalog.go
new file mode 100644
index 0000000..79abeb2
--- /dev/null
+++ b/backend/internal/server/handlers_admin_mynalog.go
@@ -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)
+}
diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go
index 361a36d..a475301 100644
--- a/backend/internal/server/server.go
+++ b/backend/internal/server/server.go
@@ -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},
diff --git a/deploy/.env.example b/deploy/.env.example
index 33df396..1b022f6 100644
--- a/deploy/.env.example
+++ b/deploy/.env.example
@@ -155,6 +155,16 @@ YOOKASSA_ANDROID_TEST=
# receipts back on — the switch a lost НПД regime would need. A Gitea VARIABLE, not a secret.
YOOKASSA_VAT_CODE=
#
+# «Мой налог» (НПД) export. MYNALOG_KEY is OUR OWN random 32 bytes — not a tax-service credential:
+# it encrypts the tax cabinet's refresh token before it is written to Postgres, so the credential
+# does not travel in a database dump or a backup. Generate with `openssl rand -base64 32`; a Gitea
+# TEST_/PROD_ secret. Empty leaves the whole rail dormant — no console section and no workers.
+# The login and password are entered in the admin console and are never stored.
+# MYNALOG_TZ is the taxpayer's own time zone: its offset decides which tax month a near-midnight
+# payment is filed under. A Gitea VARIABLE, not a secret. Defaults to Europe/Moscow.
+MYNALOG_KEY=
+MYNALOG_TZ=
+#
# Robokassa (the retired direct rail) is deliberately absent here: no deployment sets its
# credentials, which is what keeps it dormant. The backend still reads BACKEND_ROBOKASSA_* and
# falls the direct rail back to it if they are ever set again — see
diff --git a/deploy/README.md b/deploy/README.md
index 2f10f13..52c2c5c 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -81,6 +81,9 @@ compose binds from this directory.
| `BACKEND_YOOKASSA_{WEB,ANDROID}_TEST` | **variable** | `1` marks the credentials as a **test shop**: the intake then refuses to credit anything but a test payment (and a live shop refuses a test one). A variable, not a secret, so switching a contour between a test and a live shop is a flag flip + redeploy. |
| `BACKEND_YOOKASSA_VAT_CODE` | **variable** | Fiscal receipts (54-ФЗ). **Empty by default = no receipt is sent at all**, which is the intended state: the merchant runs on НПД, outside 54-ФЗ, so nothing is registered through the provider and each operation is reported to «Мой налог» instead. Setting a VAT rate code (tag 1199: `1` = «Без НДС», `4` = 20%, `11` = 22%) switches receipts back on with no code change — the path a lost НПД regime (annual income ceiling) would take. Confirm the rate with the accountant before switching it on: a wrong rate produces wrong receipts, not a failed payment, so it fails silently. |
+| `MYNALOG_KEY` | secret | Seals the «Мой налог» tax-cabinet **refresh token** at rest. Our own random 32 bytes, **not** a tax-service credential — generate with `openssl rand -base64 32`. Empty leaves the whole tax-export rail dormant: no `/_gm/mynalog` section, no workers. The cabinet login and password are typed into the console and never stored, so this is the only secret the rail needs. Rotating or losing it is recoverable — the stored token stops decrypting and the console asks the operator to sign in again. |
+| `MYNALOG_TZ` | **variable** | The taxpayer's own time zone (IANA name; defaults to `Europe/Moscow`). Its offset is what decides which **tax month** a near-midnight payment is filed under, so it follows the taxpayer's registration rather than the server clock. |
+
**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
boot** when it is empty.
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index 86aba69..32475dc 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -182,6 +182,14 @@ services:
# Setting a VAT rate code (tag 1199: 1 = «Без НДС», 4 = 20%, 11 = 22%) turns receipts back on
# — the switch a lost НПД regime would need — with no code change.
BACKEND_YOOKASSA_VAT_CODE: ${YOOKASSA_VAT_CODE:-}
+ # «Мой налог» (НПД) export. The key is ours, not the tax service's: it seals the cabinet's
+ # refresh token at rest, so the credential survives neither a database dump nor a backup in
+ # readable form. Empty leaves the whole rail dormant — no console section, no workers. The
+ # operator signs in from the console; the password is never stored. The time zone decides
+ # which tax month a near-midnight payment is filed under, so it follows the taxpayer's
+ # registration rather than the server clock.
+ BACKEND_MYNALOG_KEY: ${MYNALOG_KEY:-}
+ BACKEND_MYNALOG_TZ: ${MYNALOG_TZ:-}
# The dictionary lives on a named volume seeded from the image on first boot
# (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
# inherits). The admin console writes new version subdirectories here, and the
diff --git a/deploy/write-prod-env.sh b/deploy/write-prod-env.sh
index 4ea558f..4263c09 100755
--- a/deploy/write-prod-env.sh
+++ b/deploy/write-prod-env.sh
@@ -61,6 +61,8 @@ export YOOKASSA_ANDROID_SHOP_ID='$YOOKASSA_ANDROID_SHOP_ID'
export YOOKASSA_ANDROID_SECRET_KEY='$YOOKASSA_ANDROID_SECRET_KEY'
export YOOKASSA_ANDROID_TEST='$YOOKASSA_ANDROID_TEST'
export YOOKASSA_VAT_CODE='${YOOKASSA_VAT_CODE:-1}'
+export MYNALOG_KEY='$MYNALOG_KEY'
+export MYNALOG_TZ='${MYNALOG_TZ:-Europe/Moscow}'
export VITE_VK_APP_ID='$VITE_VK_APP_ID'
export VITE_VK_ID_REDIRECT_URL='$VITE_VK_ID_REDIRECT_URL'
export GATEWAY_VK_ID_CLIENT_SECRET='$GATEWAY_VK_ID_CLIENT_SECRET'
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 282de0a..fb8e0bd 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -1376,6 +1376,7 @@ link — misses the event; while an add-email confirmation is pending the client
| Manual account block (suspension) | backend: a per-request gate refuses a blocked account on every `/api/v1/user/*` route except the block-status probe with **403 `account_blocked`**; the operator blocks/unblocks from the admin console (§11) |
| User feedback gate | backend rejects a guest or a `feedback_banned` account from submitting; the **gateway** also rejects a guest's `feedback.submit` (the `Op.NonGuest` flag + `is_guest` from session resolve) with **`guest_forbidden`** before any backend call; attachments are served `nosniff` with a download disposition for non-images (§15) |
| Admin authentication | a single Basic-Auth gate on `/_gm/*`, forwarded **verbatim** to the backend's server-rendered admin console (and, in the deployed contour, routing `/_gm/grafana/*` to Grafana). In the deploy the **caddy** owns this gate (§13); a local non-caddy run uses the gateway's own `GATEWAY_ADMIN_*` proxy, which the per-IP admin limiter class guards ahead of its Basic-Auth — the caddy-fronted path has no limiter (stock caddy), an accepted gap. The backend trusts the proxy (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked |
+| Tax-cabinet credential at rest | the backend seals the «Мой налог» **refresh token** with AES-GCM under `BACKEND_MYNALOG_KEY` before storing it (`payments.mynalog_session`), so a database dump or a backup carries no usable credential. The cabinet login and password are typed into the admin console, used once and **never persisted**; an unset key leaves the whole export rail unregistered. The console's error page prints provider errors verbatim, so the client never puts credentials in an error string |
| backend ↔ gateway ↔ validator trust | the network (only gateway may reach backend; the validator and the gateway's admin bot-link relay serve unauthenticated gRPC on the trusted internal segment) |
| remote bot ↔ gateway (bot-link) | **mutual TLS**: a private CA signs the gateway server cert and the bot client cert, and each verifies the other. The bot dials out (no inbound port, no static IP), so the channel is guarded solely by mTLS — the bot client key is as sensitive as the token (§13) |
@@ -1543,6 +1544,13 @@ The gateway exposes the bot-link on a dedicated mTLS gRPC listener
(`GATEWAY_BOTLINK_ADDR`, internal-only in the test contour, published in prod) plus a
plaintext relay (`GATEWAY_BOTLINK_RELAY_ADDR`) the backend admin console calls.
+The **backend** egresses directly (no sidecar) to the payment provider and, for the
+professional-income tax export (`docs/PAYMENTS.md` §12), to `lknpd.nalog.ru` — a Russian
+government host, so the prod main host's own Russian egress is what that path depends on. The
+export is dormant unless `BACKEND_MYNALOG_KEY` is set, and even when armed it makes no outbound
+call at all while its queue is empty: the queue is a local query, and nothing authenticates until
+there is something to file.
+
The full contour (`deploy/docker-compose.yml`) runs one `gateway`, one `backend`,
one Postgres, the static `landing`, the Telegram `validator` and `bot` (+ the bot's VPN
sidecar — the `bot`+`vpn` pair is gated to a `telegram-local` compose profile so the prod
diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md
index a64dc52..ac4f130 100644
--- a/docs/PAYMENTS.md
+++ b/docs/PAYMENTS.md
@@ -454,11 +454,7 @@ Receipts are automatic **through the provider**, and differ by rail:
return is foreseeable: НПД carries an annual income ceiling, and losing the regime puts 54-ФЗ back
in force.
- Everything an automated «Мой налог» submission needs is already recorded — the order id, the
- provider payment id, the amount and currency, the credit time, the sold-pack snapshot, and refunds
- as their own ledger rows, all exportable as CSV. The one thing it will additionally need is a
- per-operation "already reported" marker for its own idempotency; that belongs to the submission
- work, not here.
+ Reporting each rouble operation to «Мой налог» is automated — see below.
- **VK** — VK processes Votes through the tax authority itself; nothing to do.
- **TG Stars** — no tax side (for a RU self-employed, Stars are not legally withdrawable = not
НПД income; accepted, no receipt issued).
@@ -466,6 +462,65 @@ Receipts are automatic **through the provider**, and differ by rail:
**ОРД** (ad marking) for VK ads is handled on VK's side. (Not legal advice — the owner
confirms the exact НПД scheme with a tax advisor.)
+### Reporting to «Мой налог» (D53-D60)
+
+Rouble income taken through the direct rail is registered with the tax service by us, because
+nobody else does it: the provider neither files nor issues a receipt under this regime. The rail
+covers `kind='fund'`, `provider='yookassa'`, currency `RUB` — the store rails are out of scope,
+since the ledger records their price in the store's own currency and holds no rouble amount at all.
+
+**Where it runs.** `backend/internal/mynalog` is the API client (no database), `backend/internal/
+mynalogsync` the orchestration, `payments.mynalog_receipt` the per-income progress, and
+`/_gm/mynalog` the operator surface. The console button and the background worker call the same
+`RunBatch`; there is deliberately no second implementation of the part that decides what a failed
+registration means. The whole rail is dormant without `BACKEND_MYNALOG_KEY`.
+
+**The API is unofficial** — `lknpd.nalog.ru`, no published contract, no sandbox. Two consequences
+run through the design:
+
+- **Registering an income is not idempotent.** There is no idempotency key, so an error does not
+ mean nothing happened. The service name is therefore frozen in the database *before* the call and
+ carries a marker taken from the **tail** of the order id (a UUIDv7's leading digits are a
+ millisecond clock and would be shared by every order in the same minute). After a failure the
+ income list is searched for that exact name: found means filed, not found means **unresolved** —
+ which halts the queue until a human decides, because the alternatives are declaring an income
+ twice or not at all. One run at a time, on a Postgres advisory lock, for the same reason.
+- **Faults are classified, not just logged.** A 401 is renewed silently; a 429 backs off; a 5xx or
+ timeout retries later and only alerts after a day; any other 4xx is unfixable by repetition, and
+ three in a row take the rail out of service — a changed format must not become thousands of
+ requests overnight. Exceeding the annual НПД ceiling is separated out, because it means the regime
+ no longer applies.
+
+**Timing.** Every moment is sent in the taxpayer's zone (`BACKEND_MYNALOG_TZ`, default
+`Europe/Moscow`), since the offset decides which tax month a near-midnight payment falls into; the
+receipt carries the date the money arrived, not the date it was filed. The automatic mode ticks
+every 15 minutes and does nothing — including not authenticating — when the queue is empty. A
+separate daily watchdog runs **whether or not** the automatic mode does, and escalates unfiled
+income from a closed month on the 1st, the 5th and the 9th. (ст. 14 ч. 3 ФЗ-422 allows until the
+9th only for settlements *not* involving an electronic means of payment; a card payment is one, so
+the receipt is strictly due at settlement. The automatic mode satisfies both readings.)
+
+**Manual fallback.** `/_gm/mynalog.csv` lists exactly what would be sent — time, service name,
+amount — plus the ledger id, so a receipt filed by hand can have its number entered back on the
+page. That number is what keeps a hand-filed income annullable on a refund, which is why it is
+asked for rather than a plain "done" tick.
+
+**Refunds.** The tax service has no refund concept: annulling the receipt *is* the refund. A refund
+that lands before the income was ever filed is ruled out locally as `not_required` — nothing filed,
+nothing annulled, no call made. A partial refund is out of scope on both sides (§ Refunds).
+
+**The buyer hears about it three times**, in Russian, driven by durable queues rather than by the
+payment path: the purchase confirmation (which says plainly that it is *not* the fiscal receipt and
+that one follows), the receipt itself with its printable link, and — after a refund — a notice that
+the receipt has been annulled. The first rides the existing `payments.payment_events` outbox on its
+own `mailed_at` cursor; the other two ride columns on the export row. The address is the D36
+confirmed-email anchor a direct purchase already requires.
+
+**Credentials.** The cabinet login and password are typed into the console, used once and never
+stored: only the refresh token they yield is kept, sealed with AES-GCM under
+`BACKEND_MYNALOG_KEY` (our own random 32 bytes, not a tax-service credential) so it does not travel
+in a database dump. A lost key is a nuisance, not a loss — the operator signs in again.
+
## 13. Distribution (native Android)
- **RuStore** — an external payment gate is allowed (0%); native = clean `direct` context.
diff --git a/docs/PAYMENTS_DECISIONS_ru.md b/docs/PAYMENTS_DECISIONS_ru.md
index 2671780..c99bd92 100644
--- a/docs/PAYMENTS_DECISIONS_ru.md
+++ b/docs/PAYMENTS_DECISIONS_ru.md
@@ -345,9 +345,68 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
(тег 1212), способ расчёта `full_payment` (тег 1214), доставка на email-якорь D36,
`tax_system_code` не шлём. Возврат к чекам предсказуем: у НПД годовой потолок дохода 2,4 млн ₽, и
его превышение возвращает 54-ФЗ — тогда это правка переменной, а не кода.
- Связка с «Мой налог» — **отдельная задача**. Всё нужное для неё уже хранится (идентификатор заказа
- и платежа провайдера, сумма, валюта, время зачисления, снимок пакета, возвраты); не хватает только
- отметки «операция уже отправлена» для идемпотентности выгрузки — её заводит та задача.
+ Связка с «Мой налог» — **отдельная задача**, закрыта решениями D53-D60 ниже.
+
+## Выгрузка в «Мой налог» (интервью с владельцем, 2026-07-28)
+
+- **D53. Охват выгрузки — только рубли ЮKassa.** Выгружаем `kind='fund'`, `provider='yookassa'`,
+ валюта `RUB`. VK-Голоса и Telegram Stars вне охвата **не по решению, а по факту**: журнал операций
+ хранит их цену в `VOTE`/`XTR` и рублёвой суммы не знает вообще — она приходит выплатой платформы за
+ вычетом её комиссии. Плюс VK — юрлицо, то есть ставка 6% и ИНН плательщика в чеке, а это другой тип
+ чека. Stars владелец не выводит, поэтому спор о моменте признания дохода по ним не открываем.
+ Доход от rewarded-рекламы (`vk_ads`) денег в журнале не имеет и тоже вне охвата.
+- **D54. Пароль вводится, хранится только refresh-токен, зашифрованный.** Логин (ИНН) и пароль
+ вводятся в форме `/_gm/mynalog`, используются один раз и **никуда не пишутся**; на диск ложится
+ только выданный refresh-токен, запечатанный AES-GCM на `BACKEND_MYNALOG_KEY`. Ключ — наши
+ собственные случайные 32 байта, к учётным данным налоговой отношения не имеющие; он нужен потому,
+ что refresh-токен — долгоживущий доступ к личному кабинету ФНС, и в дампе базы (а бэкапы уезжают
+ в S3) ему открытым текстом не место. Потеря ключа не катастрофа: токен перестаёт расшифровываться,
+ и консоль просит войти заново. Вариант «логин/пароль в секретах Gitea» отклонён: это пароль от
+ налоговой, а не от магазина, и радиус поражения несопоставим.
+- **D55. Автоматический режим есть, но включается галочкой в консоли, а не конфигом.** Кнопка и
+ воркер зовут один и тот же `RunBatch` — второй реализации нет. Автомат по умолчанию выключен:
+ владелец сначала прогоняет рельс руками и убеждается, что чек выглядит правильно, и только потом
+ включает. Тик — 15 минут; при пустой очереди прогон **не авторизуется** и наружу не ходит.
+ Ограничение прогона — **бюджет времени** (13 минут у воркера, 100 секунд у кнопки), а не счётчик
+ чеков: счётчик ничего не защищает, защищают пауза 2 секунды между вызовами и предохранители.
+- **D56. Неустановленный исход останавливает очередь.** Регистрация дохода не идемпотентна: ключа
+ идемпотентности у API нет, поэтому ошибка не означает, что чек не создан. Название услуги
+ замораживается в базе **до** запроса и несёт маркер из **хвоста** идентификатора заказа (старшие
+ разряды UUIDv7 — миллисекундные часы, одинаковые у всех заказов одной минуты, и такой маркер сделал
+ бы две покупки неразличимыми). После сбоя ищем чек по этому точному названию: нашли — записываем,
+ не нашли — статус «неизвестно», и **новые чеки не отправляются**, пока человек не разберёт.
+ Аннулирования при этом продолжают идти: они только убирают доход. Один прогон за раз — advisory-лок
+ Postgres; строка, застрявшая в `sending` после падения процесса, под локом заведомо осиротевшая и
+ переводится в «неизвестно», а не переотправляется.
+- **D57. Сбои классифицируются на пять классов.** `401` — молча переавторизуемся (запрос отклонён до
+ обработки, поэтому единственный сбой, который можно повторить без зонда); `429` — отступаем; `5xx`
+ и таймаут — мягко, письмо только если сервис не поднялся за сутки; прочие `4xx` — жёстко, и три
+ подряд снимают рельс с эксплуатации с письмом, потому что смена формата API отклоняет всё подряд и
+ без этого превратилась бы в тысячи запросов за ночь; превышение годового лимита НПД выделено
+ отдельным классом (оно подчиняет предыдущий, чтобы проверка «это неисправимо?» его не пропустила) —
+ это уже не техническая ошибка, а потеря режима.
+- **D58. Сумма — полная, дата — фактическая, пояс — налогоплательщика.** В чек идёт то, что заплатил
+ покупатель: на НПД расходы не вычитаются, комиссия ЮKassa — расход владельца. Время операции — это
+ момент платежа, а не момент выгрузки, и отправляется в поясе `BACKEND_MYNALOG_TZ` (по умолчанию
+ `Europe/Moscow`), потому что смещение решает, в какой налоговый месяц попадёт околополуночный
+ платёж. Название услуги: `Внутриигровая валюта: "Фишка", NN шт. (ID: xxxxxxxx)`, не длиннее 128
+ символов — при усечении жертвуем описанием, маркер неприкосновенен.
+- **D59. Надзиратель за фискальным периодом работает всегда.** Отдельный суточный цикл, независимый
+ от автоматического режима — именно потому, что случай, ради которого он существует, это выключенный
+ или сломавшийся автомат. Эскалация по незакрытому прошлому месяцу: 1-е число — предупреждение, с
+ 5-го — тревога ежедневно, с 9-го — «срок вышел». Правовая рамка: ст. 14 ч. 3 ФЗ-422 даёт отсрочку
+ до 9-го числа только для расчётов, **не связанных** с электронными средствами платежа; оплата
+ картой — ЭСП, поэтому строгое прочтение требует чек в момент расчёта. Автоматический режим
+ удовлетворяет обеим трактовкам, поэтому спор решать не потребовалось.
+- **D60. Покупателю уходят три письма, и ни одно не трогает платёжный путь.** Подтверждение покупки
+ (сразу, с прямой оговоркой, что это **не** чек и что чек придёт отдельно), фискальный чек ссылкой
+ после регистрации и уведомление об аннулировании после возврата — иначе у покупателя осталась бы
+ ссылка на мёртвый чек. Только по-русски, локализация не нужна. Первое письмо едет по уже
+ существующему outbox'у `payments.payment_events` на собственном курсоре `mailed_at`, два других — по
+ колонкам строки выгрузки; поэтому в коде зачисления и возврата не потребовалось менять ни строки, а
+ сбой релея откладывает письмо, а не теряет его. Адрес — подтверждённый email-якорь D36, который
+ прямая покупка требует и так. Возврат, случившийся раньше выгрузки, закрывается как `not_required`:
+ ни чека, ни аннулирования, ни обращения к сервису.
## Заметки к оформлению документов
@@ -363,9 +422,10 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
ведёт к пропускам. Текст — только для фиксации решённого и пояснений. Усилить
feedback-память `prefer-interview-mode` после plan mode.
-## Все развилки закрыты (D1-D52)
+## Все развилки закрыты (D1-D60)
Интервью завершено. Дальше — оформление документов и реализация по релизам.
+D53-D60 добавлены отдельным интервью 2026-07-28 по выгрузке в «Мой налог».
**Дополнение 2026-07-14 (интервью с владельцем).** D41 ревизована (владелец переходит на
ИП: 54-ФЗ через облачную кассу Robokassa вместо авточека НПД); добавлены D42-D44 — сплит
diff --git a/docs/PAYMENTS_ru.md b/docs/PAYMENTS_ru.md
index 5d3e2cf..d3344a8 100644
--- a/docs/PAYMENTS_ru.md
+++ b/docs/PAYMENTS_ru.md
@@ -446,10 +446,7 @@ Stars, общая сумма по ним не значила бы ничего)
расчёта `full_payment` (тег 1214), доставка на email-якорь D36. Переключатель нужен потому, что
возврат к чекам предсказуем: у НПД есть годовой потолок дохода, и потеря режима возвращает 54-ФЗ.
- Всё, что потребуется автоматической отправке в «Мой налог», уже записано — идентификатор заказа,
- идентификатор платежа провайдера, сумма и валюта, время зачисления, снимок проданного пакета и
- возвраты отдельными строками журнала, всё выгружается в CSV. Не хватать будет только отметки «эта
- операция уже отправлена» для идемпотентности самой выгрузки; ей место в той задаче, а не здесь.
+ Отправка каждой рублёвой операции в «Мой налог» автоматизирована — см. ниже.
- **VK** — VK сам процессит Голоса через налоговую; делать нечего.
- **TG Stars** — налоговой стороны нет (для РФ-самозанятого Stars легально невыводимы = не
доход НПД; принимаем, чек не формируем).
@@ -457,6 +454,66 @@ Stars, общая сумма по ним не значила бы ничего)
**ОРД** (маркировка рекламы) по VK-рекламе — на стороне VK. (Не юридическая консультация —
владелец сверяет точную схему НПД с налоговым консультантом.)
+### Выгрузка в «Мой налог» (D53-D60)
+
+Рублёвый доход прямого рельса регистрируем в налоговой мы, потому что больше некому: провайдер при
+этом режиме ни отчёта не подаёт, ни чека не формирует. Охват — `kind='fund'`,
+`provider='yookassa'`, валюта `RUB`; магазинные рельсы вне охвата, так как журнал операций хранит их
+цену в валюте магазина и рублёвой суммы там нет вообще.
+
+**Где живёт.** `backend/internal/mynalog` — клиент API (без базы), `backend/internal/mynalogsync` —
+оркестрация, `payments.mynalog_receipt` — состояние по каждому приходу, `/_gm/mynalog` — экран
+оператора. Кнопка в консоли и фоновый воркер зовут один и тот же `RunBatch`; второй реализации той
+части, которая решает, что значит неудавшаяся регистрация, сознательно не существует. Без
+`BACKEND_MYNALOG_KEY` рельс спит целиком.
+
+**API неофициальный** — `lknpd.nalog.ru`, опубликованного контракта нет, тестовой среды нет. Отсюда
+два следствия, пронизывающих всю конструкцию:
+
+- **Регистрация дохода не идемпотентна.** Ключа идемпотентности нет, поэтому ошибка не означает, что
+ ничего не произошло. Название услуги замораживается в базе **до** запроса и несёт маркер из
+ **хвоста** идентификатора заказа (старшие разряды UUIDv7 — миллисекундные часы, они одинаковы у
+ всех заказов одной минуты). После сбоя список доходов ищется по этому точному названию: нашли —
+ значит зарегистрировано, не нашли — **исход неизвестен**, и очередь встаёт до решения человека,
+ потому что альтернативы — задвоить доход или потерять его. По той же причине один прогон за раз,
+ на advisory-локе Postgres.
+- **Сбои классифицируются, а не просто логируются.** 401 обновляем молча; 429 — отступаем; 5xx и
+ таймаут — повторим позже, письмо только через сутки; любой другой 4xx повтором не лечится, и три
+ подряд снимают рельс с эксплуатации — смена формата не должна превращаться в тысячи запросов за
+ ночь. Превышение годового лимита НПД выделено отдельно: это уже не техническая ошибка, а потеря
+ режима.
+
+**Время.** Каждый момент отправляется в часовом поясе налогоплательщика (`BACKEND_MYNALOG_TZ`, по
+умолчанию `Europe/Moscow`): смещение решает, в какой налоговый месяц попадёт околополуночный платёж.
+В чеке стоит дата поступления денег, а не дата выгрузки. Автоматический режим просыпается раз в 15
+минут и при пустой очереди не делает ничего — в том числе не авторизуется. Отдельный суточный
+надзиратель работает **независимо** от автоматического режима и эскалирует незакрытый прошлый месяц
+1-го, 5-го и 9-го числа. (Ст. 14 ч. 3 ФЗ-422 даёт отсрочку до 9-го только для расчётов, **не**
+связанных с электронными средствами платежа; оплата картой — ЭСП, поэтому чек строго в момент
+расчёта. Автоматический режим удовлетворяет обеим трактовкам.)
+
+**Ручной фолбэк.** `/_gm/mynalog.csv` отдаёт ровно то, что было бы отправлено — время, название
+услуги, сумму — плюс идентификатор строки журнала, чтобы номер выданного вручную чека можно было
+внести обратно на странице. Именно этот номер оставляет ручной приход аннулируемым при возврате,
+поэтому спрашивается он, а не галочка «сделано».
+
+**Возвраты.** У налоговой нет понятия возврата: аннулирование чека **и есть** возврат. Возврат,
+пришедший раньше, чем приход был выгружен, закрывается локально как `not_required` — ничего не
+регистрируем, ничего не аннулируем, наружу не ходим. Частичный возврат вне охвата с обеих сторон
+(см. раздел про возвраты).
+
+**Покупатель узнаёт трижды**, по-русски, из долговечных очередей, а не из платёжного пути:
+подтверждение покупки (в котором прямо сказано, что это **не** фискальный чек и что чек придёт
+отдельно), сам чек со ссылкой на печатную форму и — после возврата — уведомление об аннулировании.
+Первое едет по существующему outbox'у `payments.payment_events` на собственном курсоре `mailed_at`,
+два других — по колонкам строки выгрузки. Адрес — тот самый подтверждённый email-якорь D36, который
+прямая покупка требует и так.
+
+**Учётные данные.** Логин и пароль от кабинета вводятся в консоли, используются один раз и нигде не
+хранятся: на диск кладётся только полученный refresh-токен, запечатанный AES-GCM на
+`BACKEND_MYNALOG_KEY` (наши собственные случайные 32 байта, а не секрет налоговой) — чтобы он не
+уезжал в дампе базы. Потеря ключа — неудобство, а не потеря: оператор входит заново.
+
## 13. Дистрибуция (native Android)
- **RuStore** — внешний платёжный гейт разрешён (0%); native = чистый контекст `direct`.
--
2.52.0
From f8efa21158f906685325a9fa5c178e2df5fa6be0 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Tue, 28 Jul 2026 15:47:19 +0200
Subject: [PATCH 2/5] test(inttest): de-flake the no-repeat pin guard
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The bag seed comes from crypto/rand, so an opening rack occasionally has no
playable word — a legitimate engine answer that says nothing about the pin the
test is guarding. Reproduced at roughly one run in three hundred locally, and
it turned CI red once. Retry with fresh games instead: an unlucky draw no
longer decides the result, while a genuinely broken path still fails every
attempt.
---
backend/internal/inttest/norepeat_test.go | 48 +++++++++++++++--------
1 file changed, 32 insertions(+), 16 deletions(-)
diff --git a/backend/internal/inttest/norepeat_test.go b/backend/internal/inttest/norepeat_test.go
index 52d6f6a..c6903a1 100644
--- a/backend/internal/inttest/norepeat_test.go
+++ b/backend/internal/inttest/norepeat_test.go
@@ -4,6 +4,7 @@ package inttest
import (
"context"
+ "errors"
"testing"
"github.com/google/uuid"
@@ -64,23 +65,38 @@ func TestNoRepeatWordsPinnedFromTheVariant(t *testing.T) {
func TestNoRepeatWordsHonoursThePinNotTheVariant(t *testing.T) {
ctx := context.Background()
svc := newGameService()
- g := createTwoSeatGame(t, svc, engine.VariantErudit)
- if _, err := testDB.ExecContext(ctx,
- `UPDATE backend.games SET no_repeat_words = false WHERE game_id = $1`, g.ID); err != nil {
- t.Fatalf("clear the pin: %v", err)
- }
+ // Each game draws from a bag seeded with crypto/rand, so an opening rack occasionally has no
+ // playable word at all. That is a legitimate engine answer which says nothing about the pin
+ // under test, and taking it for a failure made this test flake (~1 run in 300 on the committed
+ // dictionary, and more often on another one). Retry with fresh games instead: one unlucky draw
+ // no longer decides the result, while a genuinely broken path still fails every attempt.
+ const draws = 5
+ for attempt := 1; ; attempt++ {
+ g := createTwoSeatGame(t, svc, engine.VariantErudit)
+ if _, err := testDB.ExecContext(ctx,
+ `UPDATE backend.games SET no_repeat_words = false WHERE game_id = $1`, g.ID); err != nil {
+ t.Fatalf("clear the pin: %v", err)
+ }
- reloaded, err := svc.GameByID(ctx, g.ID)
- if err != nil {
- t.Fatalf("get game: %v", err)
- }
- if reloaded.NoRepeatWords {
- t.Fatal("an Erudit game whose row has the rule off must report it off")
- }
- // The rule reaches the engine from the row, so the reconstructed game plays unrestricted:
- // its generated moves are the solver's full list, none filtered away.
- if _, err := svc.Hint(ctx, reloaded.ID, reloaded.Seats[reloaded.ToMove].AccountID); err != nil {
- t.Fatalf("hint on an unrestricted Erudit game: %v", err)
+ reloaded, err := svc.GameByID(ctx, g.ID)
+ if err != nil {
+ t.Fatalf("get game: %v", err)
+ }
+ if reloaded.NoRepeatWords {
+ t.Fatal("an Erudit game whose row has the rule off must report it off")
+ }
+ // The rule reaches the engine from the row, so the reconstructed game plays unrestricted:
+ // its generated moves are the solver's full list, none filtered away.
+ _, err = svc.Hint(ctx, reloaded.ID, reloaded.Seats[reloaded.ToMove].AccountID)
+ if err == nil {
+ return
+ }
+ if !errors.Is(err, game.ErrNoHintAvailable) {
+ t.Fatalf("hint on an unrestricted Erudit game: %v", err)
+ }
+ if attempt == draws {
+ t.Fatalf("no opening rack in %d games had a playable move", draws)
+ }
}
}
--
2.52.0
From 4d4452596d00c0fc09b99f9bcc89766bf8affe39 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Tue, 28 Jul 2026 16:14:53 +0200
Subject: [PATCH 3/5] fix(payments): stop doubling the pack size in buyer mail
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Catalog titles already name the pack ("50 фишек"), so appending the chip count
produced "50 фишек — 50 фишек, 79.00 ₽". The letter now carries the title
exactly as the storefront shows it, then the price.
The tax receipt is unaffected: it states the quantity in the wording the tax
service wants, which is a separate surface with separate requirements.
---
backend/internal/inttest/mynalog_test.go | 5 +++++
backend/internal/mynalogsync/mailer.go | 19 +++++++++++--------
backend/internal/payments/mynalog.go | 2 --
backend/internal/payments/store_mynalog.go | 6 +++---
4 files changed, 19 insertions(+), 13 deletions(-)
diff --git a/backend/internal/inttest/mynalog_test.go b/backend/internal/inttest/mynalog_test.go
index ecb6484..adff85e 100644
--- a/backend/internal/inttest/mynalog_test.go
+++ b/backend/internal/inttest/mynalog_test.go
@@ -661,6 +661,11 @@ func TestMyNalogBuyerMails(t *testing.T) {
if !strings.Contains(first[0].Text, "не чек") {
t.Errorf("the purchase letter does not say it is not the receipt:\n%s", first[0].Text)
}
+ // The catalog title already names the pack size, so appending it again read as
+ // "50 фишек — 50 фишек". The line is the title verbatim, then the price, and nothing else.
+ if !strings.Contains(first[0].Text, "Покупка совершена: test pack, 149.00 ₽.") {
+ t.Errorf("the purchase line is not the catalog title plus the price:\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 {
diff --git a/backend/internal/mynalogsync/mailer.go b/backend/internal/mynalogsync/mailer.go
index 16e75ee..085793f 100644
--- a/backend/internal/mynalogsync/mailer.go
+++ b/backend/internal/mynalogsync/mailer.go
@@ -185,15 +185,18 @@ func (m *Mailer) stampReceipt(ctx context.Context, mark func(context.Context, uu
}
}
-// describe renders what was bought in one line.
-func describe(title string, chips int, amountMinor int64, currency string) string {
+// describe renders what was bought in one line: the catalog title exactly as the buyer saw it on
+// the storefront, then the price.
+//
+// The pack size is deliberately NOT appended. Catalog titles already name it ("50 фишек"), so
+// adding it produced "50 фишек — 50 фишек". The title is the single source of that wording; the
+// tax receipt states the quantity separately in the form the tax service wants (see
+// mynalog.IncomeName), which is a different surface with different requirements.
+func describe(title string, 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))
}
@@ -224,7 +227,7 @@ func purchaseText(p payments.PurchaseMail) string {
отдельным письмом: обычно в течение часа, но иногда это занимает до нескольких дней.
Спасибо, что играете в «Эрудит».
-`, describe(p.Title, p.Chips, p.AmountMinor, p.Currency), p.OrderID)
+`, describe(p.Title, p.AmountMinor, p.Currency), p.OrderID)
}
// receiptText is the fiscal receipt letter, which is how the receipt is actually handed to the
@@ -240,7 +243,7 @@ func receiptText(r payments.ReceiptMail, inn, baseURL string, loc *time.Location
Продавец применяет налог на профессиональный доход; чек сформирован в сервисе
«Мой налог» Федеральной налоговой службы.
-`, describe(r.Title, r.Chips, r.AmountMinor, r.Currency),
+`, describe(r.Title, r.AmountMinor, r.Currency),
r.OperationTime.In(loc).Format("02.01.2006 15:04"),
mynalog.ReceiptURL(baseURL, inn, r.ReceiptUUID))
}
@@ -255,6 +258,6 @@ func cancelText(r payments.ReceiptMail, loc *time.Location) string {
%s
Дата покупки: %s
-`, describe(r.Title, r.Chips, r.AmountMinor, r.Currency),
+`, describe(r.Title, r.AmountMinor, r.Currency),
r.OperationTime.In(loc).Format("02.01.2006 15:04"))
}
diff --git a/backend/internal/payments/mynalog.go b/backend/internal/payments/mynalog.go
index 29c6f46..42b78b0 100644
--- a/backend/internal/payments/mynalog.go
+++ b/backend/internal/payments/mynalog.go
@@ -125,7 +125,6 @@ type PurchaseMail struct {
OrderID uuid.UUID
Origin string
Title string
- Chips int
AmountMinor int64
Currency string
CreatedAt time.Time
@@ -137,7 +136,6 @@ type ReceiptMail struct {
AccountID uuid.UUID
ReceiptUUID string
Title string
- Chips int
AmountMinor int64
Currency string
OperationTime time.Time
diff --git a/backend/internal/payments/store_mynalog.go b/backend/internal/payments/store_mynalog.go
index a0b0b74..469d54d 100644
--- a/backend/internal/payments/store_mynalog.go
+++ b/backend/internal/payments/store_mynalog.go
@@ -476,7 +476,7 @@ func (s *Store) pendingPurchaseMails(ctx context.Context, limit int) ([]Purchase
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
+ COALESCE(p.title, ''), 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
@@ -495,7 +495,7 @@ func (s *Store) pendingPurchaseMails(ctx context.Context, limit int) ([]Purchase
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 {
+ &m.Currency, &m.Title, &m.CreatedAt); err != nil {
return nil, fmt.Errorf("payments: scan purchase mail: %w", err)
}
m.OrderID = orderID.UUID
@@ -551,7 +551,7 @@ func (s *Store) pendingMyNalogMails(ctx context.Context, limit int, cancelled bo
&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)
+ m.Title, _ = describeSnapshot(snapshot.String)
out = append(out, m)
}
if err := rows.Err(); err != nil {
--
2.52.0
From 69bddaeb9ab32e7f9263f714147716987444dcf4 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Tue, 28 Jul 2026 16:44:44 +0200
Subject: [PATCH 4/5] fix(admin): shorten the receipt letter, show the account
id
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The receipt letter repeated what the receipt itself states. It now carries the
amount, the moment of payment with its offset, and the link — the receipt is
the document, and it opens without authentication, so there is nothing for the
two to disagree about. Subject reworded to name what it is.
The ledger and tax-export tables labelled the account link "card", meaning the
user card. On pages about payments that reads as a bank card, which is exactly
how it was misread. Both now show the first eight characters of the account id,
so the column carries data — two rows from the same buyer are visible at a
glance — while the link still holds the whole id.
---
.claude/CLAUDE.md | 4 ++
backend/internal/adminconsole/render_test.go | 6 +-
.../templates/pages/ledger.gohtml | 2 +-
.../templates/pages/mynalog.gohtml | 4 +-
backend/internal/adminconsole/views.go | 57 ++++++++++---------
backend/internal/inttest/mynalog_test.go | 18 +++++-
backend/internal/mynalogsync/mailer.go | 20 +++----
.../internal/server/handlers_admin_console.go | 11 ++++
.../internal/server/handlers_admin_ledger.go | 23 ++++----
.../internal/server/handlers_admin_mynalog.go | 28 ++++-----
10 files changed, 105 insertions(+), 68 deletions(-)
diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index bfeeaaf..89e2961 100644
--- a/.claude/CLAUDE.md
+++ b/.claude/CLAUDE.md
@@ -292,6 +292,10 @@ The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded unde
millisecond clock — the first eight are shared by every order in the same ~65 seconds, which
would make two purchases indistinguishable to the probe. An unresolved outcome halts the
whole queue on purpose; a blind retry is the one thing that must never happen.
+ - **The printable receipt link IS public** — `GET /api/v1/receipt/<ИНН>//print` opens with
+ no authentication (verified on prod 2026-07-28; online sources contradict each other on this,
+ so do not "fix" it back). It is the same URL the «Мой налог» app hands out under «копировать в
+ буфер», which is why the buyer's receipt letter can simply carry it.
- **The receipt's time zone decides the tax month.** The ledger is UTC; `BACKEND_MYNALOG_TZ`
(default `Europe/Moscow`) is what a payment is filed under, so a 23:30 UTC payment on the last
of the month belongs to the *next* one.
diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go
index 0bd8117..6808d28 100644
--- a/backend/internal/adminconsole/render_test.go
+++ b/backend/internal/adminconsole/render_test.go
@@ -54,10 +54,10 @@ func TestRendererRendersEveryPage(t *testing.T) {
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",
+ Owed: []MyNalogRow{{LedgerID: "l1", AccountID: "a1", AccountShort: "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",
+ Cancels: []MyNalogCancelRow{{LedgerID: "l2", AccountID: "a2", AccountShort: "a2", ReceiptUUID: "20abcdef01", Failed: true}},
+ Attention: []MyNalogRow{{LedgerID: "l3", AccountID: "a3", AccountShort: "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,
diff --git a/backend/internal/adminconsole/templates/pages/ledger.gohtml b/backend/internal/adminconsole/templates/pages/ledger.gohtml
index 5dcb79b..637da96 100644
--- a/backend/internal/adminconsole/templates/pages/ledger.gohtml
+++ b/backend/internal/adminconsole/templates/pages/ledger.gohtml
@@ -40,7 +40,7 @@ Money in: {{range .Totals.MoneyIn}}{{.}} {{else}}card
+{{.AccountShort}} |
{{.Kind}} |
{{.Money}} |
{{.ChipsDelta}} |
diff --git a/backend/internal/adminconsole/templates/pages/mynalog.gohtml b/backend/internal/adminconsole/templates/pages/mynalog.gohtml
index 5e5c243..2cf2bcc 100644
--- a/backend/internal/adminconsole/templates/pages/mynalog.gohtml
+++ b/backend/internal/adminconsole/templates/pages/mynalog.gohtml
@@ -79,7 +79,7 @@ created; entering a receipt number means you filed it by hand.
{{range .Cancels}}
-| card |
+{{.AccountShort}} |
{{.ReceiptUUID}} |
{{if .Failed}}a previous attempt was refused{{else}}awaiting{{end}} |
@@ -96,7 +96,7 @@ created; entering a receipt number means you filed it by hand.
{{range .Owed}}
| {{.At}} |
-card |
+{{.AccountShort}} |
{{.Amount}} |
{{.Name}} |
{{.Attempts}} |
diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go
index f6379db..3d1613e 100644
--- a/backend/internal/adminconsole/views.go
+++ b/backend/internal/adminconsole/views.go
@@ -785,18 +785,21 @@ type LedgerView struct {
// spend, an admin grant, a rewarded-video credit). Refundable marks a funded order the operator may
// still reverse, which is what puts the Refund button on that row.
type LedgerRow struct {
- At string
- AccountID string
- Kind string
- Source string
- Origin string
- ChipsDelta int
- Money string
- Title string
- Order string
- Provider string
- Shop string
- Refundable bool
+ At string
+ // AccountID is the link target; AccountShort is what the cell shows, because a ledger row has
+ // no room for a full uuid and the operator mostly needs to spot two rows from the same buyer.
+ AccountID string
+ AccountShort string
+ Kind string
+ Source string
+ Origin string
+ ChipsDelta int
+ Money string
+ Title string
+ Order string
+ Provider string
+ Shop string
+ Refundable bool
}
// LedgerTotalsRow is the summary above the table, over everything the filter matches. Money is
@@ -841,23 +844,25 @@ type MyNalogBacklogRow struct {
// 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
+ LedgerID string
+ AccountID string
+ AccountShort 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
+ LedgerID string
+ AccountID string
+ AccountShort string
+ ReceiptUUID string
+ Failed bool
}
diff --git a/backend/internal/inttest/mynalog_test.go b/backend/internal/inttest/mynalog_test.go
index adff85e..8828bb8 100644
--- a/backend/internal/inttest/mynalog_test.go
+++ b/backend/internal/inttest/mynalog_test.go
@@ -675,8 +675,22 @@ func TestMyNalogBuyerMails(t *testing.T) {
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)
+ receipt := box.to(addr)[1]
+ if receipt.Subject != "Эрудит — Ваш чек покупки" {
+ t.Errorf("receipt letter subject = %q", receipt.Subject)
+ }
+ if !strings.Contains(receipt.Text, "/api/v1/receipt/770000000000/") {
+ t.Errorf("the receipt letter carries no receipt link:\n%s", receipt.Text)
+ }
+ // The amount carries its time-zone offset, because that offset is what decides the tax period
+ // the receipt belongs to and the buyer should see the same moment the receipt states.
+ if !strings.Contains(receipt.Text, "Покупка на сумму: 149.00 ₽ от ") ||
+ !strings.Contains(receipt.Text, "+03:00") {
+ t.Errorf("the receipt letter's amount line is wrong:\n%s", receipt.Text)
+ }
+ // Nothing beyond the amount, the moment and the link: the receipt is the document.
+ if strings.Contains(receipt.Text, "test pack") {
+ t.Errorf("the receipt letter repeats the catalog title; the receipt itself carries it:\n%s", receipt.Text)
}
// 3. The annulment letter after a refund.
diff --git a/backend/internal/mynalogsync/mailer.go b/backend/internal/mynalogsync/mailer.go
index 085793f..688f5d0 100644
--- a/backend/internal/mynalogsync/mailer.go
+++ b/backend/internal/mynalogsync/mailer.go
@@ -164,7 +164,7 @@ func (m *Mailer) sendReceiptMails(ctx context.Context, cancelled bool) {
m.stampReceipt(ctx, mark, r.LedgerID)
continue
}
- subject, body := "Эрудит — чек от налоговой", receiptText(r, stored.INN, m.baseURL, m.loc)
+ subject, body := "Эрудит — Ваш чек покупки", receiptText(r, stored.INN, m.baseURL, m.loc)
if cancelled {
subject, body = "Эрудит — чек аннулирован", cancelText(r, m.loc)
}
@@ -232,19 +232,19 @@ func purchaseText(p payments.PurchaseMail) string {
// receiptText is the fiscal receipt letter, which is how the receipt is actually handed to the
// buyer as the professional-income tax regime requires.
+//
+// It is deliberately bare: the amount, the moment of payment with its offset, and the link. The
+// receipt itself is the document and it opens without authentication, so repeating its contents
+// here would only add something for the two to disagree about.
func receiptText(r payments.ReceiptMail, inn, baseURL string, loc *time.Location) string {
return fmt.Sprintf(`Здравствуйте!
-По вашей покупке сформирован чек в налоговой службе.
+Покупка на сумму: %s от %s
+Ссылка на чек: %s
-%s
-Дата: %s
-Чек: %s
-
-Продавец применяет налог на профессиональный доход; чек сформирован в сервисе
-«Мой налог» Федеральной налоговой службы.
-`, describe(r.Title, r.AmountMinor, r.Currency),
- r.OperationTime.In(loc).Format("02.01.2006 15:04"),
+Спасибо, что играете в «Эрудит».
+`, formatAmount(r.AmountMinor, r.Currency),
+ r.OperationTime.In(loc).Format("02.01.2006 15:04 -07:00"),
mynalog.ReceiptURL(baseURL, inn, r.ReceiptUUID))
}
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go
index e308521..ab25b9a 100644
--- a/backend/internal/server/handlers_admin_console.go
+++ b/backend/internal/server/handlers_admin_console.go
@@ -1375,6 +1375,17 @@ func normalizeComplaintStatus(s string) string {
return ""
}
+// shortID abbreviates an account id for a table cell: enough to recognise the same buyer across
+// rows at a glance, while the link still carries the whole id. Money tables have no room for a full
+// uuid, and a generic label there ("card") read as a payment card on the very pages about payments.
+func shortID(id string) string {
+ const n = 8
+ if len(id) <= n {
+ return id
+ }
+ return id[:n]
+}
+
// fmtTime formats a timestamp for display, or "" when zero.
func fmtTime(t time.Time) string {
if t.IsZero() {
diff --git a/backend/internal/server/handlers_admin_ledger.go b/backend/internal/server/handlers_admin_ledger.go
index de43173..b37d509 100644
--- a/backend/internal/server/handlers_admin_ledger.go
+++ b/backend/internal/server/handlers_admin_ledger.go
@@ -130,17 +130,18 @@ func (s *Server) consoleLedger(c *gin.Context) {
// the template — only a funded order can be reversed.
func ledgerRow(r payments.LedgerReportRow) adminconsole.LedgerRow {
row := adminconsole.LedgerRow{
- At: fmtTime(r.CreatedAt),
- AccountID: r.AccountID.String(),
- Kind: r.Kind,
- Source: r.Source,
- Origin: r.Origin,
- ChipsDelta: r.ChipsDelta,
- Order: r.OrderID,
- Provider: r.Provider,
- Shop: r.Shop,
- Refundable: r.Kind == "fund" && r.OrderID != "",
- Title: snapshotTitle(r.Snapshot),
+ At: fmtTime(r.CreatedAt),
+ AccountID: r.AccountID.String(),
+ AccountShort: shortID(r.AccountID.String()),
+ Kind: r.Kind,
+ Source: r.Source,
+ Origin: r.Origin,
+ ChipsDelta: r.ChipsDelta,
+ Order: r.OrderID,
+ Provider: r.Provider,
+ Shop: r.Shop,
+ Refundable: r.Kind == "fund" && r.OrderID != "",
+ Title: snapshotTitle(r.Snapshot),
}
if r.HasMoney() {
row.Money = fmtMoney(r.Money)
diff --git a/backend/internal/server/handlers_admin_mynalog.go b/backend/internal/server/handlers_admin_mynalog.go
index 79abeb2..bf9cb06 100644
--- a/backend/internal/server/handlers_admin_mynalog.go
+++ b/backend/internal/server/handlers_admin_mynalog.go
@@ -59,10 +59,11 @@ func (s *Server) consoleMyNalog(c *gin.Context) {
}
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,
+ LedgerID: cn.LedgerID.String(),
+ AccountID: cn.AccountID.String(),
+ AccountShort: shortID(cn.AccountID.String()),
+ ReceiptUUID: cn.ReceiptUUID,
+ Failed: cn.CancelStatus == payments.MyNalogCancelFailed,
})
}
@@ -97,15 +98,16 @@ func fmtMinor(minor int64, cur payments.Currency) string {
// 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,
+ LedgerID: in.LedgerID.String(),
+ AccountID: in.AccountID.String(),
+ AccountShort: shortID(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)
--
2.52.0
From ca9cc90db959d48816fd6dd0d72d54790a06a96d Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Tue, 28 Jul 2026 16:56:12 +0200
Subject: [PATCH 5/5] fix(backend): embed the IANA time-zone database
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The taxpayer's zone decides which tax month an income is filed under, and the
config refuses to boot on a zone it cannot resolve rather than misfiling into
UTC. That correctness was resting on the base image happening to ship
/usr/share/zoneinfo — true of distroless/static-debian12 today, but not a thing
to leave implicit on a path where being wrong means a wrong tax return.
Costs about 450 KB of binary. Adds a config test pinning the default zone and
the hard failure on an unknown one.
---
backend/cmd/backend/main.go | 7 ++++
backend/internal/config/config_test.go | 47 ++++++++++++++++++++++++++
2 files changed, 54 insertions(+)
diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go
index 2d87fb6..8afd02e 100644
--- a/backend/cmd/backend/main.go
+++ b/backend/cmd/backend/main.go
@@ -15,6 +15,13 @@ import (
"syscall"
"time"
+ // The IANA time-zone database, embedded in the binary. The image ships one today, but the
+ // dependency is not one to leave implicit: the taxpayer's zone decides which tax month an
+ // income is filed under, and the config refuses to boot on a zone it cannot resolve rather
+ // than silently falling back to UTC and misfiling. Embedding it makes that correctness
+ // independent of whatever the base image happens to contain.
+ _ "time/tzdata"
+
"github.com/google/uuid"
"go.uber.org/zap"
diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go
index 90ed415..a8a430e 100644
--- a/backend/internal/config/config_test.go
+++ b/backend/internal/config/config_test.go
@@ -202,3 +202,50 @@ func TestLoadGuestReaperDefaultsAndOverride(t *testing.T) {
t.Fatal("Load: expected an error for a non-positive reap interval, got nil")
}
}
+
+// TestLoadMyNalogTimeZone pins the taxpayer's time zone: it defaults to Europe/Moscow and an
+// unresolvable zone is a hard boot failure, never a quiet fall back to UTC.
+//
+// The offset a receipt carries decides which tax month an income is filed under, so a silent UTC
+// default would misfile every near-midnight payment at a month boundary. That is also why the
+// backend embeds the IANA database (a blank time/tzdata import in cmd/backend) instead of trusting
+// the base image to ship one.
+func TestLoadMyNalogTimeZone(t *testing.T) {
+ base := func(t *testing.T) {
+ t.Helper()
+ t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
+ t.Setenv("BACKEND_DICT_DIR", "/dict")
+ }
+
+ t.Run("defaults to the taxpayer's zone", func(t *testing.T) {
+ base(t)
+ t.Setenv("BACKEND_MYNALOG_TZ", "")
+ c, err := Load()
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ if got := c.MyNalog.Location.String(); got != "Europe/Moscow" {
+ t.Fatalf("time zone = %q, want Europe/Moscow", got)
+ }
+ })
+
+ t.Run("honours an explicit zone", func(t *testing.T) {
+ base(t)
+ t.Setenv("BACKEND_MYNALOG_TZ", "Asia/Yekaterinburg")
+ c, err := Load()
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ if got := c.MyNalog.Location.String(); got != "Asia/Yekaterinburg" {
+ t.Fatalf("time zone = %q", got)
+ }
+ })
+
+ t.Run("refuses an unresolvable zone", func(t *testing.T) {
+ base(t)
+ t.Setenv("BACKEND_MYNALOG_TZ", "Mars/Olympus_Mons")
+ if _, err := Load(); err == nil {
+ t.Fatal("an unknown time zone must fail the boot, not fall back to UTC")
+ }
+ })
+}
--
2.52.0