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
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:
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user