6e03ce0131
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
Accept real money via Telegram Stars (XTR) — the third intake rail alongside Robokassa (direct) and VK Votes. Only the bot reaches Telegram, so the rail funnels through the reverse mTLS bot-link: - the gateway mints the invoice on a CreateInvoice command (the bot calls createInvoiceLink, XTR; the link goes to WebApp.openInvoice); - the bot gates each pre_checkout_query via a ValidatePreCheckout unary (the order must exist, be still creditable and not already paid — the reusable-invoice double-pay guard; the decline reason is localised to the order account's language); - a completed successful_payment is queued in a durable pure-Go SQLite outbox and forwarded via a ForwardPayment unary, credited once (idempotent on telegram_payment_charge_id, honours an expired order), re-driven on restart and every 30s. The rail is wired by TELEGRAM_STARS_OUTBOX_DIR (default /data) but stays inert until a chip pack carries an XTR price, so seeding a Stars price in the admin is the go-live. Tests: backend integration (order->forward->credit once, duplicate, pre_checkout gate) + bot outbox unit (idempotent, restart re-drive) + executor createInvoice. Docs: PAYMENTS(+ru) §9, ARCHITECTURE, the platform/telegram README, PLAN.
167 lines
6.9 KiB
Go
167 lines
6.9 KiB
Go
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."
|
|
}
|