Files
scrabble-game/backend/internal/mynalogsync/mailer.go
T
Ilia Denisov 4d4452596d
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
fix(payments): stop doubling the pack size in buyer mail
Catalog titles already name the pack ("50 фишек"), so appending the chip count
produced "50 фишек — 50 фишек, 79.00 ₽". The letter now carries the title
exactly as the storefront shows it, then the price.

The tax receipt is unaffected: it states the quantity in the wording the tax
service wants, which is a separate surface with separate requirements.
2026-07-28 16:14:53 +02:00

264 lines
9.7 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: the catalog title exactly as the buyer saw it on
// the storefront, then the price.
//
// The pack size is deliberately NOT appended. Catalog titles already name it ("50 фишек"), so
// adding it produced "50 фишек — 50 фишек". The title is the single source of that wording; the
// tax receipt states the quantity separately in the form the tax service wants (see
// mynalog.IncomeName), which is a different surface with different requirements.
func describe(title string, amountMinor int64, currency string) string {
what := strings.TrimSpace(title)
if what == "" {
what = "покупка в игре"
}
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.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.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.AmountMinor, r.Currency),
r.OperationTime.In(loc).Format("02.01.2006 15:04"))
}