69bddaeb9a
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 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
The receipt letter repeated what the receipt itself states. It now carries the amount, the moment of payment with its offset, and the link — the receipt is the document, and it opens without authentication, so there is nothing for the two to disagree about. Subject reworded to name what it is. The ledger and tax-export tables labelled the account link "card", meaning the user card. On pages about payments that reads as a bank card, which is exactly how it was misread. Both now show the first eight characters of the account id, so the column carries data — two rows from the same buyer are visible at a glance — while the link still holds the whole id.
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.
|
|
//
|
|
// 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"))
|
|
}
|