Files
scrabble-game/backend/internal/payments/mynalog.go
T
Ilia Denisov 4d4452596d
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
fix(payments): stop doubling the pack size in buyer mail
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.
2026-07-28 16:14:53 +02:00

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