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
+326
View File
@@ -0,0 +1,326 @@
package payments
import (
"context"
"encoding/json"
"time"
"github.com/google/uuid"
)
// Export statuses of one income on its way into the professional-income tax register. They are
// deliberately not a subset of the order or ledger statuses: registering an income with the tax
// service is a separate obligation with its own failure modes, and the ledger is append-only, so
// this progress cannot live there.
const (
// MyNalogSending is written before the registration call. It exists so that a process that dies
// mid-call leaves evidence: on the next run the row is promoted to MyNalogUnknown rather than
// being retried blindly, because the call is not idempotent.
MyNalogSending = "sending"
// MyNalogSent means the tax service issued a receipt and we hold its identifier.
MyNalogSent = "sent"
// MyNalogUnknown means the outcome could not be established: the call failed and the follow-up
// probe did not find our receipt. It halts the queue until a human resolves it, because the only
// alternatives are filing the income twice or losing it.
MyNalogUnknown = "unknown"
// MyNalogFailed is a rejection we understand and may retry later.
MyNalogFailed = "failed"
// MyNalogNotRequired marks an income the buyer got back before it was ever registered. Nothing
// is filed and nothing is annulled — the two cancel out.
MyNalogNotRequired = "not_required"
)
// Annulment statuses of a registered receipt whose money was returned.
const (
MyNalogCancelNone = "none"
MyNalogCancelled = "cancelled"
MyNalogCancelFailed = "failed"
)
// myNalogProvider is the only rail this export covers: the direct rouble rail settled through
// YooKassa. Store rails are excluded on purpose — the ledger holds no rouble amount for them.
const myNalogProvider = "yookassa"
// MyNalogIncome is one rouble income the tax register may still be owed. Chips and Title come from
// the ledger snapshot, which is what the receipt description is built from. Status is empty when
// the income has never been acted on.
type MyNalogIncome struct {
LedgerID uuid.UUID
OrderID uuid.UUID
AccountID uuid.UUID
Chips int
Title string
Amount Money
OperationTime time.Time
Status string
ReceiptName string
ReceiptUUID string
Attempts int
LastError string
}
// MyNalogCancel is one registered receipt whose income has since been refunded and which must
// therefore be annulled. LedgerID names the income row, RefundLedgerID the refund that voided it.
type MyNalogCancel struct {
LedgerID uuid.UUID
RefundLedgerID uuid.UUID
OrderID uuid.UUID
AccountID uuid.UUID
ReceiptUUID string
// CancelStatus is MyNalogCancelFailed when a previous attempt was refused, so the console can
// show that this one is a retry rather than a first try.
CancelStatus string
LastError string
}
// MyNalogSession is the stored tax-cabinet session and the rail's operating mode. RefreshTokenEnc
// is opaque here: the payments domain stores the sealed bytes without holding the key, so the
// credential never becomes readable through a query on this schema.
type MyNalogSession struct {
INN string
DeviceID string
RefreshTokenEnc []byte
AutoEnabled bool
PausedAt *time.Time
PauseReason string
LastOKAt *time.Time
LastAlertAt *time.Time
LastAlertKind string
UpdatedAt time.Time
}
// Paused reports whether the rail has taken itself out of service.
func (s MyNalogSession) Paused() bool { return s.PausedAt != nil }
// MyNalogBacklog summarises income still owed to the tax register up to a cut-off. It is what the
// fiscal-period watchdog escalates on, so it carries enough to write a useful alert without a
// second query.
type MyNalogBacklog struct {
Count int
AmountMinor int64
Oldest time.Time
}
// MyNalogQueue is everything one export run has to consider, read in a single pass so the console
// and the worker see the same picture.
type MyNalogQueue struct {
Incomes []MyNalogIncome
Cancels []MyNalogCancel
NotRequired []MyNalogIncome
Attention []MyNalogIncome
}
// Empty reports whether there is nothing to do. The export engine checks this before authenticating,
// so an idle installation sends no traffic to the tax service at all.
func (q MyNalogQueue) Empty() bool {
return len(q.Incomes) == 0 && len(q.Cancels) == 0 && len(q.NotRequired) == 0
}
// PurchaseMail is one credited purchase whose buyer has not yet been told. It rides the existing
// payment-event outbox on its own cursor, so no payment path had to be modified to send it.
type PurchaseMail struct {
EventID uuid.UUID
AccountID uuid.UUID
OrderID uuid.UUID
Origin string
Title string
Chips int
AmountMinor int64
Currency string
CreatedAt time.Time
}
// ReceiptMail is one registered (or annulled) receipt whose buyer has not yet been told.
type ReceiptMail struct {
LedgerID uuid.UUID
AccountID uuid.UUID
ReceiptUUID string
Title string
Chips int
AmountMinor int64
Currency string
OperationTime time.Time
}
// myNalogSnapshot is the part of a fund ledger row's snapshot the receipt description needs.
type myNalogSnapshot struct {
Title string `json:"title"`
Chips int `json:"chips"`
}
// describeSnapshot recovers the product title and pack size a fund row recorded. A snapshot that
// cannot be read yields zeroes, and the caller falls back to a generic description rather than
// refusing to file the income.
func describeSnapshot(snapshot string) (title string, chips int) {
if snapshot == "" {
return "", 0
}
var snap myNalogSnapshot
if err := json.Unmarshal([]byte(snapshot), &snap); err != nil {
return "", 0
}
return snap.Title, snap.Chips
}
// MyNalogQueue reads everything an export run must consider, capped at limit incomes and limit
// cancels. Nothing here calls out to the tax service, so it is safe to run on every tick.
func (s *Service) MyNalogQueue(ctx context.Context, limit int) (MyNalogQueue, error) {
return s.store.myNalogQueue(ctx, limit)
}
// MyNalogIncomeAt reads one taxable income by its ledger row, reporting false when that row is not
// one this export is responsible for. It backs the per-row console actions, which must not depend on
// the row still being inside the paged queue.
func (s *Service) MyNalogIncomeAt(ctx context.Context, ledgerID uuid.UUID) (MyNalogIncome, bool, error) {
return s.store.myNalogIncomeByLedger(ctx, ledgerID)
}
// MyNalogBacklogBefore summarises income received before the cut-off that is still not registered.
// The fiscal-period watchdog uses it to escalate as the filing deadline approaches.
func (s *Service) MyNalogBacklogBefore(ctx context.Context, before time.Time) (MyNalogBacklog, error) {
return s.store.myNalogBacklogBefore(ctx, before)
}
// PromoteStrandedSending turns rows left in MyNalogSending by a process that died mid-call into
// MyNalogUnknown, and reports how many. The caller holds the export lock, so any such row is
// genuinely orphaned rather than in flight — and since the registration call is not idempotent, a
// blind retry is exactly what must not happen.
func (s *Service) PromoteStrandedSending(ctx context.Context) (int, error) {
return s.store.promoteStrandedSending(ctx, s.clock())
}
// BeginMyNalogSend records the intent to register income, freezing the receipt name before the call
// leaves. The name is the only handle the recovery probe has, so it must be durable before the
// request, not after it.
func (s *Service) BeginMyNalogSend(ctx context.Context, in MyNalogIncome, receiptName string) error {
return s.store.beginMyNalogSend(ctx, in, receiptName, s.clock())
}
// MarkMyNalogSent records a receipt the tax service issued. manual is true when an operator filed
// the income by hand and typed the identifier back in — such a row is as good as an automatic one,
// and in particular its receipt can still be annulled automatically on a refund.
func (s *Service) MarkMyNalogSent(ctx context.Context, ledgerID uuid.UUID, receiptUUID string, manual bool) error {
return s.store.markMyNalogSent(ctx, ledgerID, receiptUUID, manual, s.clock())
}
// MarkMyNalogManual records an income an operator filed by hand, creating the row when the export
// never got as far as attempting it.
func (s *Service) MarkMyNalogManual(ctx context.Context, in MyNalogIncome, receiptName, receiptUUID string) error {
return s.store.markMyNalogManual(ctx, in, receiptName, receiptUUID, s.clock())
}
// MarkMyNalogOutcome records a non-final outcome: MyNalogUnknown when the result could not be
// established, MyNalogFailed when it was understood and may be retried.
func (s *Service) MarkMyNalogOutcome(ctx context.Context, ledgerID uuid.UUID, status, reason string) error {
return s.store.markMyNalogOutcome(ctx, ledgerID, status, reason, s.clock())
}
// MarkMyNalogNotRequired records that an income needs no receipt because it was refunded before one
// was ever issued.
func (s *Service) MarkMyNalogNotRequired(ctx context.Context, in MyNalogIncome, reason string) error {
return s.store.markMyNalogNotRequired(ctx, in, reason, s.clock())
}
// MarkMyNalogCancelled records that a receipt was annulled for the given refund.
func (s *Service) MarkMyNalogCancelled(ctx context.Context, ledgerID, refundLedgerID uuid.UUID) error {
return s.store.markMyNalogCancel(ctx, ledgerID, refundLedgerID, MyNalogCancelled, "", s.clock())
}
// MarkMyNalogCancelFailed records that an annulment was refused, leaving the receipt standing.
func (s *Service) MarkMyNalogCancelFailed(ctx context.Context, ledgerID, refundLedgerID uuid.UUID, reason string) error {
return s.store.markMyNalogCancel(ctx, ledgerID, refundLedgerID, MyNalogCancelFailed, reason, s.clock())
}
// SaveMyNalogSession stores the tax-cabinet session, preserving the operating mode across a
// re-login and clearing any self-imposed pause — signing in again is the operator saying the
// problem is dealt with.
func (s *Service) SaveMyNalogSession(ctx context.Context, inn, deviceID string, refreshTokenEnc []byte) error {
return s.store.saveMyNalogSession(ctx, inn, deviceID, refreshTokenEnc, s.clock())
}
// UpdateMyNalogRefreshToken replaces the stored refresh token after the service rotated it. Losing
// a rotated token would strand the next renewal and force a password login.
func (s *Service) UpdateMyNalogRefreshToken(ctx context.Context, refreshTokenEnc []byte) error {
return s.store.updateMyNalogRefreshToken(ctx, refreshTokenEnc, s.clock())
}
// MyNalogSession reads the stored session, reporting false when the rail has never been signed in.
func (s *Service) MyNalogSession(ctx context.Context) (MyNalogSession, bool, error) {
return s.store.myNalogSession(ctx)
}
// DropMyNalogSession signs the rail out. The operating mode goes with it: without a session there
// is nothing for the automatic mode to run on.
func (s *Service) DropMyNalogSession(ctx context.Context) error {
return s.store.dropMyNalogSession(ctx)
}
// SetMyNalogAuto arms or disarms the automatic export.
func (s *Service) SetMyNalogAuto(ctx context.Context, enabled bool) error {
return s.store.setMyNalogAuto(ctx, enabled, s.clock())
}
// PauseMyNalog takes the rail out of service with a reason an operator can read. It is how a run
// reacts to a fault that repeating cannot fix, so that a changed API cannot turn into thousands of
// futile requests overnight.
func (s *Service) PauseMyNalog(ctx context.Context, reason string) error {
return s.store.pauseMyNalog(ctx, reason, s.clock())
}
// ResumeMyNalog clears a self-imposed pause after an operator has dealt with the cause.
func (s *Service) ResumeMyNalog(ctx context.Context) error {
return s.store.resumeMyNalog(ctx, s.clock())
}
// NoteMyNalogOK records a successful exchange with the tax service, which is what the "has it been
// down for a day?" test measures against.
func (s *Service) NoteMyNalogOK(ctx context.Context) error {
return s.store.noteMyNalogOK(ctx, s.clock())
}
// NoteMyNalogAlert records that an alert of the given kind was sent, so a persistent fault produces
// one letter a day rather than one per run.
func (s *Service) NoteMyNalogAlert(ctx context.Context, kind string) error {
return s.store.noteMyNalogAlert(ctx, kind, s.clock())
}
// TryLockMyNalog takes the advisory lock guarding an export run, reporting false when another run
// already holds it. Releasing it is the caller's job; the returned release function is safe to call
// exactly once.
func (s *Service) TryLockMyNalog(ctx context.Context) (release func(), ok bool, err error) {
return s.store.tryLockMyNalog(ctx)
}
// PendingPurchaseMails reads credited purchases whose buyer has not been written to yet.
func (s *Service) PendingPurchaseMails(ctx context.Context, limit int) ([]PurchaseMail, error) {
return s.store.pendingPurchaseMails(ctx, limit)
}
// MarkPurchaseMailed records that the mail decision for an event has been made. It is stamped even
// when no letter was sent — a store-rail purchase, or a buyer with no confirmed address — because
// the column tracks the decision, not the delivery, and an undecided event would be reconsidered
// forever.
func (s *Service) MarkPurchaseMailed(ctx context.Context, eventID uuid.UUID) error {
return s.store.markPurchaseMailed(ctx, eventID, s.clock())
}
// PendingReceiptMails reads registered receipts whose buyer has not been sent the receipt yet.
func (s *Service) PendingReceiptMails(ctx context.Context, limit int) ([]ReceiptMail, error) {
return s.store.pendingMyNalogMails(ctx, limit, false)
}
// PendingCancelMails reads annulled receipts whose buyer has not been told the receipt is void.
func (s *Service) PendingCancelMails(ctx context.Context, limit int) ([]ReceiptMail, error) {
return s.store.pendingMyNalogMails(ctx, limit, true)
}
// MarkReceiptMailed records that the receipt letter decision for an income has been made.
func (s *Service) MarkReceiptMailed(ctx context.Context, ledgerID uuid.UUID) error {
return s.store.markMyNalogMailed(ctx, ledgerID, false, s.clock())
}
// MarkCancelMailed records that the annulment letter decision for an income has been made.
func (s *Service) MarkCancelMailed(ctx context.Context, ledgerID uuid.UUID) error {
return s.store.markMyNalogMailed(ctx, ledgerID, true, s.clock())
}
+575
View File
@@ -0,0 +1,575 @@
package payments
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
)
// myNalogLockKey is the advisory-lock key guarding an export run. One run at a time is a hard
// requirement, not a nicety: the tax service accepts no idempotency key, so two overlapping runs
// could file the same income twice.
const myNalogLockKey int64 = 0x6D796E616C6F67 // "mynalog"
// taxableIncome is the predicate selecting ledger rows this export is responsible for: rouble
// funding on the direct rail. Store rails are excluded because the ledger records their price in
// the store's own currency and holds no rouble amount at all.
const taxableIncome = `l.kind = 'fund' AND l.provider = $1 AND l.order_id IS NOT NULL
AND l.snapshot->>'currency' = $2`
// refundedOrder matches an income whose order has since been refunded.
const refundedOrder = `EXISTS (SELECT 1 FROM payments.ledger x
WHERE x.order_id = l.order_id AND x.kind = 'refund')`
// incomeColumns is the projection every queue row is read through.
const incomeColumns = `l.ledger_id, l.order_id, l.account_id, l.snapshot, l.created_at,
COALESCE(r.status, ''), COALESCE(r.receipt_name, ''), COALESCE(r.receipt_uuid, ''),
COALESCE(r.attempts, 0), COALESCE(r.last_error, '')`
// myNalogQueue reads everything one export run must consider. It is four cheap reads against the
// ledger and the export table; nothing here touches the tax service, which is what lets the engine
// decide it has no work before authenticating.
func (s *Store) myNalogQueue(ctx context.Context, limit int) (MyNalogQueue, error) {
var q MyNalogQueue
var err error
// Owed: never attempted, or attempted and understood to have failed — and not since refunded.
if q.Incomes, err = s.myNalogIncomes(ctx, `(r.status IS NULL OR r.status = '`+MyNalogFailed+`')
AND NOT `+refundedOrder, limit); err != nil {
return MyNalogQueue{}, err
}
// Moot: refunded before a receipt was ever issued, so nothing is filed and nothing is annulled.
if q.NotRequired, err = s.myNalogIncomes(ctx, `(r.status IS NULL OR r.status = '`+MyNalogFailed+`')
AND `+refundedOrder, limit); err != nil {
return MyNalogQueue{}, err
}
// Stuck: an outcome nobody can infer. These halt the run and wait for a human.
if q.Attention, err = s.myNalogIncomes(ctx,
`r.status IN ('`+MyNalogUnknown+`', '`+MyNalogSending+`')`, limit); err != nil {
return MyNalogQueue{}, err
}
if q.Cancels, err = s.myNalogCancels(ctx, limit); err != nil {
return MyNalogQueue{}, err
}
return q, nil
}
// myNalogIncomes reads taxable incomes narrowed by an extra predicate over the export row, oldest
// first — the oldest income is the one closest to its filing deadline.
func (s *Store) myNalogIncomes(ctx context.Context, extra string, limit int) ([]MyNalogIncome, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT `+incomeColumns+`
FROM payments.ledger l
LEFT JOIN payments.mynalog_receipt r ON r.ledger_id = l.ledger_id
WHERE `+taxableIncome+` AND (`+extra+`)
ORDER BY l.created_at
LIMIT $3`, myNalogProvider, string(CurrencyRUB), limit)
if err != nil {
return nil, fmt.Errorf("payments: read mynalog queue: %w", err)
}
defer rows.Close()
var out []MyNalogIncome
for rows.Next() {
var (
in MyNalogIncome
orderID uuid.NullUUID
snapshot sql.NullString
)
if err := rows.Scan(&in.LedgerID, &orderID, &in.AccountID, &snapshot, &in.OperationTime,
&in.Status, &in.ReceiptName, &in.ReceiptUUID, &in.Attempts, &in.LastError); err != nil {
return nil, fmt.Errorf("payments: scan mynalog queue row: %w", err)
}
in.OrderID = orderID.UUID
in.Amount = moneyFromSnapshot(snapshot.String)
in.Title, in.Chips = describeSnapshot(snapshot.String)
out = append(out, in)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("payments: read mynalog queue: %w", err)
}
return out, nil
}
// myNalogIncomeByLedger reads one taxable income by its ledger row, reporting false when that row
// is not one this export is responsible for.
func (s *Store) myNalogIncomeByLedger(ctx context.Context, ledgerID uuid.UUID) (MyNalogIncome, bool, error) {
var (
in MyNalogIncome
orderID uuid.NullUUID
snapshot sql.NullString
)
err := s.db.QueryRowContext(ctx, `
SELECT `+incomeColumns+`
FROM payments.ledger l
LEFT JOIN payments.mynalog_receipt r ON r.ledger_id = l.ledger_id
WHERE `+taxableIncome+` AND l.ledger_id = $3`,
myNalogProvider, string(CurrencyRUB), ledgerID).
Scan(&in.LedgerID, &orderID, &in.AccountID, &snapshot, &in.OperationTime,
&in.Status, &in.ReceiptName, &in.ReceiptUUID, &in.Attempts, &in.LastError)
if err == sql.ErrNoRows {
return MyNalogIncome{}, false, nil
}
if err != nil {
return MyNalogIncome{}, false, fmt.Errorf("payments: read mynalog income: %w", err)
}
in.OrderID = orderID.UUID
in.Amount = moneyFromSnapshot(snapshot.String)
in.Title, in.Chips = describeSnapshot(snapshot.String)
return in, true, nil
}
// myNalogCancels reads receipts whose income has been refunded and which are therefore owed an
// annulment, oldest refund first. A previously failed annulment is included: the receipt still
// stands, so it is still owed.
func (s *Store) myNalogCancels(ctx context.Context, limit int) ([]MyNalogCancel, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT r.ledger_id, x.ledger_id, r.order_id, r.account_id, r.receipt_uuid,
r.cancel_status, r.last_error
FROM payments.mynalog_receipt r
JOIN payments.ledger x ON x.order_id = r.order_id AND x.kind = 'refund'
WHERE r.status = $1 AND r.cancel_status IN ($2, $3)
ORDER BY x.created_at
LIMIT $4`, MyNalogSent, MyNalogCancelNone, MyNalogCancelFailed, limit)
if err != nil {
return nil, fmt.Errorf("payments: read mynalog cancels: %w", err)
}
defer rows.Close()
var out []MyNalogCancel
for rows.Next() {
var c MyNalogCancel
if err := rows.Scan(&c.LedgerID, &c.RefundLedgerID, &c.OrderID, &c.AccountID, &c.ReceiptUUID,
&c.CancelStatus, &c.LastError); err != nil {
return nil, fmt.Errorf("payments: scan mynalog cancel: %w", err)
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("payments: read mynalog cancels: %w", err)
}
return out, nil
}
// myNalogBacklogBefore counts and sums taxable income received before the cut-off that has neither
// been registered nor been ruled out.
func (s *Store) myNalogBacklogBefore(ctx context.Context, before time.Time) (MyNalogBacklog, error) {
var (
out MyNalogBacklog
oldest sql.NullTime
)
err := s.db.QueryRowContext(ctx, `
SELECT count(*),
COALESCE(SUM((l.snapshot->>'amount_minor')::bigint), 0),
MIN(l.created_at)
FROM payments.ledger l
LEFT JOIN payments.mynalog_receipt r ON r.ledger_id = l.ledger_id
WHERE `+taxableIncome+`
AND l.created_at < $3
AND (r.status IS NULL OR r.status NOT IN ($4, $5))`,
myNalogProvider, string(CurrencyRUB), before, MyNalogSent, MyNalogNotRequired,
).Scan(&out.Count, &out.AmountMinor, &oldest)
if err != nil {
return MyNalogBacklog{}, fmt.Errorf("payments: read mynalog backlog: %w", err)
}
out.Oldest = oldest.Time
return out, nil
}
// promoteStrandedSending reclassifies rows left mid-call by a process that died. The caller holds
// the export lock, so nothing is genuinely in flight; and because the registration call is not
// idempotent, the safe reading of "we started and do not know how it ended" is MyNalogUnknown,
// never "try again".
func (s *Store) promoteStrandedSending(ctx context.Context, now time.Time) (int, error) {
res, err := s.db.ExecContext(ctx, `
UPDATE payments.mynalog_receipt
SET status = $1, last_error = $2, updated_at = $3
WHERE status = $4`,
MyNalogUnknown, "the export stopped mid-call; the outcome was never established",
now, MyNalogSending)
if err != nil {
return 0, fmt.Errorf("payments: promote stranded mynalog rows: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("payments: promote stranded mynalog rows: %w", err)
}
return int(n), nil
}
// beginMyNalogSend writes the intent to register, freezing the receipt name. It commits before the
// request leaves so that a crash mid-call is visible afterwards.
func (s *Store) beginMyNalogSend(ctx context.Context, in MyNalogIncome, receiptName string, now time.Time) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO payments.mynalog_receipt (ledger_id, order_id, account_id, status, receipt_name,
operation_time, amount_minor, currency, attempts, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1, $9, $9)
ON CONFLICT (ledger_id) DO UPDATE SET
status = EXCLUDED.status,
receipt_name = EXCLUDED.receipt_name,
attempts = payments.mynalog_receipt.attempts + 1,
last_error = '',
updated_at = EXCLUDED.updated_at`,
in.LedgerID, in.OrderID, in.AccountID, MyNalogSending, receiptName,
in.OperationTime, in.Amount.Minor(), string(in.Amount.Currency()), now)
if err != nil {
return fmt.Errorf("payments: begin mynalog send: %w", err)
}
return nil
}
// markMyNalogSent records the receipt the tax service issued.
func (s *Store) markMyNalogSent(ctx context.Context, ledgerID uuid.UUID, receiptUUID string, manual bool, now time.Time) error {
res, err := s.db.ExecContext(ctx, `
UPDATE payments.mynalog_receipt
SET status = $1, receipt_uuid = $2, entered_manually = $3, sent_at = $4,
last_error = '', updated_at = $4
WHERE ledger_id = $5`,
MyNalogSent, receiptUUID, manual, now, ledgerID)
if err != nil {
return fmt.Errorf("payments: mark mynalog sent: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("payments: mark mynalog sent: no export row for ledger %s", ledgerID)
}
return nil
}
// markMyNalogManual records an income an operator filed by hand, creating the row when the export
// never attempted it. Such a row is deliberately indistinguishable from an automatic one apart from
// the flag, so a later refund still annuls the receipt without further human involvement.
func (s *Store) markMyNalogManual(ctx context.Context, in MyNalogIncome, receiptName, receiptUUID string, now time.Time) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO payments.mynalog_receipt (ledger_id, order_id, account_id, status, receipt_uuid,
receipt_name, operation_time, amount_minor, currency, entered_manually, sent_at,
created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, true, $10, $10, $10)
ON CONFLICT (ledger_id) DO UPDATE SET
status = EXCLUDED.status,
receipt_uuid = EXCLUDED.receipt_uuid,
entered_manually = true,
sent_at = EXCLUDED.sent_at,
last_error = '',
updated_at = EXCLUDED.updated_at`,
in.LedgerID, in.OrderID, in.AccountID, MyNalogSent, receiptUUID, receiptName,
in.OperationTime, in.Amount.Minor(), string(in.Amount.Currency()), now)
if err != nil {
return fmt.Errorf("payments: mark mynalog manual: %w", err)
}
return nil
}
// markMyNalogOutcome records a non-final outcome and why.
func (s *Store) markMyNalogOutcome(ctx context.Context, ledgerID uuid.UUID, status, reason string, now time.Time) error {
res, err := s.db.ExecContext(ctx, `
UPDATE payments.mynalog_receipt
SET status = $1, last_error = $2, updated_at = $3
WHERE ledger_id = $4`, status, reason, now, ledgerID)
if err != nil {
return fmt.Errorf("payments: mark mynalog outcome: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("payments: mark mynalog outcome: no export row for ledger %s", ledgerID)
}
return nil
}
// markMyNalogNotRequired records that an income needs no receipt, because the buyer had the money
// back before one was issued.
func (s *Store) markMyNalogNotRequired(ctx context.Context, in MyNalogIncome, reason string, now time.Time) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO payments.mynalog_receipt (ledger_id, order_id, account_id, status, receipt_name,
operation_time, amount_minor, currency, last_error, created_at, updated_at)
VALUES ($1, $2, $3, $4, '', $5, $6, $7, $8, $9, $9)
ON CONFLICT (ledger_id) DO UPDATE SET
status = EXCLUDED.status,
last_error = EXCLUDED.last_error,
updated_at = EXCLUDED.updated_at`,
in.LedgerID, in.OrderID, in.AccountID, MyNalogNotRequired,
in.OperationTime, in.Amount.Minor(), string(in.Amount.Currency()), reason, now)
if err != nil {
return fmt.Errorf("payments: mark mynalog not required: %w", err)
}
return nil
}
// markMyNalogCancel records the outcome of annulling a receipt.
func (s *Store) markMyNalogCancel(ctx context.Context, ledgerID, refundLedgerID uuid.UUID, status, reason string, now time.Time) error {
var cancelledAt any
if status == MyNalogCancelled {
cancelledAt = now
}
res, err := s.db.ExecContext(ctx, `
UPDATE payments.mynalog_receipt
SET cancel_status = $1, cancel_refund_ledger_id = $2, cancelled_at = $3,
last_error = $4, updated_at = $5
WHERE ledger_id = $6`, status, refundLedgerID, cancelledAt, reason, now, ledgerID)
if err != nil {
return fmt.Errorf("payments: mark mynalog cancel: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("payments: mark mynalog cancel: no export row for ledger %s", ledgerID)
}
return nil
}
// saveMyNalogSession stores the session. Signing in again clears a self-imposed pause — that is the
// operator telling us the cause has been dealt with — while the automatic mode is left as it was,
// so a routine re-login does not silently arm or disarm it.
func (s *Store) saveMyNalogSession(ctx context.Context, inn, deviceID string, refreshTokenEnc []byte, now time.Time) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO payments.mynalog_session (only_row, inn, device_id, refresh_token_enc, updated_at)
VALUES (true, $1, $2, $3, $4)
ON CONFLICT (only_row) DO UPDATE SET
inn = EXCLUDED.inn,
device_id = EXCLUDED.device_id,
refresh_token_enc = EXCLUDED.refresh_token_enc,
paused_at = NULL,
pause_reason = '',
updated_at = EXCLUDED.updated_at`,
inn, deviceID, refreshTokenEnc, now)
if err != nil {
return fmt.Errorf("payments: save mynalog session: %w", err)
}
return nil
}
// updateMyNalogRefreshToken replaces the stored token after the service rotated it.
func (s *Store) updateMyNalogRefreshToken(ctx context.Context, refreshTokenEnc []byte, now time.Time) error {
_, err := s.db.ExecContext(ctx, `
UPDATE payments.mynalog_session SET refresh_token_enc = $1, updated_at = $2
WHERE only_row`, refreshTokenEnc, now)
if err != nil {
return fmt.Errorf("payments: update mynalog refresh token: %w", err)
}
return nil
}
// myNalogSession reads the stored session.
func (s *Store) myNalogSession(ctx context.Context) (MyNalogSession, bool, error) {
var (
out MyNalogSession
pausedAt, lastOKAt, lastAlertAt sql.NullTime
)
err := s.db.QueryRowContext(ctx, `
SELECT inn, device_id, refresh_token_enc, auto_enabled, paused_at, pause_reason,
last_ok_at, last_alert_at, last_alert_kind, updated_at
FROM payments.mynalog_session WHERE only_row`).
Scan(&out.INN, &out.DeviceID, &out.RefreshTokenEnc, &out.AutoEnabled, &pausedAt,
&out.PauseReason, &lastOKAt, &lastAlertAt, &out.LastAlertKind, &out.UpdatedAt)
if err == sql.ErrNoRows {
return MyNalogSession{}, false, nil
}
if err != nil {
return MyNalogSession{}, false, fmt.Errorf("payments: read mynalog session: %w", err)
}
if pausedAt.Valid {
out.PausedAt = &pausedAt.Time
}
if lastOKAt.Valid {
out.LastOKAt = &lastOKAt.Time
}
if lastAlertAt.Valid {
out.LastAlertAt = &lastAlertAt.Time
}
return out, true, nil
}
// dropMyNalogSession signs the rail out.
func (s *Store) dropMyNalogSession(ctx context.Context) error {
if _, err := s.db.ExecContext(ctx, `DELETE FROM payments.mynalog_session WHERE only_row`); err != nil {
return fmt.Errorf("payments: drop mynalog session: %w", err)
}
return nil
}
// setMyNalogAuto arms or disarms the automatic export.
func (s *Store) setMyNalogAuto(ctx context.Context, enabled bool, now time.Time) error {
res, err := s.db.ExecContext(ctx, `
UPDATE payments.mynalog_session SET auto_enabled = $1, updated_at = $2
WHERE only_row`, enabled, now)
if err != nil {
return fmt.Errorf("payments: set mynalog auto: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("payments: set mynalog auto: sign in first")
}
return nil
}
// pauseMyNalog takes the rail out of service with a reason.
func (s *Store) pauseMyNalog(ctx context.Context, reason string, now time.Time) error {
if _, err := s.db.ExecContext(ctx, `
UPDATE payments.mynalog_session SET paused_at = $1, pause_reason = $2, updated_at = $1
WHERE only_row AND paused_at IS NULL`, now, reason); err != nil {
return fmt.Errorf("payments: pause mynalog: %w", err)
}
return nil
}
// resumeMyNalog clears a self-imposed pause.
func (s *Store) resumeMyNalog(ctx context.Context, now time.Time) error {
if _, err := s.db.ExecContext(ctx, `
UPDATE payments.mynalog_session SET paused_at = NULL, pause_reason = '', updated_at = $1
WHERE only_row`, now); err != nil {
return fmt.Errorf("payments: resume mynalog: %w", err)
}
return nil
}
// noteMyNalogOK records a successful exchange with the tax service.
func (s *Store) noteMyNalogOK(ctx context.Context, now time.Time) error {
if _, err := s.db.ExecContext(ctx, `
UPDATE payments.mynalog_session SET last_ok_at = $1, updated_at = $1
WHERE only_row`, now); err != nil {
return fmt.Errorf("payments: note mynalog ok: %w", err)
}
return nil
}
// noteMyNalogAlert records that an alert of the given kind went out.
func (s *Store) noteMyNalogAlert(ctx context.Context, kind string, now time.Time) error {
if _, err := s.db.ExecContext(ctx, `
UPDATE payments.mynalog_session SET last_alert_at = $1, last_alert_kind = $2, updated_at = $1
WHERE only_row`, now, kind); err != nil {
return fmt.Errorf("payments: note mynalog alert: %w", err)
}
return nil
}
// tryLockMyNalog takes the advisory lock guarding an export run.
//
// The lock is held on one pinned connection, because a session-level advisory lock belongs to the
// connection that took it: releasing it from another pooled connection would silently fail and
// leave the rail locked until the process restarted.
func (s *Store) tryLockMyNalog(ctx context.Context) (func(), bool, error) {
conn, err := s.db.Conn(ctx)
if err != nil {
return nil, false, fmt.Errorf("payments: lock mynalog: %w", err)
}
var locked bool
if err := conn.QueryRowContext(ctx, `SELECT pg_try_advisory_lock($1)`, myNalogLockKey).Scan(&locked); err != nil {
_ = conn.Close()
return nil, false, fmt.Errorf("payments: lock mynalog: %w", err)
}
if !locked {
_ = conn.Close()
return nil, false, nil
}
release := func() {
// The caller's context is typically done by now (that is what ended the run), and the lock
// outlives the connection's return to the pool, so the unlock must not inherit that
// cancellation.
_, _ = conn.ExecContext(context.WithoutCancel(ctx), `SELECT pg_advisory_unlock($1)`, myNalogLockKey)
_ = conn.Close()
}
return release, true, nil
}
// pendingPurchaseMails reads credited purchases whose buyer has not been written to yet. Every
// undecided succeeded event is returned, including store-rail ones the caller will merely stamp:
// the cursor tracks the decision, so leaving a row unexamined would mean revisiting it forever.
func (s *Store) pendingPurchaseMails(ctx context.Context, limit int) ([]PurchaseMail, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT e.event_id, e.account_id, e.order_id, COALESCE(o.origin, ''),
COALESCE(o.expected_amount, 0), COALESCE(o.currency, ''),
COALESCE(p.title, ''), COALESCE((e.payload->>'chips')::int, 0), e.created_at
FROM payments.payment_events e
LEFT JOIN payments.orders o ON o.order_id = e.order_id
LEFT JOIN payments.product p ON p.product_id = o.product_id
WHERE e.type = 'succeeded' AND e.mailed_at IS NULL
ORDER BY e.created_at
LIMIT $1`, limit)
if err != nil {
return nil, fmt.Errorf("payments: read purchase mail queue: %w", err)
}
defer rows.Close()
var out []PurchaseMail
for rows.Next() {
var (
m PurchaseMail
orderID uuid.NullUUID
)
if err := rows.Scan(&m.EventID, &m.AccountID, &orderID, &m.Origin, &m.AmountMinor,
&m.Currency, &m.Title, &m.Chips, &m.CreatedAt); err != nil {
return nil, fmt.Errorf("payments: scan purchase mail: %w", err)
}
m.OrderID = orderID.UUID
out = append(out, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("payments: read purchase mail queue: %w", err)
}
return out, nil
}
// markPurchaseMailed stamps the mail decision for an event.
func (s *Store) markPurchaseMailed(ctx context.Context, eventID uuid.UUID, now time.Time) error {
if _, err := s.db.ExecContext(ctx, `
UPDATE payments.payment_events SET mailed_at = $1 WHERE event_id = $2`,
now, eventID); err != nil {
return fmt.Errorf("payments: mark purchase mailed: %w", err)
}
return nil
}
// pendingMyNalogMails reads receipts owed a letter: the annulment letter when cancelled is true,
// the receipt letter otherwise. The product title and pack size come from the ledger snapshot the
// income was filed from, so the letter describes what the buyer actually bought.
func (s *Store) pendingMyNalogMails(ctx context.Context, limit int, cancelled bool) ([]ReceiptMail, error) {
// Both variants bind the same two parameters, so neither leaves a placeholder unreferenced. A
// cancelled receipt is always a sent one, so repeating the status test costs nothing.
where := `r.status = $1 AND r.receipt_mail_sent_at IS NULL`
if cancelled {
where = `r.status = $1 AND r.cancel_status = '` + MyNalogCancelled +
`' AND r.cancel_mail_sent_at IS NULL`
}
rows, err := s.db.QueryContext(ctx, `
SELECT r.ledger_id, r.account_id, r.receipt_uuid, l.snapshot,
r.amount_minor, r.currency, r.operation_time
FROM payments.mynalog_receipt r
JOIN payments.ledger l ON l.ledger_id = r.ledger_id
WHERE `+where+`
ORDER BY r.updated_at
LIMIT $2`, MyNalogSent, limit)
if err != nil {
return nil, fmt.Errorf("payments: read receipt mail queue: %w", err)
}
defer rows.Close()
var out []ReceiptMail
for rows.Next() {
var (
m ReceiptMail
snapshot sql.NullString
)
if err := rows.Scan(&m.LedgerID, &m.AccountID, &m.ReceiptUUID, &snapshot,
&m.AmountMinor, &m.Currency, &m.OperationTime); err != nil {
return nil, fmt.Errorf("payments: scan receipt mail: %w", err)
}
m.Title, m.Chips = describeSnapshot(snapshot.String)
out = append(out, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("payments: read receipt mail queue: %w", err)
}
return out, nil
}
// markMyNalogMailed stamps the letter decision for an income.
func (s *Store) markMyNalogMailed(ctx context.Context, ledgerID uuid.UUID, cancelled bool, now time.Time) error {
column := "receipt_mail_sent_at"
if cancelled {
column = "cancel_mail_sent_at"
}
if _, err := s.db.ExecContext(ctx,
`UPDATE payments.mynalog_receipt SET `+column+` = $1, updated_at = $1 WHERE ledger_id = $2`,
now, ledgerID); err != nil {
return fmt.Errorf("payments: mark receipt mailed: %w", err)
}
return nil
}