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
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.
85 lines
2.7 KiB
Go
85 lines
2.7 KiB
Go
package mynalogsync
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"scrabble/backend/internal/account"
|
|
)
|
|
|
|
// Run drives the automatic export until the context ends.
|
|
//
|
|
// A tick with nothing to do costs one local query: the queue is read before anything authenticates,
|
|
// so an installation with no unfiled income never appears at the tax service at all. The automatic
|
|
// mode is armed from the admin console rather than from configuration, so that it can be switched
|
|
// on only once the operator has watched a run go through by hand.
|
|
func (e *Exporter) Run(ctx context.Context, interval time.Duration) {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
e.tick(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// tick performs one automatic run, if the rail is signed in, armed and not paused.
|
|
func (e *Exporter) tick(ctx context.Context) {
|
|
stored, ok, err := e.payments.MyNalogSession(ctx)
|
|
if err != nil {
|
|
e.log.Error("mynalog session could not be read", zap.Error(err))
|
|
return
|
|
}
|
|
if !ok || !stored.AutoEnabled || stored.Paused() {
|
|
return
|
|
}
|
|
sum, err := e.RunBatch(ctx, WorkerBudget)
|
|
if err != nil {
|
|
e.log.Error("mynalog automatic export failed", zap.Error(err))
|
|
return
|
|
}
|
|
if sum.Registered == 0 && sum.Cancelled == 0 && sum.NotRequired == 0 &&
|
|
sum.Failed == 0 && sum.Unknown == 0 {
|
|
return // nothing happened; not worth a log line every quarter of an hour
|
|
}
|
|
e.log.Info("mynalog automatic export ran",
|
|
zap.Int("registered", sum.Registered), zap.Int("cancelled", sum.Cancelled),
|
|
zap.Int("not_required", sum.NotRequired), zap.Int("failed", sum.Failed),
|
|
zap.Int("unknown", sum.Unknown), zap.String("stopped", sum.Stopped))
|
|
}
|
|
|
|
// MailAlerter delivers operator alerts by email, reusing the relay the admin digest already uses.
|
|
type MailAlerter struct {
|
|
mail account.Mailer
|
|
from string
|
|
to string
|
|
}
|
|
|
|
// NewMailAlerter builds the alert channel. It returns nil unless a distinct admin sender and
|
|
// recipient are configured, matching how the existing admin digest stays inert without them.
|
|
func NewMailAlerter(mail account.Mailer, from, to string) *MailAlerter {
|
|
if mail == nil || from == "" || to == "" {
|
|
return nil
|
|
}
|
|
return &MailAlerter{mail: mail, from: from, to: to}
|
|
}
|
|
|
|
// Alert sends one operator alert. kind is the caller's deduplication key and is deliberately not
|
|
// put in the message.
|
|
//
|
|
// The body never carries a link into the admin console: an admin URL must not travel by email,
|
|
// where a provider could cache or index it (the same rule the admin digest follows).
|
|
func (a *MailAlerter) Alert(ctx context.Context, _, subject, body string) error {
|
|
return a.mail.Send(ctx, account.Message{
|
|
From: a.from,
|
|
To: a.to,
|
|
Subject: subject,
|
|
Text: body,
|
|
})
|
|
}
|