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
+45 -4
View File
@@ -401,19 +401,60 @@ type walletOrderBody struct {
ProductID string `json:"product_id"`
}
// WalletOrderResp is a created order: its id and the provider launch URL the client opens.
// WalletOrderResp is a created order: its id and the rail's launch details. RedirectURL is the
// provider's hosted-payment URL (direct); Rail names the settling rail; for the Telegram Stars rail
// InvoiceTitle and InvoiceAmount (whole stars) are the invoice the gateway mints via the bot.
type WalletOrderResp 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"`
InvoiceAmount int64 `json:"invoice_amount"`
}
// WalletOrder opens a pending order to fund a chip pack and returns the provider launch URL.
// WalletOrder opens a pending order to fund a chip pack and returns the rail's launch details.
func (c *Client) WalletOrder(ctx context.Context, userID, productID string) (WalletOrderResp, error) {
var out WalletOrderResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/wallet/order", userID, "", walletOrderBody{ProductID: productID}, &out)
return out, err
}
// preCheckoutResp is the backend intake's pre_checkout answer.
type preCheckoutResp struct {
OK bool `json:"ok"`
Reason string `json:"reason"`
}
// ValidatePreCheckout asks the backend intake whether a Telegram Stars pre_checkout_query for
// orderID paying amount in currency may be approved, before the charge. It returns the approval and
// a short decline reason for the payer. It backs the bot's pre_checkout gate through the bot-link.
func (c *Client) ValidatePreCheckout(ctx context.Context, orderID string, amount int64, currency string) (bool, string, error) {
var out preCheckoutResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/telegram/precheckout", "", "",
map[string]any{"order_id": orderID, "amount": amount, "currency": currency}, &out)
return out.OK, out.Reason, err
}
// telegramPaymentResp is the backend intake's credit outcome.
type telegramPaymentResp struct {
Credited bool `json:"credited"`
}
// TelegramPayment forwards a completed Telegram Stars payment from the bot's outbox to the backend
// intake, which credits the order idempotently on chargeID. It reports whether the order was
// credited (or already had been); a transport error is a transient failure the bot retries.
func (c *Client) TelegramPayment(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (bool, error) {
var out telegramPaymentResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/telegram/payment", "", "",
map[string]any{
"order_id": orderID,
"telegram_payment_charge_id": chargeID,
"amount": amount,
"telegram_user_id": telegramUserID,
}, &out)
return out.Credited, err
}
// robokassaResultResp is the backend intake's reply: the body to echo back to Robokassa.
type robokassaResultResp struct {
Response string `json:"response"`