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:
@@ -153,6 +153,16 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
r, rerr := backend.ChatEligibility(ctx, externalID)
|
||||
return r.Registered, r.Eligible, rerr
|
||||
})
|
||||
// The Telegram Stars payment bridge rides the same bot-link: the bot validates each
|
||||
// pre_checkout and forwards each completed payment through these, backed by the backend intake.
|
||||
botHub.SetPaymentBridge(
|
||||
func(ctx context.Context, orderID string, amount int64, currency string) (bool, string, error) {
|
||||
return backend.ValidatePreCheckout(ctx, orderID, amount, currency)
|
||||
},
|
||||
func(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (bool, error) {
|
||||
return backend.TelegramPayment(ctx, orderID, chargeID, amount, telegramUserID)
|
||||
},
|
||||
)
|
||||
tlsCfg, terr := mtls.ServerConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile)
|
||||
if terr != nil {
|
||||
return terr
|
||||
@@ -199,7 +209,12 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
vkidExchanger = vkid.New(cfg.VKID.AppID, cfg.VKID.ClientSecret, cfg.VKID.RedirectURI)
|
||||
logger.Info("vk id web login enabled")
|
||||
}
|
||||
registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret), transcode.WithVKLink(vkidExchanger))
|
||||
regOpts := []transcode.Option{transcode.WithVKAuth(cfg.VKAppSecret), transcode.WithVKLink(vkidExchanger)}
|
||||
if botHub != nil {
|
||||
// A Telegram-context wallet order mints its Stars invoice link through the connected bot.
|
||||
regOpts = append(regOpts, transcode.WithTelegramStars(botHub))
|
||||
}
|
||||
registry := transcode.NewRegistry(backend, validator, regOpts...)
|
||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||
Registry: registry,
|
||||
Sessions: sessions,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -48,3 +48,18 @@ func ChatGateCommand(externalID string, allow bool) *botlinkv1.Command {
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// CreateInvoiceCommand builds a Telegram Stars invoice-mint command: the bot calls
|
||||
// createInvoiceLink (in XTR) with the given title and description, the order id as the
|
||||
// payload (echoed by Telegram in pre_checkout and successful_payment) and amountStars whole
|
||||
// stars, and returns the link in its Ack result.
|
||||
func CreateInvoiceCommand(title, description, orderID string, amountStars int64) *botlinkv1.Command {
|
||||
return &botlinkv1.Command{
|
||||
Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
|
||||
Title: title,
|
||||
Description: description,
|
||||
Payload: orderID,
|
||||
Amount: amountStars,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
@@ -31,6 +32,11 @@ var ErrNoBot = errors.New("botlink: no bot connected")
|
||||
// (at-most-once under backpressure).
|
||||
const outboundBuffer = 64
|
||||
|
||||
// invoiceMintTimeout bounds one synchronous invoice-mint round-trip (the gateway commands the bot
|
||||
// and awaits the Ack carrying the createInvoiceLink result), so a hung or slow bot cannot stall the
|
||||
// caller's wallet-order request indefinitely.
|
||||
const invoiceMintTimeout = 15 * time.Second
|
||||
|
||||
// EligibilityResolver answers a Telegram identity's moderated-chat write eligibility
|
||||
// for the bot's join-time ResolveChatEligibility query: registered reports whether the
|
||||
// identity maps to an account, eligible is the final gate the bot acts on (registered
|
||||
@@ -38,6 +44,17 @@ const outboundBuffer = 64
|
||||
// chat-access endpoint.
|
||||
type EligibilityResolver func(ctx context.Context, externalID string) (registered, eligible bool, err error)
|
||||
|
||||
// PreCheckoutResolver validates a Telegram Stars pre_checkout_query for the bot's
|
||||
// ValidatePreCheckout query: it answers whether the order may still be charged (ok) and, when not,
|
||||
// a short reason to show the payer. The gateway backs it with the backend intake.
|
||||
type PreCheckoutResolver func(ctx context.Context, orderID string, amount int64, currency string) (ok bool, reason string, err error)
|
||||
|
||||
// PaymentForwarder delivers a completed Telegram Stars payment for the bot's ForwardPayment call:
|
||||
// it credits the order through the backend intake and reports whether it was credited (or already
|
||||
// had been). A non-nil error is a transient failure the bot retries. The gateway backs it with the
|
||||
// backend intake.
|
||||
type PaymentForwarder func(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (credited bool, err error)
|
||||
|
||||
// Hub registers connected bots and routes send commands to them. A single bot is
|
||||
// expected today; the registry already holds a set so adding more later needs no
|
||||
// rewrite.
|
||||
@@ -46,6 +63,10 @@ type Hub struct {
|
||||
|
||||
log *zap.Logger
|
||||
eligibility EligibilityResolver
|
||||
// precheck and forward back the Telegram Stars payment RPCs; nil until SetPaymentBridge wires
|
||||
// them, in which case the RPCs report Unavailable. Set once before the gRPC server serves.
|
||||
precheck PreCheckoutResolver
|
||||
forward PaymentForwarder
|
||||
|
||||
mu sync.Mutex
|
||||
links map[*link]struct{}
|
||||
@@ -147,6 +168,105 @@ func (h *Hub) ResolveChatEligibility(ctx context.Context, req *botlinkv1.ChatEli
|
||||
return &botlinkv1.ChatEligibilityResponse{Registered: registered, Eligible: eligible}, nil
|
||||
}
|
||||
|
||||
// SetPaymentBridge wires the Telegram Stars payment resolvers, backing ValidatePreCheckout and
|
||||
// ForwardPayment. It must be called before the gRPC server starts serving (the fields are read
|
||||
// without a lock, relying on that happens-before). A nil resolver leaves its RPC reporting
|
||||
// Unavailable.
|
||||
func (h *Hub) SetPaymentBridge(precheck PreCheckoutResolver, forward PaymentForwarder) {
|
||||
h.precheck = precheck
|
||||
h.forward = forward
|
||||
}
|
||||
|
||||
// ValidatePreCheckout serves the bot's pre_checkout validation over the same mTLS channel: it
|
||||
// delegates to the configured resolver (the backend intake) and returns whether the order may be
|
||||
// charged. A resolver failure maps to Internal, which the bot treats as a decline (fail-closed).
|
||||
func (h *Hub) ValidatePreCheckout(ctx context.Context, req *botlinkv1.PreCheckoutRequest) (*botlinkv1.PreCheckoutResponse, error) {
|
||||
if h.precheck == nil {
|
||||
return nil, status.Error(codes.Unavailable, "payment bridge not configured")
|
||||
}
|
||||
ok, reason, err := h.precheck(ctx, req.GetOrderId(), req.GetAmount(), req.GetCurrency())
|
||||
if err != nil {
|
||||
h.log.Warn("validate pre_checkout failed", zap.String("order", req.GetOrderId()), zap.Error(err))
|
||||
return nil, status.Error(codes.Internal, "validate pre_checkout")
|
||||
}
|
||||
return &botlinkv1.PreCheckoutResponse{Ok: ok, Reason: reason}, nil
|
||||
}
|
||||
|
||||
// ForwardPayment serves the bot's completed-payment delivery: it credits the order through the
|
||||
// configured forwarder (the backend intake) and reports the durable outcome. A forwarder failure
|
||||
// maps to Internal, which the bot treats as transient and retries; a clean response (credited true
|
||||
// or false) lets the bot forget the outbox row.
|
||||
func (h *Hub) ForwardPayment(ctx context.Context, req *botlinkv1.ForwardPaymentRequest) (*botlinkv1.ForwardPaymentResponse, error) {
|
||||
if h.forward == nil {
|
||||
return nil, status.Error(codes.Unavailable, "payment bridge not configured")
|
||||
}
|
||||
credited, err := h.forward(ctx, req.GetOrderId(), req.GetTelegramPaymentChargeId(), req.GetAmount(), req.GetTelegramUserId())
|
||||
if err != nil {
|
||||
h.log.Warn("forward telegram payment failed", zap.String("order", req.GetOrderId()), zap.Error(err))
|
||||
return nil, status.Error(codes.Internal, "forward payment")
|
||||
}
|
||||
return &botlinkv1.ForwardPaymentResponse{Credited: credited}, nil
|
||||
}
|
||||
|
||||
// MintInvoice commands the connected bot to mint a Telegram Stars invoice link for the order and
|
||||
// returns the link. It is a bounded, synchronous round-trip (invoiceMintTimeout): no bot connected
|
||||
// returns ErrNoBot, and a bot error or an empty link is an error the caller surfaces as a failed
|
||||
// order launch.
|
||||
func (h *Hub) MintInvoice(ctx context.Context, title, description, orderID string, amountStars int64) (string, error) {
|
||||
cctx, cancel := context.WithTimeout(ctx, invoiceMintTimeout)
|
||||
defer cancel()
|
||||
ack, err := h.sendAwaitAck(cctx, CreateInvoiceCommand(title, description, orderID, amountStars))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ack.GetResult() == "" {
|
||||
return "", errors.New("botlink: bot returned an empty invoice link")
|
||||
}
|
||||
return ack.GetResult(), nil
|
||||
}
|
||||
|
||||
// sendAwaitAck enqueues a command and waits for the bot's full Ack (or ctx). It mirrors SendAwait
|
||||
// but returns the Ack — the invoice-mint path needs its result field — and reports ctx expiry as an
|
||||
// error rather than a not-delivered, since a timed-out mint has no link to return.
|
||||
func (h *Hub) sendAwaitAck(ctx context.Context, cmd *botlinkv1.Command) (*botlinkv1.Ack, error) {
|
||||
l, ok := h.pick()
|
||||
if !ok {
|
||||
h.count("dropped")
|
||||
return nil, ErrNoBot
|
||||
}
|
||||
id := h.nextID()
|
||||
cmd.CommandId = id
|
||||
ackc := make(chan *botlinkv1.Ack, 1)
|
||||
h.mu.Lock()
|
||||
h.pending[id] = ackc
|
||||
h.mu.Unlock()
|
||||
defer func() {
|
||||
h.mu.Lock()
|
||||
delete(h.pending, id)
|
||||
h.mu.Unlock()
|
||||
}()
|
||||
|
||||
select {
|
||||
case l.out <- &botlinkv1.ToBot{Command: cmd}:
|
||||
case <-ctx.Done():
|
||||
h.count("dropped")
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
select {
|
||||
case ack := <-ackc:
|
||||
if e := ack.GetError(); e != "" {
|
||||
h.count("error")
|
||||
return nil, errors.New(e)
|
||||
}
|
||||
h.count(deliveredLabel(ack.GetDelivered()))
|
||||
return ack, nil
|
||||
case <-ctx.Done():
|
||||
h.count("error")
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// register adds a connected bot.
|
||||
func (h *Hub) register(l *link) {
|
||||
h.mu.Lock()
|
||||
|
||||
@@ -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