Files
scrabble-game/backend/internal/payments/mynalog.go
T
Ilia Denisov e3c2e80a0a
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
feat(payments): report income to «Мой налог»
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.
2026-07-28 15:40:36 +02:00

327 lines
14 KiB
Go

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())
}