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