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
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.
325 lines
14 KiB
Go
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())
|
|
}
|