Files
scrabble-game/backend/internal/mynalogsync/mailer.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

261 lines
9.3 KiB
Go

package mynalogsync
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/mynalog"
"scrabble/backend/internal/payments"
)
// MailInterval is how often the letter queues are drained. Nothing here talks to the tax service,
// so a short tick costs only a local query.
const MailInterval = time.Minute
// mailBatch caps one drain, so a backlog is delivered over several ticks rather than in one burst
// through the relay.
const mailBatch = 50
// EmailLookup resolves an account's confirmed address. It is the seam onto the account domain,
// which the payments side must not import wholesale.
type EmailLookup interface {
ConfirmedEmail(ctx context.Context, accountID uuid.UUID) (string, bool, error)
}
// Mailer tells buyers what happened to their purchase: that it went through, that a tax receipt
// was issued for it, and — if the money went back — that the receipt has been annulled.
//
// It is driven entirely by durable queues rather than by the payment path. The purchase letter
// rides the existing payment-event outbox on its own cursor, and the two receipt letters ride
// columns on the export row, so nothing in the crediting or refunding code had to change and no
// letter is lost to a relay hiccup.
type Mailer struct {
payments *payments.Service
emails EmailLookup
mail account.Mailer
from string
baseURL string
loc *time.Location
log *zap.Logger
}
// NewMailer builds the buyer mailer. It returns nil when there is no relay or no sender address, so
// an installation without email simply does not write to buyers.
func NewMailer(svc *payments.Service, emails EmailLookup, mail account.Mailer,
from, baseURL string, loc *time.Location, log *zap.Logger) *Mailer {
if svc == nil || emails == nil || mail == nil || from == "" {
return nil
}
if loc == nil {
loc = time.UTC
}
return &Mailer{payments: svc, emails: emails, mail: mail, from: from,
baseURL: baseURL, loc: loc, log: log}
}
// Run drains the letter queues until the context ends.
func (m *Mailer) Run(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
m.Tick(ctx)
}
}
}
// Tick drains one batch of each queue. Run calls it on a cadence; it is exported so a test can
// drive one drain deterministically instead of waiting on a ticker.
func (m *Mailer) Tick(ctx context.Context) {
m.sendPurchaseMails(ctx)
m.sendReceiptMails(ctx, false)
m.sendReceiptMails(ctx, true)
}
// sendPurchaseMails tells buyers their purchase went through.
func (m *Mailer) sendPurchaseMails(ctx context.Context) {
pending, err := m.payments.PendingPurchaseMails(ctx, mailBatch)
if err != nil {
m.log.Error("purchase mail queue could not be read", zap.Error(err))
return
}
for _, p := range pending {
// Only the direct rail writes to buyers: a store purchase is confirmed by the store itself,
// and no tax receipt of ours follows it.
if p.Origin != string(payments.SourceDirect) || p.OrderID == uuid.Nil {
m.stampPurchase(ctx, p.EventID)
continue
}
to, ok, err := m.emails.ConfirmedEmail(ctx, p.AccountID)
if err != nil {
m.log.Error("purchase mail address lookup failed",
zap.String("event", p.EventID.String()), zap.Error(err))
continue
}
if !ok {
// A direct purchase requires a confirmed address, so this should not happen — but a
// later address removal must not leave the event undecided forever.
m.stampPurchase(ctx, p.EventID)
continue
}
msg := account.Message{
From: m.from,
To: to,
Subject: "Эрудит — покупка совершена",
Text: purchaseText(p),
}
if err := m.mail.Send(ctx, msg); err != nil {
m.log.Error("purchase mail could not be delivered",
zap.String("event", p.EventID.String()), zap.Error(err))
continue // left undecided on purpose: the next tick tries again
}
m.stampPurchase(ctx, p.EventID)
}
}
// stampPurchase records that the decision for an event has been made, whether or not a letter went.
func (m *Mailer) stampPurchase(ctx context.Context, eventID uuid.UUID) {
if err := m.payments.MarkPurchaseMailed(ctx, eventID); err != nil {
m.log.Error("purchase mail could not be marked",
zap.String("event", eventID.String()), zap.Error(err))
}
}
// sendReceiptMails delivers the tax-receipt letter, or the annulment letter when cancelled is true.
func (m *Mailer) sendReceiptMails(ctx context.Context, cancelled bool) {
read := m.payments.PendingReceiptMails
mark := m.payments.MarkReceiptMailed
if cancelled {
read = m.payments.PendingCancelMails
mark = m.payments.MarkCancelMailed
}
pending, err := read(ctx, mailBatch)
if err != nil {
m.log.Error("receipt mail queue could not be read", zap.Error(err))
return
}
if len(pending) == 0 {
return
}
stored, ok, err := m.payments.MyNalogSession(ctx)
if err != nil || !ok {
// The taxpayer id lives on the session and the receipt link cannot be built without it.
// Signing back in makes these letters go out; until then they simply wait.
return
}
for _, r := range pending {
to, ok, err := m.emails.ConfirmedEmail(ctx, r.AccountID)
if err != nil {
m.log.Error("receipt mail address lookup failed",
zap.String("ledger", r.LedgerID.String()), zap.Error(err))
continue
}
if !ok {
m.stampReceipt(ctx, mark, r.LedgerID)
continue
}
subject, body := "Эрудит — чек от налоговой", receiptText(r, stored.INN, m.baseURL, m.loc)
if cancelled {
subject, body = "Эрудит — чек аннулирован", cancelText(r, m.loc)
}
if err := m.mail.Send(ctx, account.Message{From: m.from, To: to, Subject: subject, Text: body}); err != nil {
m.log.Error("receipt mail could not be delivered",
zap.String("ledger", r.LedgerID.String()), zap.Error(err))
continue
}
m.stampReceipt(ctx, mark, r.LedgerID)
}
}
// stampReceipt records that the letter decision for an income has been made.
func (m *Mailer) stampReceipt(ctx context.Context, mark func(context.Context, uuid.UUID) error, ledgerID uuid.UUID) {
if err := mark(ctx, ledgerID); err != nil {
m.log.Error("receipt mail could not be marked",
zap.String("ledger", ledgerID.String()), zap.Error(err))
}
}
// describe renders what was bought in one line.
func describe(title string, chips int, amountMinor int64, currency string) string {
what := strings.TrimSpace(title)
if what == "" {
what = "покупка в игре"
}
if chips > 0 {
what = fmt.Sprintf("%s — %d фишек", what, chips)
}
return fmt.Sprintf("%s, %s", what, formatAmount(amountMinor, currency))
}
// formatAmount renders a minor-unit amount for a buyer, in roubles where that is the currency.
func formatAmount(minor int64, currency string) string {
money, err := payments.MoneyFromMinor(minor, payments.Currency(currency))
if err != nil {
return ""
}
if money.Currency() == payments.CurrencyRUB {
return money.Major() + " ₽"
}
return money.String()
}
// purchaseText is the confirmation sent the moment chips are credited.
//
// It says plainly that it is not the fiscal receipt. The tax receipt is issued by a separate
// service on its own schedule, and a buyer who took this letter for the receipt would be misled —
// so the wait is stated up front rather than left to be discovered.
func purchaseText(p payments.PurchaseMail) string {
return fmt.Sprintf(`Здравствуйте!
Покупка совершена: %s.
Номер заказа: %s
Это письмо — подтверждение покупки, а не чек. Чек от налоговой службы придёт
отдельным письмом: обычно в течение часа, но иногда это занимает до нескольких дней.
Спасибо, что играете в «Эрудит».
`, describe(p.Title, p.Chips, p.AmountMinor, p.Currency), p.OrderID)
}
// receiptText is the fiscal receipt letter, which is how the receipt is actually handed to the
// buyer as the professional-income tax regime requires.
func receiptText(r payments.ReceiptMail, inn, baseURL string, loc *time.Location) string {
return fmt.Sprintf(`Здравствуйте!
По вашей покупке сформирован чек в налоговой службе.
%s
Дата: %s
Чек: %s
Продавец применяет налог на профессиональный доход; чек сформирован в сервисе
«Мой налог» Федеральной налоговой службы.
`, describe(r.Title, r.Chips, r.AmountMinor, r.Currency),
r.OperationTime.In(loc).Format("02.01.2006 15:04"),
mynalog.ReceiptURL(baseURL, inn, r.ReceiptUUID))
}
// cancelText tells a buyer their receipt has been annulled after a refund, so that the link they
// were sent earlier does not simply stop working without explanation.
func cancelText(r payments.ReceiptMail, loc *time.Location) string {
return fmt.Sprintf(`Здравствуйте!
Деньги по покупке возвращены, поэтому чек в налоговой службе аннулирован —
ранее присланная ссылка на него больше не действует.
%s
Дата покупки: %s
`, describe(r.Title, r.Chips, r.AmountMinor, r.Currency),
r.OperationTime.In(loc).Format("02.01.2006 15:04"))
}