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
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.
264 lines
9.7 KiB
Go
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"))
|
|
}
|