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

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

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

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

No payment path changed: the purchase letter rides the existing payment-event
outbox on its own cursor, the receipt and annulment letters ride the export
row. Decisions D53-D60.
This commit is contained in:
Ilia Denisov
2026-07-28 15:40:36 +02:00
parent c13f5cdb1e
commit e3c2e80a0a
35 changed files with 4926 additions and 13 deletions
+34
View File
@@ -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)