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