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,148 @@
|
||||
package mynalogsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// WatchdogInterval is how often the filing deadline is checked. It reads only local tables, so the
|
||||
// cost is one query a day.
|
||||
const WatchdogInterval = 24 * time.Hour
|
||||
|
||||
// Days of the month at which unfiled income of the previous month escalates.
|
||||
//
|
||||
// The professional-income statute gives until the 9th of the following month only for settlements
|
||||
// that do NOT involve an electronic means of payment; a card payment is one, so the receipt is
|
||||
// strictly due at the moment of settlement. The automatic export satisfies both readings, and this
|
||||
// watchdog is the backstop for when it is switched off or has stopped — which is exactly when the
|
||||
// deadline stops taking care of itself.
|
||||
const (
|
||||
warnDay = 1
|
||||
alarmDay = 5
|
||||
overdueDay = 9
|
||||
)
|
||||
|
||||
// Watchdog warns when income from a closed month is still not registered with the tax service.
|
||||
//
|
||||
// It runs whether or not the automatic export does, because the case it exists for is the automatic
|
||||
// export being off, paused or broken. Nothing here contacts the tax service.
|
||||
type Watchdog struct {
|
||||
payments *payments.Service
|
||||
alert Alerter
|
||||
loc *time.Location
|
||||
log *zap.Logger
|
||||
now func() time.Time
|
||||
|
||||
// mu guards sent, an in-process record of what has already been raised. The watchdog ticks
|
||||
// daily, so this only prevents a restart from re-alerting within the same day.
|
||||
mu sync.Mutex
|
||||
sent map[string]time.Time
|
||||
}
|
||||
|
||||
// NewWatchdog builds the deadline watchdog. It returns nil without a payments service or an alert
|
||||
// channel, since it has nothing to watch or nowhere to say it.
|
||||
func NewWatchdog(svc *payments.Service, alert Alerter, loc *time.Location, log *zap.Logger) *Watchdog {
|
||||
if svc == nil || alert == nil {
|
||||
return nil
|
||||
}
|
||||
if loc == nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
return &Watchdog{
|
||||
payments: svc, alert: alert, loc: loc, log: log,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
sent: map[string]time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
// Run checks the deadline now and then once per interval, until the context ends. It checks on
|
||||
// start too: a deployment that lands on the 6th should not wait a day to mention a month of
|
||||
// unfiled income.
|
||||
func (w *Watchdog) Run(ctx context.Context, interval time.Duration) {
|
||||
w.tick(ctx)
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.tick(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tick raises the appropriate alert for whatever of the previous month is still unfiled.
|
||||
func (w *Watchdog) tick(ctx context.Context) {
|
||||
now := w.now().In(w.loc)
|
||||
kind, heading := escalation(now.Day())
|
||||
if kind == "" {
|
||||
return
|
||||
}
|
||||
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, w.loc)
|
||||
backlog, err := w.payments.MyNalogBacklogBefore(ctx, monthStart)
|
||||
if err != nil {
|
||||
w.log.Error("fiscal deadline check failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if backlog.Count == 0 {
|
||||
return
|
||||
}
|
||||
w.raise(ctx, kind, "«Мой налог»: "+heading, w.body(heading, backlog, monthStart))
|
||||
}
|
||||
|
||||
// escalation maps the day of the month onto an alert kind and its heading, or an empty kind on the
|
||||
// days between the warning and the alarm, when there is nothing new to say.
|
||||
func escalation(day int) (kind, heading string) {
|
||||
switch {
|
||||
case day >= overdueDay:
|
||||
return alertBacklogOverdue, "срок подачи вышел"
|
||||
case day >= alarmDay:
|
||||
return alertBacklogDue, "чеки за прошлый месяц не отправлены"
|
||||
case day >= warnDay && day < alarmDay:
|
||||
if day == warnDay {
|
||||
return alertBacklog, "остались неотправленные чеки за прошлый месяц"
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// body renders the alert, naming what is owed and by when.
|
||||
func (w *Watchdog) body(heading string, backlog payments.MyNalogBacklog, monthStart time.Time) string {
|
||||
deadline := time.Date(monthStart.Year(), monthStart.Month(), overdueDay, 0, 0, 0, 0, w.loc)
|
||||
return fmt.Sprintf(`%s.
|
||||
|
||||
Не зарегистрировано в «Мой налог»: %d приход(ов) на сумму %s.
|
||||
Самый старый — от %s.
|
||||
|
||||
Крайний срок по закону — %s. Оплата картой считается расчётом электронным средством
|
||||
платежа, поэтому чек по-хорошему формируется в момент расчёта, а не к этой дате.
|
||||
|
||||
Что сделать: в админ-консоли, раздел «Мой налог», войти в кабинет и запустить выгрузку
|
||||
(или включить автоматический режим, если он выключен).`,
|
||||
heading, backlog.Count, formatAmount(backlog.AmountMinor, string(payments.CurrencyRUB)),
|
||||
backlog.Oldest.In(w.loc).Format("02.01.2006"), deadline.Format("02.01.2006"))
|
||||
}
|
||||
|
||||
// raise sends an alert unless one of the same kind already went out within the day.
|
||||
func (w *Watchdog) raise(ctx context.Context, kind, subject, body string) {
|
||||
w.mu.Lock()
|
||||
last, seen := w.sent[kind]
|
||||
now := w.now()
|
||||
if seen && now.Sub(last) < 24*time.Hour {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.sent[kind] = now
|
||||
w.mu.Unlock()
|
||||
|
||||
if err := w.alert.Alert(ctx, kind, subject, body); err != nil {
|
||||
w.log.Error("fiscal deadline alert could not be delivered", zap.String("kind", kind), zap.Error(err))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user