package bot import ( "context" "time" tgbot "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" "go.uber.org/zap" "scrabble/platform/telegram/internal/outbox" ) // starsCurrency is the Telegram Stars currency code for createInvoiceLink and the invoice line. const starsCurrency = "XTR" // paymentDrainInterval is how often the bot re-drives undelivered outbox payments (the backstop for a // gateway or backend outage, and the restart re-drive); the happy path forwards immediately on receipt. const paymentDrainInterval = 30 * time.Second // PreCheckoutValidator validates a Stars pre_checkout order over the bot-link and returns whether it // may be charged plus a short, already-localised decline reason for the payer. The bot-link client // backs it. type PreCheckoutValidator func(ctx context.Context, orderID string, amount int64, currency string) (ok bool, reason string, err error) // PaymentForwarder delivers a completed Stars payment over the bot-link for crediting and reports // whether it was credited (or already had been). A non-nil error is transient and retried. The // bot-link client backs it. type PaymentForwarder func(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (credited bool, err error) // SetPaymentHandlers wires the Telegram Stars runtime dependencies after construction: the // pre_checkout validator and payment forwarder (both over the bot-link, built after the bot) and the // durable outbox. It is the late binding that breaks the bot <-> bot-link construction cycle. func (t *Bot) SetPaymentHandlers(precheck PreCheckoutValidator, forward PaymentForwarder, store *outbox.Store) { t.precheck = precheck t.forward = forward t.outbox = store } // CreateInvoiceLink mints a Telegram Stars invoice link (XTR) for amountStars, tagged with payload // (the order id, echoed back in pre_checkout and successful_payment), and returns the link. The // provider token is empty for Stars. Only the bot reaches Telegram, so the gateway calls this over // the bot-link on the wallet-order path. func (t *Bot) CreateInvoiceLink(ctx context.Context, title, description, payload string, amountStars int64) (string, error) { return t.api.CreateInvoiceLink(ctx, &tgbot.CreateInvoiceLinkParams{ Title: title, Description: description, Payload: payload, Currency: starsCurrency, Prices: []models.LabeledPrice{{Label: title, Amount: int(amountStars)}}, }) } // handlePreCheckout answers a Stars pre_checkout_query. It validates the order against the backend // (over the bot-link) and approves only on a positive answer; a not-ok answer or a validation // failure declines the charge (fail-closed), before any star moves. The decline reason from the // backend is already localised to the payer's account language. func (t *Bot) handlePreCheckout(ctx context.Context, q *models.PreCheckoutQuery) { ok, reason := false, "" if t.precheck == nil { t.log.Warn("pre_checkout received but the validator is not wired; declining", zap.String("order", q.InvoicePayload)) reason = fallbackDeclineText(q.From) } else if v, r, err := t.precheck(ctx, q.InvoicePayload, int64(q.TotalAmount), q.Currency); err != nil { t.log.Warn("pre_checkout validation failed; declining", zap.String("order", q.InvoicePayload), zap.Error(err)) reason = fallbackDeclineText(q.From) } else { ok, reason = v, r } params := &tgbot.AnswerPreCheckoutQueryParams{PreCheckoutQueryID: q.ID, OK: ok} if !ok { params.ErrorMessage = reason } if _, err := t.api.AnswerPreCheckoutQuery(ctx, params); err != nil { t.log.Warn("answer pre_checkout failed", zap.String("order", q.InvoicePayload), zap.Error(err)) } } // handleSuccessfulPayment records a completed Stars payment in the durable outbox and forwards it to // the gateway. Persisting first is the durability point: a crash before forwarding still re-drives // the payment on restart. The immediate forward is best-effort; the periodic drainer covers a // gateway or backend outage. func (t *Bot) handleSuccessfulPayment(ctx context.Context, msg *models.Message) { sp := msg.SuccessfulPayment if t.outbox == nil { t.log.Error("successful_payment received but the outbox is not wired; the payment is at risk", zap.String("charge", sp.TelegramPaymentChargeID)) return } var tgUserID int64 if msg.From != nil { tgUserID = msg.From.ID } rec := outbox.Record{ ChargeID: sp.TelegramPaymentChargeID, OrderID: sp.InvoicePayload, Amount: int64(sp.TotalAmount), UserID: tgUserID, } if err := t.outbox.Add(ctx, rec); err != nil { t.log.Error("outbox persist failed; the drainer cannot recover this payment", zap.String("charge", rec.ChargeID), zap.Error(err)) return } t.log.Info("stars payment received", zap.String("charge", rec.ChargeID), zap.String("order", rec.OrderID)) t.forwardOne(ctx, rec) } // RunPaymentDrainer re-drives undelivered outbox payments: once at startup (recovering any left by a // restart or a past outage) and then on paymentDrainInterval, until ctx is cancelled. It is a no-op // when the Stars rail is not wired. func (t *Bot) RunPaymentDrainer(ctx context.Context) { if t.outbox == nil || t.forward == nil { return } t.drainOutbox(ctx) ticker := time.NewTicker(paymentDrainInterval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: t.drainOutbox(ctx) } } } // drainOutbox forwards a batch of pending payments to the gateway. func (t *Bot) drainOutbox(ctx context.Context) { recs, err := t.outbox.Pending(ctx, 50) if err != nil { t.log.Warn("outbox drain read failed", zap.Error(err)) return } for _, rec := range recs { t.forwardOne(ctx, rec) } } // forwardOne delivers one payment to the gateway and, on a durable response (credited or not), marks // it forwarded so it is not re-sent. A transport error leaves the row for the next drain. Crediting is // idempotent at the backend, so a re-forward after a lost ack never double-credits. func (t *Bot) forwardOne(ctx context.Context, rec outbox.Record) { if t.forward == nil { return } credited, err := t.forward(ctx, rec.OrderID, rec.ChargeID, rec.Amount, rec.UserID) if err != nil { t.log.Warn("forward payment failed; will retry", zap.String("charge", rec.ChargeID), zap.Error(err)) return } if err := t.outbox.MarkForwarded(ctx, rec.ChargeID); err != nil { t.log.Error("mark forwarded failed", zap.String("charge", rec.ChargeID), zap.Error(err)) return } t.log.Info("stars payment forwarded", zap.String("charge", rec.ChargeID), zap.String("order", rec.OrderID), zap.Bool("credited", credited)) } // fallbackDeclineText is the pre_checkout decline message used only when the backend cannot be // reached to form a localised one; it falls back to the payer's Telegram client language. func fallbackDeclineText(from *models.User) string { if from != nil && from.LanguageCode == "ru" { return "Оплата временно недоступна. Попробуйте позже." } return "Payment is temporarily unavailable. Please try again later." }