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:
@@ -112,7 +112,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, opts ...Op
|
||||
r.ops[MsgWalletGet] = Op{Handler: walletHandler(backend), Auth: true}
|
||||
r.ops[MsgWalletCatalog] = Op{Handler: walletCatalogHandler(backend), Auth: true}
|
||||
r.ops[MsgWalletBuy] = Op{Handler: walletBuyHandler(backend), Auth: true}
|
||||
r.ops[MsgWalletOrder] = Op{Handler: walletOrderHandler(backend), Auth: true}
|
||||
r.ops[MsgWalletOrder] = Op{Handler: walletOrderHandler(backend, nil), Auth: true}
|
||||
r.ops[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true}
|
||||
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true}
|
||||
r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true}
|
||||
@@ -170,6 +170,17 @@ func WithVKLink(ex VKIDExchanger) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithTelegramStars re-registers the wallet.order op with a Telegram Stars invoice minter (the
|
||||
// bot-link), so a Telegram-context order mints its Stars invoice link through the bot. A nil minter
|
||||
// is a no-op, leaving the default (Stars-unavailable) handler in place.
|
||||
func WithTelegramStars(minter InvoiceMinter) Option {
|
||||
return func(r *Registry, backend *backendclient.Client) {
|
||||
if minter != nil {
|
||||
r.ops[MsgWalletOrder] = Op{Handler: walletOrderHandler(backend, minter), Auth: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup returns the operation for messageType, and whether it is registered.
|
||||
func (r *Registry) Lookup(messageType string) (Op, bool) {
|
||||
op, ok := r.ops[messageType]
|
||||
@@ -333,13 +344,37 @@ func walletBuyHandler(backend *backendclient.Client) Handler {
|
||||
}
|
||||
}
|
||||
|
||||
func walletOrderHandler(backend *backendclient.Client) Handler {
|
||||
// InvoiceMinter mints a Telegram Stars invoice link for a created order via the bot (only the bot
|
||||
// reaches Telegram). The gateway bot-link Hub implements it; it is nil where the bot-link is off,
|
||||
// which leaves the Telegram rail unavailable.
|
||||
type InvoiceMinter interface {
|
||||
MintInvoice(ctx context.Context, title, description, orderID string, amountStars int64) (string, error)
|
||||
}
|
||||
|
||||
// errTelegramStarsUnavailable is returned when a Telegram Stars order is requested but no invoice
|
||||
// minter (the bot-link) is wired — the rail is not available on this gateway.
|
||||
var errTelegramStarsUnavailable = errors.New("telegram stars rail unavailable")
|
||||
|
||||
func walletOrderHandler(backend *backendclient.Client, minter InvoiceMinter) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsWalletOrderRequest(req.Payload, 0)
|
||||
o, err := backend.WalletOrder(ctx, req.UserID, string(in.ProductId()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Telegram Stars: mint the invoice link here via the bot-link and return it to the client
|
||||
// as the redirect URL it opens with WebApp.openInvoice. The title doubles as the invoice
|
||||
// description (both are required and the pack title is the whole product).
|
||||
if o.Rail == "telegram" {
|
||||
if minter == nil {
|
||||
return nil, errTelegramStarsUnavailable
|
||||
}
|
||||
link, merr := minter.MintInvoice(ctx, o.InvoiceTitle, o.InvoiceTitle, o.OrderID, o.InvoiceAmount)
|
||||
if merr != nil {
|
||||
return nil, merr
|
||||
}
|
||||
o.RedirectURL = link
|
||||
}
|
||||
return encodeWalletOrder(o), nil
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user