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. // // It is deliberately bare: the amount, the moment of payment with its offset, and the link. The // receipt itself is the document and it opens without authentication, so repeating its contents // here would only add something for the two to disagree about. func receiptText(r payments.ReceiptMail, inn, baseURL string, loc *time.Location) string { return fmt.Sprintf(`Здравствуйте! Покупка на сумму: %s от %s Ссылка на чек: %s Спасибо, что играете в «Эрудит». `, formatAmount(r.AmountMinor, r.Currency), r.OperationTime.In(loc).Format("02.01.2006 15:04 -07:00"), 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")) }