feat(payments): Telegram Stars payment rail
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
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.
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"scrabble/platform/telegram/internal/outbox"
|
||||
"scrabble/platform/telegram/internal/support"
|
||||
)
|
||||
|
||||
@@ -49,6 +50,10 @@ type Config struct {
|
||||
// SupportStore persists the support relay's state (topic mapping, block list,
|
||||
// relayed message ids); required when SupportChatID is set, ignored otherwise.
|
||||
SupportStore *support.Store
|
||||
// AcceptPayments enables the Telegram Stars rail: the bot then subscribes to
|
||||
// pre_checkout_query updates and handles pre_checkout / successful_payment. The runtime
|
||||
// dependencies (the validator, forwarder and outbox) are wired with SetPaymentHandlers.
|
||||
AcceptPayments bool
|
||||
}
|
||||
|
||||
// EligibilityResolver answers whether the Telegram user identified by externalID
|
||||
@@ -91,6 +96,12 @@ type Bot struct {
|
||||
supportLocks *keyedMutex
|
||||
// admins caches the support chat's administrator ids (who may reply and act).
|
||||
admins *adminCache
|
||||
// precheck validates a Stars pre_checkout order and forward delivers a completed payment; both
|
||||
// are late-bound (SetPaymentHandlers) over the bot-link, which is built after the bot. outbox
|
||||
// durably records completed payments before they are forwarded. All nil when the Stars rail is off.
|
||||
precheck PreCheckoutValidator
|
||||
forward PaymentForwarder
|
||||
outbox *outbox.Store
|
||||
}
|
||||
|
||||
// New builds the bot wrapper, registering the /start handler and a default handler
|
||||
@@ -123,9 +134,10 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
||||
// callback handler by their "sup:" data prefix.
|
||||
opts = append(opts, tgbot.WithCallbackQueryDataHandler(supportCallbackPrefix, tgbot.MatchTypePrefix, t.handleSupportCallback))
|
||||
}
|
||||
// Allowed updates default to "all except chat_member". Specify an explicit set only
|
||||
// when we need chat_member (moderated chat) — and then re-add callback_query (which
|
||||
// the explicit set would otherwise drop) when the support relay needs it.
|
||||
// Allowed updates default to "all except chat_member" (which already includes
|
||||
// pre_checkout_query and message-borne successful_payment). Specify an explicit set only when we
|
||||
// need chat_member (moderated chat) — and then re-add callback_query and, for the Stars rail,
|
||||
// pre_checkout_query, which the explicit set would otherwise drop.
|
||||
if cfg.ChatID != 0 {
|
||||
allowed := tgbot.AllowedUpdates{
|
||||
models.AllowedUpdateMessage,
|
||||
@@ -135,6 +147,9 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
||||
if t.supportEnabled() {
|
||||
allowed = append(allowed, models.AllowedUpdateCallbackQuery)
|
||||
}
|
||||
if cfg.AcceptPayments {
|
||||
allowed = append(allowed, models.AllowedUpdatePreCheckoutQuery)
|
||||
}
|
||||
opts = append(opts, tgbot.WithAllowedUpdates(allowed))
|
||||
}
|
||||
if cfg.TestEnv {
|
||||
@@ -346,6 +361,17 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
|
||||
t.handleChatMember(ctx, update.ChatMember)
|
||||
return
|
||||
}
|
||||
// Telegram Stars: the pre_checkout gate (validated against the backend) and the completed
|
||||
// payment (persisted to the outbox and forwarded) — before the support relay, so a payment
|
||||
// message is never mistaken for a support DM or given a launch reply.
|
||||
if update.PreCheckoutQuery != nil {
|
||||
t.handlePreCheckout(ctx, update.PreCheckoutQuery)
|
||||
return
|
||||
}
|
||||
if update.Message != nil && update.Message.SuccessfulPayment != nil {
|
||||
t.handleSuccessfulPayment(ctx, update.Message)
|
||||
return
|
||||
}
|
||||
// Support relay (when enabled): a non-/start message — /start has its own handler —
|
||||
// is either an operator's reply in the support chat or a user's direct message to
|
||||
// relay. Everything else falls through to the Mini App launch reply.
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
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."
|
||||
}
|
||||
Reference in New Issue
Block a user