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:
@@ -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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user