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

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:
Ilia Denisov
2026-07-09 21:35:29 +02:00
parent 5612bb624d
commit 6e03ce0131
32 changed files with 1830 additions and 94 deletions
@@ -129,6 +129,101 @@ func TestPaymentsVKOrderFundCredits(t *testing.T) {
}
}
// TestPaymentsTelegramStarsRail exercises the Telegram Stars rail over Postgres: an order prices the
// pack in whole stars (XTR); a pre_checkout on the pending order is approved; the forwarded payment
// credits the telegram segment exactly once (idempotent on the Telegram charge id); and a
// pre_checkout after the order is paid is declined (a reusable invoice link paid twice).
func TestPaymentsTelegramStarsRail(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 50, methodPrice{method: "telegram", currency: "XTR", amount: 40}) // 40 stars fund 50 chips
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("telegram", "android"), []payments.Source{payments.SourceTelegram}, prod, "telegram")
if err != nil {
t.Fatalf("create telegram order: %v", err)
}
if res.Amount.Currency() != payments.CurrencyStar || res.Amount.Minor() != 40 {
t.Fatalf("telegram order amount = %s, want 40 XTR", res.Amount)
}
// pre_checkout on the pending order: approved, and it reports the account for reason localisation.
starAmt, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
pc, err := svc.ValidatePreCheckout(ctx, res.OrderID, starAmt)
if err != nil {
t.Fatalf("pre_checkout: %v", err)
}
if !pc.OK || pc.AccountID != acc {
t.Fatalf("pre_checkout = %+v, want approved for account %s", pc, acc)
}
// The forwarded successful_payment credits once, idempotent on the Telegram charge id.
out, err := svc.Fund(ctx, res.OrderID, "telegram", "tg-charge-1", starAmt)
if err != nil {
t.Fatalf("telegram fund: %v", err)
}
if out.AlreadyCredited || out.Chips != 50 || out.Source != payments.SourceTelegram {
t.Fatalf("telegram fund outcome = %+v, want 50 chips credited to telegram", out)
}
if got := readBalance(t, acc, "telegram"); got != 50 {
t.Errorf("telegram balance after fund = %d, want 50", got)
}
// A retried forward (same charge id, e.g. a lost ack) credits nothing more.
out2, err := svc.Fund(ctx, res.OrderID, "telegram", "tg-charge-1", starAmt)
if err != nil {
t.Fatalf("duplicate telegram fund: %v", err)
}
if !out2.AlreadyCredited {
t.Error("retried forward not flagged AlreadyCredited")
}
if got := readBalance(t, acc, "telegram"); got != 50 {
t.Errorf("telegram balance after retry = %d, want 50 (credited once)", got)
}
// pre_checkout after the order is paid: declined (the reusable-link double-pay guard).
pc2, err := svc.ValidatePreCheckout(ctx, res.OrderID, starAmt)
if err != nil {
t.Fatalf("pre_checkout after paid: %v", err)
}
if pc2.OK || pc2.Reason != payments.PreCheckoutAlreadyPaid {
t.Errorf("pre_checkout after paid = %+v, want a decline with already_paid", pc2)
}
}
// TestPaymentsTelegramPreCheckoutDeclines covers the pre_checkout decline reasons: an unknown order
// and an amount that no longer matches.
func TestPaymentsTelegramPreCheckoutDeclines(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
starAmt, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
// An unknown order id declines as gone, with no account to localise against.
pc, err := svc.ValidatePreCheckout(ctx, uuid.New(), starAmt)
if err != nil {
t.Fatalf("pre_checkout unknown: %v", err)
}
if pc.OK || pc.Reason != payments.PreCheckoutGone || pc.AccountID != (uuid.UUID{}) {
t.Errorf("pre_checkout unknown = %+v, want a gone decline with no account", pc)
}
// A pending order validated at the wrong amount declines as price-changed.
acc := uuid.New()
prod := seedPackProduct(t, 100, methodPrice{method: "telegram", currency: "XTR", amount: 80})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("telegram", "android"), []payments.Source{payments.SourceTelegram}, prod, "telegram")
if err != nil {
t.Fatalf("create telegram order: %v", err)
}
wrong, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
pc2, err := svc.ValidatePreCheckout(ctx, res.OrderID, wrong)
if err != nil {
t.Fatalf("pre_checkout mismatch: %v", err)
}
if pc2.OK || pc2.Reason != payments.PreCheckoutPriceChanged {
t.Errorf("pre_checkout mismatch = %+v, want a price_changed decline", pc2)
}
}
// TestPaymentsFundAmountMismatch verifies a callback whose paid amount does not match the order is
// refused and credits nothing (§9: verify the amount after matching by order id).
func TestPaymentsFundAmountMismatch(t *testing.T) {
@@ -2,6 +2,7 @@ package payments
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
@@ -80,6 +81,49 @@ func (s *Service) Fund(ctx context.Context, orderID uuid.UUID, provider, provide
return s.store.fund(ctx, orderID, provider, providerPaymentID, paid, s.clock())
}
// Pre-checkout decline reason codes. They are language-neutral: the transport layer localises them
// to the order account's preferred language before showing the payer (the reason is displayed in the
// Telegram payment sheet).
const (
// PreCheckoutGone means no order matches — an unknown or stale invoice payload.
PreCheckoutGone = "order_gone"
// PreCheckoutAlreadyPaid means the order is already paid (a reusable invoice link paid twice).
PreCheckoutAlreadyPaid = "already_paid"
// PreCheckoutPriceChanged means the amount or currency no longer matches the order.
PreCheckoutPriceChanged = "price_changed"
)
// PreCheckoutOutcome is the pre-charge validation of a Telegram Stars order. OK approves the charge;
// otherwise Reason is a decline reason code the transport localises. AccountID is the order's account
// (for localising the reason to its preferred language); it is the zero UUID when the order is unknown.
type PreCheckoutOutcome struct {
OK bool
Reason string
AccountID uuid.UUID
}
// ValidatePreCheckout answers whether a Stars pre_checkout_query for orderID paying amount may be
// approved, before any star is charged. It approves an order that exists, is not already paid (a
// reusable invoice link paid a second time is refused here) and whose expected amount and currency
// match the invoice. A pending or honoured-expired order is approved — a late credit is honoured
// (§9/D23). A missing order or a mismatch is a clean decline with a reason code, not an error.
func (s *Service) ValidatePreCheckout(ctx context.Context, orderID uuid.UUID, amount Money) (PreCheckoutOutcome, error) {
ord, err := s.store.orderByID(ctx, orderID)
if errors.Is(err, ErrOrderNotFound) {
return PreCheckoutOutcome{OK: false, Reason: PreCheckoutGone}, nil
}
if err != nil {
return PreCheckoutOutcome{}, err
}
if ord.status == "paid" {
return PreCheckoutOutcome{OK: false, Reason: PreCheckoutAlreadyPaid, AccountID: ord.accountID}, nil
}
if amount.Currency() != Currency(ord.currency) || amount.Minor() != ord.expectedAmount {
return PreCheckoutOutcome{OK: false, Reason: PreCheckoutPriceChanged, AccountID: ord.accountID}, nil
}
return PreCheckoutOutcome{OK: true, AccountID: ord.accountID}, nil
}
// ExpireOrders marks pending orders older than the configured lifetime as expired, returning how
// many were swept. It backs the periodic pending reaper; expiry is cosmetic (a late valid callback
// still credits — see Fund).
+4
View File
@@ -80,6 +80,10 @@ func (s *Server) registerRoutes() {
// the Robokassa Result callback when a merchant is configured.
u.POST("/wallet/order", s.handleWalletOrder)
s.internal.POST("/payments/vk/callback", s.handleVKCallback)
// The Telegram Stars rail: the bot forwards a pre_checkout validation and a completed
// payment over the reverse bot-link; the gateway proxies both onto these gateway-only routes.
s.internal.POST("/payments/telegram/precheckout", s.handleTelegramPreCheckout)
s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment)
if s.robokassa.MerchantLogin != "" {
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
}
+180 -8
View File
@@ -18,6 +18,7 @@ import (
const (
providerRobokassa = "robokassa" // the direct (RUB) rail
providerVK = "vk" // the VK Votes rail
providerTelegram = "telegram" // the Telegram Stars (XTR) rail
)
// walletOrderRequest is the POST body of a chip-pack purchase: the pack to fund.
@@ -25,16 +26,25 @@ type walletOrderRequest struct {
ProductID string `json:"product_id"`
}
// walletOrderResponse returns the created order id and the provider launch URL the client opens.
// walletOrderResponse returns the created order id and the rail's launch details. RedirectURL is
// the provider's hosted-payment URL for the direct rail (empty for VK/Telegram, which settle
// in-app). Rail names the settling rail so the gateway knows how to launch it; for the Telegram
// Stars rail the gateway mints the invoice link from InvoiceTitle and InvoiceAmount (whole stars)
// via the bot and returns it in RedirectURL.
type walletOrderResponse struct {
OrderID string `json:"order_id"`
RedirectURL string `json:"redirect_url"`
OrderID string `json:"order_id"`
RedirectURL string `json:"redirect_url"`
Rail string `json:"rail"`
InvoiceTitle string `json:"invoice_title,omitempty"`
InvoiceAmount int64 `json:"invoice_amount,omitempty"`
}
// handleWalletOrder opens a pending order to fund a chip pack and returns the Robokassa
// hosted-payment URL for the client to open. It is direct-rail only for now (VK/TG land later);
// it enforces the wallet gate and D36 (a direct purchase requires a confirmed email anchor). No
// chips are credited here — only later, by the verified Result callback.
// handleWalletOrder opens a pending order to fund a chip pack and returns the rail's launch
// details for the client: the Robokassa hosted-payment URL (direct), the order id for
// VKWebAppShowOrderBox (VK), or the pack title and star amount the gateway mints into a Stars
// invoice link (Telegram). It enforces the wallet gate and, on the direct rail, D36 (a purchase
// requires a confirmed email anchor). No chips are credited here — only later, by the verified
// provider callback.
func (s *Server) handleWalletOrder(c *gin.Context) {
uid, ok := userID(c)
if !ok {
@@ -80,6 +90,7 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
c.JSON(http.StatusOK, walletOrderResponse{
OrderID: res.OrderID.String(),
RedirectURL: s.robokassa.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
Rail: providerRobokassa,
})
case payments.SourceVK:
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerVK)
@@ -88,7 +99,23 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
return
}
// The client passes the order id to VKWebAppShowOrderBox as the item; there is no redirect.
c.JSON(http.StatusOK, walletOrderResponse{OrderID: res.OrderID.String()})
c.JSON(http.StatusOK, walletOrderResponse{OrderID: res.OrderID.String(), Rail: providerVK})
case payments.SourceTelegram:
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerTelegram)
if err != nil {
s.abortErr(c, err)
return
}
// The gateway mints the Stars invoice link from the title and amount via the bot (only
// the bot reaches Telegram) and returns it to the client as RedirectURL; the amount is in
// whole stars (the XTR minor unit is the star). No chips are credited until the verified
// successful_payment is forwarded back through the bot.
c.JSON(http.StatusOK, walletOrderResponse{
OrderID: res.OrderID.String(),
Rail: providerTelegram,
InvoiceTitle: res.Title,
InvoiceAmount: res.Amount.Minor(),
})
default:
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available yet"}})
}
@@ -225,3 +252,148 @@ func (s *Server) handleVKCallback(c *gin.Context) {
c.JSON(http.StatusOK, vkErrorResponse(100, "unknown notification", false))
}
}
// telegramPreCheckoutRequest is the bot's pre_checkout validation, forwarded through the gateway:
// the order in the invoice payload and the amount and currency Telegram is about to charge.
type telegramPreCheckoutRequest struct {
OrderID string `json:"order_id"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
}
// telegramPreCheckoutResponse tells the bot whether to approve the pre_checkout_query; Reason is a
// short message the bot surfaces to the payer on a decline.
type telegramPreCheckoutResponse struct {
OK bool `json:"ok"`
Reason string `json:"reason,omitempty"`
}
// handleTelegramPreCheckout validates a Telegram Stars pre_checkout_query before the charge,
// reached only through the gateway (the bot's ValidatePreCheckout, forwarded on the internal
// route). It approves an order that exists, is not already paid (a reusable invoice link paid twice
// is refused here, before any star moves) and whose amount and currency match. A malformed
// reference is a decline, not an error.
func (s *Server) handleTelegramPreCheckout(c *gin.Context) {
var req telegramPreCheckoutRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
ctx := c.Request.Context()
orderID, err := uuid.Parse(req.OrderID)
if err != nil {
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: false, Reason: telegramDeclineText(payments.PreCheckoutGone, "")})
return
}
amount, err := payments.MoneyFromMinor(req.Amount, payments.Currency(req.Currency))
if err != nil {
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: false, Reason: telegramDeclineText(payments.PreCheckoutGone, "")})
return
}
out, err := s.payments.ValidatePreCheckout(ctx, orderID, amount)
if err != nil {
s.log.Error("telegram pre_checkout validate failed", zap.String("order", orderID.String()), zap.Error(err))
c.AbortWithStatusJSON(http.StatusInternalServerError, errorResponse{Error: errorBody{Code: "internal", Message: "internal error"}})
return
}
reason := ""
if !out.OK {
// Localise the decline to the order account's preferred language (the reason shows in the
// Telegram payment sheet). An unknown order has no account, so it falls back to English.
lang := ""
if s.accounts != nil && out.AccountID != (uuid.UUID{}) {
if acc, aerr := s.accounts.GetByID(ctx, out.AccountID); aerr == nil {
lang = acc.PreferredLanguage
}
}
reason = telegramDeclineText(out.Reason, lang)
}
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: out.OK, Reason: reason})
}
// telegramDeclineText renders a pre-checkout decline reason code in the payer's language (ru or
// anything else falls back to English), for display in the Telegram payment sheet.
func telegramDeclineText(code, lang string) string {
ru := lang == "ru"
switch code {
case payments.PreCheckoutAlreadyPaid:
if ru {
return "Этот заказ уже оплачен."
}
return "This order has already been paid."
case payments.PreCheckoutPriceChanged:
if ru {
return "Цена изменилась — начните покупку заново."
}
return "The price has changed; please start the purchase again."
default:
if ru {
return "Этот заказ больше недоступен."
}
return "This order is no longer available."
}
}
// telegramPaymentRequest is a completed Stars payment forwarded from the bot's outbox through the
// gateway: the order, the Telegram charge id (the idempotency key), the stars paid, and the payer.
type telegramPaymentRequest struct {
OrderID string `json:"order_id"`
TelegramPaymentChargeID string `json:"telegram_payment_charge_id"`
Amount int64 `json:"amount"`
TelegramUserID int64 `json:"telegram_user_id"`
}
// telegramPaymentResponse reports the durable outcome to the bot: Credited is true once the order
// is credited (or already was). A false with a 200 means the payment was recorded but not creditable
// (the bot drops it); a 5xx means a transient failure the bot retries.
type telegramPaymentResponse struct {
Credited bool `json:"credited"`
}
// handleTelegramPayment credits a completed Telegram Stars payment, reached only through the gateway
// (the bot's ForwardPayment, forwarded on the internal route). It credits the matched order exactly
// once (idempotent on the Telegram charge id, honoured even if the order expired) and records a
// succeeded event. A permanent rejection (unknown order, amount mismatch) is answered 200 with
// Credited=false so the bot stops retrying a payment it cannot place; a transient failure is a 5xx
// the bot retries.
func (s *Server) handleTelegramPayment(c *gin.Context) {
var req telegramPaymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
orderID, err := uuid.Parse(req.OrderID)
if err != nil {
s.log.Warn("telegram payment: bad order id", zap.String("charge", req.TelegramPaymentChargeID))
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
return
}
paid, err := payments.MoneyFromMinor(req.Amount, payments.CurrencyStar)
if err != nil {
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
return
}
ctx := c.Request.Context()
outcome, err := s.payments.Fund(ctx, orderID, providerTelegram, req.TelegramPaymentChargeID, paid)
if err != nil {
if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) {
// The star charge already happened but the order cannot be placed; record it loudly for
// an operator and tell the bot to stop retrying (an operator refunds or credits by hand).
s.log.Error("telegram payment rejected (charge taken, not credited)",
zap.String("order", orderID.String()), zap.String("charge", req.TelegramPaymentChargeID), zap.Error(err))
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
return
}
s.log.Error("telegram fund failed", zap.String("order", orderID.String()), zap.Error(err))
c.AbortWithStatusJSON(http.StatusInternalServerError, errorResponse{Error: errorBody{Code: "internal", Message: "internal error"}})
return
}
if !outcome.AlreadyCredited {
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
s.log.Error("record telegram payment event failed", zap.String("order", orderID.String()), zap.Error(err))
}
}
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: true})
}