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,260 @@
|
||||
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"))
|
||||
}
|
||||
Reference in New Issue
Block a user