Files
scrabble-game/backend/internal/mynalogsync/exporter.go
T
Ilia Denisov e3c2e80a0a
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
feat(payments): report income to «Мой налог»
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.
2026-07-28 15:40:36 +02:00

658 lines
26 KiB
Go

// Package mynalogsync registers the direct rail's rouble income with the professional-income tax
// service and annuls a receipt when the money is returned. It is the orchestration layer between
// the payments domain, which knows what is owed, and the mynalog client, which knows how to say it.
//
// One engine serves both ways of running it. The admin console calls RunBatch with a short budget
// so the page does not hang; the background worker calls the same RunBatch with a long one. There
// is deliberately no second implementation, because the delicate part — deciding what a failed
// registration means — must not exist twice.
//
// Two rules shape the whole design, and both come from the tax API accepting no idempotency key:
//
// - Exactly one run at a time, enforced by an advisory lock. Two concurrent runs could file the
// same income twice, and an over-filed income costs real money to unpick.
// - An outcome that cannot be established stops everything. Where a normal integration would
// retry, this one stops and asks a human, because the two ways of being wrong are "the buyer's
// income was never declared" and "it was declared twice", and no automatic choice between them
// is safe.
package mynalogsync
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/mynalog"
"scrabble/backend/internal/payments"
)
// Pacing and sizing of a run. None of these are configurable at runtime: they are the shape of the
// rail, not an operational preference.
const (
// WorkerInterval is how often the automatic export wakes. A tick with an empty queue costs one
// local query and no traffic at all to the tax service.
WorkerInterval = 15 * time.Minute
// WorkerBudget bounds an automatic run to comfortably less than the interval, so a long queue
// simply continues on the next tick instead of overlapping with it.
WorkerBudget = 13 * time.Minute
// ConsoleBudget bounds a run started from the admin console, so the operator's request returns
// while they are still looking at it.
ConsoleBudget = 100 * time.Second
// defaultPace is the gap between two calls to the tax service. It is the whole of our politeness
// towards an undocumented API: everything else about a run is bounded by time, not by count.
defaultPace = 2 * time.Second
// queueLimit caps how much of the queue one run reads.
queueLimit = 500
// probeWindow is how far back the post-failure probe searches for our own receipt. A day is
// ample: the income being probed was attempted moments ago.
probeWindow = 24 * time.Hour
// permanentStreakLimit is how many consecutive unfixable rejections take the rail out of
// service. A changed API rejects everything, and the alternative to stopping is thousands of
// futile requests overnight.
permanentStreakLimit = 3
// downtimeAlertAfter is how long the tax service may be unreachable before it is worth a letter.
// Below that it is noise: the service is often briefly unavailable and the queue simply waits.
downtimeAlertAfter = 24 * time.Hour
)
// Alert kinds. They double as the deduplication key: a persistent fault produces one letter a day
// per kind rather than one per run.
const (
alertPaused = "paused"
alertUnknown = "unknown"
alertLimit = "limit"
alertDown = "down"
alertSignIn = "signin"
// The three filing-deadline kinds are distinct so that an escalation is never suppressed by the
// previous, gentler letter still being within its deduplication window.
alertBacklog = "backlog"
alertBacklogDue = "backlog-due"
alertBacklogOverdue = "backlog-overdue"
)
// ErrNotSignedIn is returned when the rail has no usable session. Only an operator can fix it, by
// signing in again with the tax cabinet password.
var ErrNotSignedIn = errors.New("mynalogsync: not signed in to the tax cabinet")
// ErrBusy is returned when another run already holds the export lock.
var ErrBusy = errors.New("mynalogsync: an export is already running")
// Alerter delivers an operator alert. kind is the deduplication key, not part of the message.
type Alerter interface {
Alert(ctx context.Context, kind, subject, body string) error
}
// Config is the deployment-time shape of the rail. An empty Key disables it entirely: without
// somewhere safe to keep the refresh token there is no way to stay signed in.
type Config struct {
Key []byte
BaseURL string
Location *time.Location
// Pace overrides the gap between calls. It exists for tests, which would otherwise take
// minutes; production leaves it zero.
Pace time.Duration
}
// Exporter registers income with the tax service on behalf of one taxpayer.
type Exporter struct {
payments *payments.Service
alert Alerter
log *zap.Logger
cfg Config
http *http.Client
now func() time.Time
}
// New builds an Exporter. It returns nil when cfg carries no encryption key, which is how the rail
// stays dormant on a deployment that has not configured it.
func New(svc *payments.Service, cfg Config, alert Alerter, log *zap.Logger) *Exporter {
if svc == nil || len(cfg.Key) == 0 {
return nil
}
if cfg.Location == nil {
cfg.Location = time.UTC
}
if cfg.Pace <= 0 {
cfg.Pace = defaultPace
}
return &Exporter{
payments: svc,
alert: alert,
log: log,
cfg: cfg,
// One client shared across a run so connections pool; the ceiling is the API client's own,
// and per-request cancellation still rides the context.
http: &http.Client{Timeout: mynalog.RequestTimeout},
now: func() time.Time { return time.Now().UTC() },
}
}
// Summary is what one run did. Stopped explains an early exit in words an operator can act on; it
// is empty when the run simply ran out of work.
type Summary struct {
Registered int
Cancelled int
NotRequired int
Failed int
Unknown int
Busy bool
Paused bool
Stopped string
}
// clientFor builds a tax-cabinet client presenting the given device.
func (e *Exporter) clientFor(deviceID string) *mynalog.Client {
return mynalog.NewClient(e.cfg.BaseURL, deviceID, e.http)
}
// Location is the taxpayer's time zone. Every moment sent to the tax service is rendered in it,
// because the offset decides which tax period an income falls into.
func (e *Exporter) Location() *time.Location { return e.cfg.Location }
// ReceiptURL is where a registered receipt can be read, against the configured service root.
func (e *Exporter) ReceiptURL(inn, receiptUUID string) string {
return mynalog.ReceiptURL(e.cfg.BaseURL, inn, receiptUUID)
}
// ReceiptName renders exactly the service description an income would be filed under. The console
// shows it, the manual-entry export lists it, and the recovery probe searches for it — all three
// must agree, so they all come from here.
func (e *Exporter) ReceiptName(in payments.MyNalogIncome) string {
if in.ReceiptName != "" {
return in.ReceiptName // already frozen by an earlier attempt; the probe depends on it
}
return mynalog.IncomeName(in.Chips, in.OrderID, in.Title)
}
// SignIn exchanges the cabinet password for a session and stores it. Only the refresh token is
// kept, sealed with the configured key; the password is not persisted anywhere, which is why this
// is an interactive action rather than a configuration value.
func (e *Exporter) SignIn(ctx context.Context, login, password string) error {
deviceID := mynalog.DeviceIDForLogin(login)
sess, err := e.clientFor(deviceID).AuthByPassword(ctx, login, password, e.now())
if err != nil {
return err
}
if sess.RefreshToken == "" {
return fmt.Errorf("mynalogsync: the tax cabinet issued no refresh token: %w", mynalog.ErrPermanent)
}
sealed, err := mynalog.Seal(e.cfg.Key, sess.RefreshToken)
if err != nil {
return err
}
return e.payments.SaveMyNalogSession(ctx, sess.INN, deviceID, sealed)
}
// SignOut forgets the stored session, which also disarms the automatic export.
func (e *Exporter) SignOut(ctx context.Context) error {
return e.payments.DropMyNalogSession(ctx)
}
// session renews the stored session and returns a client bound to it. A rotated refresh token is
// persisted immediately: losing one would strand the next renewal and force a password login.
func (e *Exporter) session(ctx context.Context) (mynalog.Session, *mynalog.Client, error) {
stored, ok, err := e.payments.MyNalogSession(ctx)
if err != nil {
return mynalog.Session{}, nil, err
}
if !ok {
return mynalog.Session{}, nil, ErrNotSignedIn
}
refresh, err := mynalog.Open(e.cfg.Key, stored.RefreshTokenEnc)
if err != nil {
// The key was rotated or lost. Nothing is broken beyond repair; the operator signs in again.
return mynalog.Session{}, nil, fmt.Errorf("%w: %v", ErrNotSignedIn, err)
}
client := e.clientFor(stored.DeviceID)
sess, err := client.AuthByRefresh(ctx, refresh, stored.INN, e.now())
if err != nil {
return mynalog.Session{}, nil, err
}
if sess.RefreshToken != refresh {
sealed, err := mynalog.Seal(e.cfg.Key, sess.RefreshToken)
if err != nil {
return mynalog.Session{}, nil, err
}
if err := e.payments.UpdateMyNalogRefreshToken(ctx, sealed); err != nil {
return mynalog.Session{}, nil, err
}
}
return sess, client, nil
}
// RunBatch performs one export run within budget and reports what it did.
//
// The order is deliberate. Local bookkeeping happens first, so progress is made even when the tax
// service is unreachable. Annulments go before registrations, because an annulment removes income
// from the tax base and is the more time-sensitive of the two. And nothing authenticates until the
// queue is known to be non-empty, so an idle installation is invisible to the tax service.
func (e *Exporter) RunBatch(ctx context.Context, budget time.Duration) (Summary, error) {
release, ok, err := e.payments.TryLockMyNalog(ctx)
if err != nil {
return Summary{}, err
}
if !ok {
return Summary{Busy: true}, nil
}
defer release()
var sum Summary
deadline := e.now().Add(budget)
// A row still marked as being sent belongs to a run that died mid-call. Under the lock nothing
// is genuinely in flight, so it becomes an unresolved outcome rather than a silent retry.
if stranded, err := e.payments.PromoteStrandedSending(ctx); err != nil {
return sum, err
} else if stranded > 0 {
e.log.Warn("mynalog export found stranded rows", zap.Int("count", stranded))
}
queue, err := e.payments.MyNalogQueue(ctx, queueLimit)
if err != nil {
return sum, err
}
// Refunded before ever being filed: nothing to declare and nothing to annul. Purely local.
for _, in := range queue.NotRequired {
if err := e.payments.MarkMyNalogNotRequired(ctx, in,
"the money was returned before the income was ever filed"); err != nil {
return sum, err
}
sum.NotRequired++
}
stored, ok, err := e.payments.MyNalogSession(ctx)
if err != nil {
return sum, err
}
if !ok {
if len(queue.Incomes) > 0 || len(queue.Cancels) > 0 {
return sum, ErrNotSignedIn
}
return sum, nil
}
if stored.Paused() {
sum.Paused = true
sum.Stopped = stored.PauseReason
return sum, nil
}
if len(queue.Incomes) == 0 && len(queue.Cancels) == 0 {
return sum, nil
}
sess, client, err := e.session(ctx)
if err != nil {
return e.handleSessionFailure(ctx, sum, err)
}
run := &runner{e: e, client: client, sess: sess}
sum, err = run.cancels(ctx, queue.Cancels, deadline, sum)
if err != nil {
return sum, err
}
// An unresolved outcome from an earlier run blocks new filings. It does not block annulments —
// those only ever remove income — but it does mean the operator must look before more income is
// declared, which is the only way the backlog of unknowns stays at zero.
if len(queue.Attention) > 0 {
sum.Unknown = len(queue.Attention)
sum.Stopped = "there are incomes with an unresolved outcome; resolve them before filing more"
e.raise(ctx, alertUnknown, "«Мой налог»: есть чеки с неизвестным исходом",
fmt.Sprintf("Выгрузка остановлена: %d приход(ов) в состоянии «неизвестно». "+
"Откройте раздел «Мой налог» в админ-консоли и разберите их — проверьте повторно "+
"или введите УИД вручную. Пока они не разобраны, новые чеки не отправляются.",
len(queue.Attention)))
return sum, nil
}
return run.incomes(ctx, queue.Incomes, deadline, sum)
}
// runner carries the authenticated session through one run so that a token which ages out mid-queue
// can be renewed in place instead of derailing the income it happened to land on.
type runner struct {
e *Exporter
client *mynalog.Client
sess mynalog.Session
reauthed bool
}
// reauth renews the session once per run. Twice would mean something other than expiry is wrong,
// and looping on it would hammer the authentication endpoint.
func (r *runner) reauth(ctx context.Context) error {
if r.reauthed {
return errors.New("mynalogsync: the session was already renewed once this run")
}
sess, client, err := r.e.session(ctx)
if err != nil {
return err
}
r.sess, r.client, r.reauthed = sess, client, true
return nil
}
// call runs op, renewing the session and retrying once when the access token turns out to have aged
// out mid-run.
//
// The retry is safe precisely because a rejected token is refused before the request is processed:
// no receipt can have been created, so this is the one failure that may be repeated without a probe.
// Treating it as an unknown outcome instead would halt the queue every hour for no reason.
func (r *runner) call(ctx context.Context, op func() error) error {
err := op()
if !errors.Is(err, mynalog.ErrAuthExpired) || r.reauthed {
return err
}
if authErr := r.reauth(ctx); authErr != nil {
return fmt.Errorf("%w: renewing the session failed: %v", mynalog.ErrPermanent, authErr)
}
return op()
}
// handleSessionFailure turns a failed sign-in into an alert and, where the fault is unfixable by
// repetition, takes the rail out of service.
func (e *Exporter) handleSessionFailure(ctx context.Context, sum Summary, err error) (Summary, error) {
switch {
case errors.Is(err, ErrNotSignedIn), errors.Is(err, mynalog.ErrPermanent):
reason := "сессия «Мой налог» недействительна — нужно войти заново"
if pauseErr := e.payments.PauseMyNalog(ctx, reason); pauseErr != nil {
return sum, pauseErr
}
sum.Paused = true
sum.Stopped = reason
e.raise(ctx, alertSignIn, "«Мой налог»: требуется вход",
"Автоматическая выгрузка остановлена: сохранённая сессия больше не действует. "+
"Откройте раздел «Мой налог» в админ-консоли и войдите заново по логину и паролю.")
return sum, nil
default:
sum.Stopped = "сервис «Мой налог» недоступен"
e.noteDowntime(ctx)
return sum, nil
}
}
// cancels annuls receipts whose income was refunded.
func (r *runner) cancels(ctx context.Context, cancels []payments.MyNalogCancel,
deadline time.Time, sum Summary) (Summary, error) {
e := r.e
for _, c := range cancels {
if e.now().After(deadline) {
sum.Stopped = "время прогона вышло; остаток уйдёт следующим прогоном"
return sum, nil
}
err := r.call(ctx, func() error {
return r.client.CancelIncome(ctx, r.sess, c.ReceiptUUID, e.now().In(e.cfg.Location))
})
switch {
case err == nil:
if err := e.payments.MarkMyNalogCancelled(ctx, c.LedgerID, c.RefundLedgerID); err != nil {
return sum, err
}
_ = e.payments.NoteMyNalogOK(ctx)
sum.Cancelled++
case errors.Is(err, mynalog.ErrThrottled):
sum.Stopped = "сервис «Мой налог» просит снизить темп"
return sum, nil
case errors.Is(err, mynalog.ErrPermanent):
// The receipt stands and repeating will not change that; record it and let the operator
// see it rather than hammering the same call.
if markErr := e.payments.MarkMyNalogCancelFailed(ctx, c.LedgerID, c.RefundLedgerID, err.Error()); markErr != nil {
return sum, markErr
}
sum.Failed++
default:
sum.Stopped = "сервис «Мой налог» недоступен"
e.noteDowntime(ctx)
return sum, nil
}
if err := e.pause(ctx); err != nil {
return sum, nil
}
}
return sum, nil
}
// incomes files income, one at a time, until the queue empties or the budget runs out.
func (r *runner) incomes(ctx context.Context, incomes []payments.MyNalogIncome,
deadline time.Time, sum Summary) (Summary, error) {
e := r.e
streak := 0
for _, in := range incomes {
if e.now().After(deadline) {
sum.Stopped = "время прогона вышло; остаток уйдёт следующим прогоном"
return sum, nil
}
name := e.ReceiptName(in)
// Frozen before the call, and once per income: the name is the probe's only handle, and the
// attempt counter should count incomes tried, not tokens renewed.
if err := e.payments.BeginMyNalogSend(ctx, in, name); err != nil {
return sum, err
}
var receipt string
err := r.call(ctx, func() error {
var callErr error
receipt, callErr = r.client.AddIncome(ctx, r.sess, mynalog.IncomeRequest{
Name: name,
Amount: in.Amount.Major(),
OperationTime: in.OperationTime.In(e.cfg.Location),
RequestTime: e.now().In(e.cfg.Location),
})
return callErr
})
switch {
case err == nil:
if err := e.payments.MarkMyNalogSent(ctx, in.LedgerID, receipt, false); err != nil {
return sum, err
}
_ = e.payments.NoteMyNalogOK(ctx)
sum.Registered++
streak = 0
case errors.Is(err, mynalog.ErrThrottled):
// Nothing was filed; the row goes back into the queue for the next run.
if markErr := e.payments.MarkMyNalogOutcome(ctx, in.LedgerID, payments.MyNalogFailed, err.Error()); markErr != nil {
return sum, markErr
}
sum.Stopped = "сервис «Мой налог» просит снизить темп"
return sum, nil
case errors.Is(err, mynalog.ErrPermanent):
if markErr := e.payments.MarkMyNalogOutcome(ctx, in.LedgerID, payments.MyNalogFailed, err.Error()); markErr != nil {
return sum, markErr
}
sum.Failed++
streak++
if errors.Is(err, mynalog.ErrIncomeLimit) {
return e.stop(ctx, sum, alertLimit, "превышен годовой лимит дохода по НПД",
"«Мой налог» отклонил чек: превышен годовой лимит дохода. Это уже не техническая "+
"ошибка — режим НПД больше не применяется. Выгрузка остановлена.")
}
if streak >= permanentStreakLimit {
return e.stop(ctx, sum, alertPaused, "«Мой налог» отклоняет чеки подряд",
fmt.Sprintf("Подряд отклонено %d чеков — похоже, изменился формат API или данные. "+
"Выгрузка поставлена на паузу, чтобы не долбить сервис. Последняя ошибка: %v",
streak, err))
}
default:
// The outcome is genuinely unknown: ask the tax service whether our receipt exists.
if e.resolveByProbe(ctx, r.client, r.sess, in, name) {
_ = e.payments.NoteMyNalogOK(ctx)
sum.Registered++
streak = 0
break
}
if markErr := e.payments.MarkMyNalogOutcome(ctx, in.LedgerID, payments.MyNalogUnknown, err.Error()); markErr != nil {
return sum, markErr
}
sum.Unknown++
sum.Stopped = "исход отправки не удалось установить; дальше — только вручную"
e.raise(ctx, alertUnknown, "«Мой налог»: исход отправки неизвестен",
fmt.Sprintf("Чек на сумму %s не удалось подтвердить: сервис не ответил, а поиск по "+
"названию «%s» ничего не нашёл. Выгрузка остановлена, чтобы не задвоить доход. "+
"В админ-консоли, раздел «Мой налог»: «проверить ещё раз», либо введите УИД "+
"вручную, либо верните приход в очередь. Ошибка: %v", in.Amount, name, err))
return sum, nil
}
if err := e.pause(ctx); err != nil {
return sum, nil
}
}
return sum, nil
}
// resolveByProbe asks the tax service whether the receipt we just failed to confirm exists after
// all, recording it when it does. It reports whether the income ended up filed.
//
// This is the whole recovery story for a non-idempotent write: without it a timeout would leave
// every ambiguous income to be sorted out by hand.
func (e *Exporter) resolveByProbe(ctx context.Context, client *mynalog.Client, sess mynalog.Session,
in payments.MyNalogIncome, name string) bool {
from := in.OperationTime.In(e.cfg.Location).Add(-probeWindow)
to := e.now().In(e.cfg.Location).Add(time.Minute)
receipt, found, err := client.FindIncome(ctx, sess, name, from, to)
if err != nil || !found {
return false
}
if err := e.payments.MarkMyNalogSent(ctx, in.LedgerID, receipt, false); err != nil {
e.log.Error("mynalog probe found the receipt but it could not be recorded",
zap.String("ledger", in.LedgerID.String()), zap.Error(err))
return false
}
e.log.Info("mynalog receipt recovered by probe after an ambiguous call",
zap.String("ledger", in.LedgerID.String()), zap.String("receipt", receipt))
return true
}
// stop takes the rail out of service, alerts, and ends the run.
func (e *Exporter) stop(ctx context.Context, sum Summary, kind, reason, body string) (Summary, error) {
if err := e.payments.PauseMyNalog(ctx, reason); err != nil {
return sum, err
}
sum.Paused = true
sum.Stopped = reason
e.raise(ctx, kind, "«Мой налог»: выгрузка остановлена", body)
return sum, nil
}
// pause waits out the gap between two calls, returning the context's error if it ends first.
func (e *Exporter) pause(ctx context.Context) error {
timer := time.NewTimer(e.cfg.Pace)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
// noteDowntime alerts only once the tax service has been unreachable long enough to be worth a
// letter. Below that threshold an outage is normal and the queue simply waits for it to pass.
func (e *Exporter) noteDowntime(ctx context.Context) {
stored, ok, err := e.payments.MyNalogSession(ctx)
if err != nil || !ok {
return
}
since := stored.UpdatedAt
if stored.LastOKAt != nil {
since = *stored.LastOKAt
}
if e.now().Sub(since) < downtimeAlertAfter {
return
}
e.raise(ctx, alertDown, "«Мой налог»: сервис недоступен больше суток",
fmt.Sprintf("Последняя успешная отправка была %s. Очередь ждёт; если сервис не поднимется, "+
"выгрузите чеки вручную из раздела «Мой налог» в админ-консоли.",
since.In(e.cfg.Location).Format("2006-01-02 15:04")))
}
// raise sends an operator alert unless one of the same kind went out within the last day. A
// persistent fault should be one letter a day, not one per run.
func (e *Exporter) raise(ctx context.Context, kind, subject, body string) {
if e.alert == nil {
return
}
stored, ok, err := e.payments.MyNalogSession(ctx)
if err == nil && ok && stored.LastAlertKind == kind && stored.LastAlertAt != nil &&
e.now().Sub(*stored.LastAlertAt) < 24*time.Hour {
return
}
if err := e.alert.Alert(ctx, kind, subject, body); err != nil {
e.log.Error("mynalog alert could not be delivered", zap.String("kind", kind), zap.Error(err))
return
}
if err := e.payments.NoteMyNalogAlert(ctx, kind); err != nil {
e.log.Error("mynalog alert could not be recorded", zap.String("kind", kind), zap.Error(err))
}
}
// Recheck probes the tax service once for an income whose outcome was never established, and
// reports whether the receipt turned out to exist.
func (e *Exporter) Recheck(ctx context.Context, ledgerID uuid.UUID) (bool, error) {
in, ok, err := e.payments.MyNalogIncomeAt(ctx, ledgerID)
if err != nil {
return false, err
}
if !ok {
return false, fmt.Errorf("mynalogsync: no such income")
}
release, locked, err := e.payments.TryLockMyNalog(ctx)
if err != nil {
return false, err
}
if !locked {
return false, ErrBusy
}
defer release()
sess, client, err := e.session(ctx)
if err != nil {
return false, err
}
return e.resolveByProbe(ctx, client, sess, in, e.ReceiptName(in)), nil
}
// RecordManual records an income the operator filed by hand in the tax cabinet, along with the
// receipt identifier it was given. Such a row is thereafter treated like any other: in particular a
// later refund annuls its receipt automatically, which is the whole reason the identifier is asked
// for rather than merely ticking the income off.
func (e *Exporter) RecordManual(ctx context.Context, ledgerID uuid.UUID, receiptUUID string) error {
in, ok, err := e.payments.MyNalogIncomeAt(ctx, ledgerID)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("mynalogsync: no such income")
}
return e.payments.MarkMyNalogManual(ctx, in, e.ReceiptName(in), receiptUUID)
}
// Requeue puts an income whose outcome was unresolved back into the queue. It is the operator
// asserting what the software may not assume: that the receipt really was never created.
func (e *Exporter) Requeue(ctx context.Context, ledgerID uuid.UUID) error {
return e.payments.MarkMyNalogOutcome(ctx, ledgerID, payments.MyNalogFailed,
"returned to the queue by an operator")
}
// Skip rules an income out of the export for a reason the operator gives.
func (e *Exporter) Skip(ctx context.Context, ledgerID uuid.UUID, reason string) error {
in, ok, err := e.payments.MyNalogIncomeAt(ctx, ledgerID)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("mynalogsync: no such income")
}
return e.payments.MarkMyNalogNotRequired(ctx, in, reason)
}