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
+15 -6
View File
@@ -219,12 +219,21 @@ the amount, credits, marks `paid`. **Idempotency:** dedup by `(provider, provide
valid callback is **always** honoured, even on an expired order (`expired` ≠ cancellation —
the money is real, the chips are owed). The user sees only successful purchases.
**TG bot outbox.** `successful_payment` reaches the bot only (Bot API, not the Mini App), and
the bot host is weak and can lose connectivity, so the bot is a durable link. Store-and-
forward on **SQLite** on the bot's disk: receive → store → ack the Telegram update → forward
to payments (idempotent, dedup by `telegram_payment_charge_id`) → ack → mark `forwarded`.
Retries with backoff; re-drives undelivered on restart. At-least-once delivery + idempotent
intake = credited exactly once.
**TG Stars.** Only the **bot** reaches Telegram, so the whole rail funnels through the reverse
mTLS **bot-link** (bot ↔ gateway; the bot cannot dial the backend). The invoice is minted by the
bot: on the order path the gateway sends a `CreateInvoice` command and the bot returns a
`createInvoiceLink` (XTR) in its Ack, which the Mini App opens with `WebApp.openInvoice`. Before any
star moves the bot answers `pre_checkout_query` via a bot→gateway `ValidatePreCheckout` unary
(backed by the intake): approve only if the order exists, is still creditable and is **not already
paid** — a Stars invoice link is reusable, so this gate is the one place a repeat payment is stopped
before the charge; the decline reason is localised to the order account's language.
`successful_payment` reaches the bot only (Bot API, not the Mini App), and the bot host is weak and
can lose connectivity, so the bot is a durable link. Store-and-forward on **SQLite** on the bot's
disk (`internal/outbox`): persist on receipt (idempotent on `telegram_payment_charge_id`) → forward
over the bot-link (a `ForwardPayment` unary; the gateway proxies to the intake) → on a durable
response, mark `forwarded`. Re-drives undelivered on restart and on a periodic tick. At-least-once
delivery + idempotent intake (dedup by `telegram_payment_charge_id`) = credited exactly once.
**Events.** The payments domain writes `payment_events` (succeeded / failed / refunded); a
dispatcher fans out over channels — the live gRPC stream if the user is in-app, else the