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:
@@ -129,6 +129,101 @@ func TestPaymentsVKOrderFundCredits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsTelegramStarsRail exercises the Telegram Stars rail over Postgres: an order prices the
|
||||
// pack in whole stars (XTR); a pre_checkout on the pending order is approved; the forwarded payment
|
||||
// credits the telegram segment exactly once (idempotent on the Telegram charge id); and a
|
||||
// pre_checkout after the order is paid is declined (a reusable invoice link paid twice).
|
||||
func TestPaymentsTelegramStarsRail(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, 50, methodPrice{method: "telegram", currency: "XTR", amount: 40}) // 40 stars fund 50 chips
|
||||
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("telegram", "android"), []payments.Source{payments.SourceTelegram}, prod, "telegram")
|
||||
if err != nil {
|
||||
t.Fatalf("create telegram order: %v", err)
|
||||
}
|
||||
if res.Amount.Currency() != payments.CurrencyStar || res.Amount.Minor() != 40 {
|
||||
t.Fatalf("telegram order amount = %s, want 40 XTR", res.Amount)
|
||||
}
|
||||
|
||||
// pre_checkout on the pending order: approved, and it reports the account for reason localisation.
|
||||
starAmt, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
|
||||
pc, err := svc.ValidatePreCheckout(ctx, res.OrderID, starAmt)
|
||||
if err != nil {
|
||||
t.Fatalf("pre_checkout: %v", err)
|
||||
}
|
||||
if !pc.OK || pc.AccountID != acc {
|
||||
t.Fatalf("pre_checkout = %+v, want approved for account %s", pc, acc)
|
||||
}
|
||||
|
||||
// The forwarded successful_payment credits once, idempotent on the Telegram charge id.
|
||||
out, err := svc.Fund(ctx, res.OrderID, "telegram", "tg-charge-1", starAmt)
|
||||
if err != nil {
|
||||
t.Fatalf("telegram fund: %v", err)
|
||||
}
|
||||
if out.AlreadyCredited || out.Chips != 50 || out.Source != payments.SourceTelegram {
|
||||
t.Fatalf("telegram fund outcome = %+v, want 50 chips credited to telegram", out)
|
||||
}
|
||||
if got := readBalance(t, acc, "telegram"); got != 50 {
|
||||
t.Errorf("telegram balance after fund = %d, want 50", got)
|
||||
}
|
||||
|
||||
// A retried forward (same charge id, e.g. a lost ack) credits nothing more.
|
||||
out2, err := svc.Fund(ctx, res.OrderID, "telegram", "tg-charge-1", starAmt)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate telegram fund: %v", err)
|
||||
}
|
||||
if !out2.AlreadyCredited {
|
||||
t.Error("retried forward not flagged AlreadyCredited")
|
||||
}
|
||||
if got := readBalance(t, acc, "telegram"); got != 50 {
|
||||
t.Errorf("telegram balance after retry = %d, want 50 (credited once)", got)
|
||||
}
|
||||
|
||||
// pre_checkout after the order is paid: declined (the reusable-link double-pay guard).
|
||||
pc2, err := svc.ValidatePreCheckout(ctx, res.OrderID, starAmt)
|
||||
if err != nil {
|
||||
t.Fatalf("pre_checkout after paid: %v", err)
|
||||
}
|
||||
if pc2.OK || pc2.Reason != payments.PreCheckoutAlreadyPaid {
|
||||
t.Errorf("pre_checkout after paid = %+v, want a decline with already_paid", pc2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsTelegramPreCheckoutDeclines covers the pre_checkout decline reasons: an unknown order
|
||||
// and an amount that no longer matches.
|
||||
func TestPaymentsTelegramPreCheckoutDeclines(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
starAmt, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
|
||||
|
||||
// An unknown order id declines as gone, with no account to localise against.
|
||||
pc, err := svc.ValidatePreCheckout(ctx, uuid.New(), starAmt)
|
||||
if err != nil {
|
||||
t.Fatalf("pre_checkout unknown: %v", err)
|
||||
}
|
||||
if pc.OK || pc.Reason != payments.PreCheckoutGone || pc.AccountID != (uuid.UUID{}) {
|
||||
t.Errorf("pre_checkout unknown = %+v, want a gone decline with no account", pc)
|
||||
}
|
||||
|
||||
// A pending order validated at the wrong amount declines as price-changed.
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "telegram", currency: "XTR", amount: 80})
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("telegram", "android"), []payments.Source{payments.SourceTelegram}, prod, "telegram")
|
||||
if err != nil {
|
||||
t.Fatalf("create telegram order: %v", err)
|
||||
}
|
||||
wrong, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
|
||||
pc2, err := svc.ValidatePreCheckout(ctx, res.OrderID, wrong)
|
||||
if err != nil {
|
||||
t.Fatalf("pre_checkout mismatch: %v", err)
|
||||
}
|
||||
if pc2.OK || pc2.Reason != payments.PreCheckoutPriceChanged {
|
||||
t.Errorf("pre_checkout mismatch = %+v, want a price_changed decline", pc2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsFundAmountMismatch verifies a callback whose paid amount does not match the order is
|
||||
// refused and credits nothing (§9: verify the amount after matching by order id).
|
||||
func TestPaymentsFundAmountMismatch(t *testing.T) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -80,6 +81,49 @@ func (s *Service) Fund(ctx context.Context, orderID uuid.UUID, provider, provide
|
||||
return s.store.fund(ctx, orderID, provider, providerPaymentID, paid, s.clock())
|
||||
}
|
||||
|
||||
// Pre-checkout decline reason codes. They are language-neutral: the transport layer localises them
|
||||
// to the order account's preferred language before showing the payer (the reason is displayed in the
|
||||
// Telegram payment sheet).
|
||||
const (
|
||||
// PreCheckoutGone means no order matches — an unknown or stale invoice payload.
|
||||
PreCheckoutGone = "order_gone"
|
||||
// PreCheckoutAlreadyPaid means the order is already paid (a reusable invoice link paid twice).
|
||||
PreCheckoutAlreadyPaid = "already_paid"
|
||||
// PreCheckoutPriceChanged means the amount or currency no longer matches the order.
|
||||
PreCheckoutPriceChanged = "price_changed"
|
||||
)
|
||||
|
||||
// PreCheckoutOutcome is the pre-charge validation of a Telegram Stars order. OK approves the charge;
|
||||
// otherwise Reason is a decline reason code the transport localises. AccountID is the order's account
|
||||
// (for localising the reason to its preferred language); it is the zero UUID when the order is unknown.
|
||||
type PreCheckoutOutcome struct {
|
||||
OK bool
|
||||
Reason string
|
||||
AccountID uuid.UUID
|
||||
}
|
||||
|
||||
// ValidatePreCheckout answers whether a Stars pre_checkout_query for orderID paying amount may be
|
||||
// approved, before any star is charged. It approves an order that exists, is not already paid (a
|
||||
// reusable invoice link paid a second time is refused here) and whose expected amount and currency
|
||||
// match the invoice. A pending or honoured-expired order is approved — a late credit is honoured
|
||||
// (§9/D23). A missing order or a mismatch is a clean decline with a reason code, not an error.
|
||||
func (s *Service) ValidatePreCheckout(ctx context.Context, orderID uuid.UUID, amount Money) (PreCheckoutOutcome, error) {
|
||||
ord, err := s.store.orderByID(ctx, orderID)
|
||||
if errors.Is(err, ErrOrderNotFound) {
|
||||
return PreCheckoutOutcome{OK: false, Reason: PreCheckoutGone}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return PreCheckoutOutcome{}, err
|
||||
}
|
||||
if ord.status == "paid" {
|
||||
return PreCheckoutOutcome{OK: false, Reason: PreCheckoutAlreadyPaid, AccountID: ord.accountID}, nil
|
||||
}
|
||||
if amount.Currency() != Currency(ord.currency) || amount.Minor() != ord.expectedAmount {
|
||||
return PreCheckoutOutcome{OK: false, Reason: PreCheckoutPriceChanged, AccountID: ord.accountID}, nil
|
||||
}
|
||||
return PreCheckoutOutcome{OK: true, AccountID: ord.accountID}, nil
|
||||
}
|
||||
|
||||
// ExpireOrders marks pending orders older than the configured lifetime as expired, returning how
|
||||
// many were swept. It backs the periodic pending reaper; expiry is cosmetic (a late valid callback
|
||||
// still credits — see Fund).
|
||||
|
||||
@@ -80,6 +80,10 @@ func (s *Server) registerRoutes() {
|
||||
// the Robokassa Result callback when a merchant is configured.
|
||||
u.POST("/wallet/order", s.handleWalletOrder)
|
||||
s.internal.POST("/payments/vk/callback", s.handleVKCallback)
|
||||
// The Telegram Stars rail: the bot forwards a pre_checkout validation and a completed
|
||||
// payment over the reverse bot-link; the gateway proxies both onto these gateway-only routes.
|
||||
s.internal.POST("/payments/telegram/precheckout", s.handleTelegramPreCheckout)
|
||||
s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment)
|
||||
if s.robokassa.MerchantLogin != "" {
|
||||
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
const (
|
||||
providerRobokassa = "robokassa" // the direct (RUB) rail
|
||||
providerVK = "vk" // the VK Votes rail
|
||||
providerTelegram = "telegram" // the Telegram Stars (XTR) rail
|
||||
)
|
||||
|
||||
// walletOrderRequest is the POST body of a chip-pack purchase: the pack to fund.
|
||||
@@ -25,16 +26,25 @@ type walletOrderRequest struct {
|
||||
ProductID string `json:"product_id"`
|
||||
}
|
||||
|
||||
// walletOrderResponse returns the created order id and the provider launch URL the client opens.
|
||||
// walletOrderResponse returns the created order id and the rail's launch details. RedirectURL is
|
||||
// the provider's hosted-payment URL for the direct rail (empty for VK/Telegram, which settle
|
||||
// in-app). Rail names the settling rail so the gateway knows how to launch it; for the Telegram
|
||||
// Stars rail the gateway mints the invoice link from InvoiceTitle and InvoiceAmount (whole stars)
|
||||
// via the bot and returns it in RedirectURL.
|
||||
type walletOrderResponse 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,omitempty"`
|
||||
InvoiceAmount int64 `json:"invoice_amount,omitempty"`
|
||||
}
|
||||
|
||||
// handleWalletOrder opens a pending order to fund a chip pack and returns the Robokassa
|
||||
// hosted-payment URL for the client to open. It is direct-rail only for now (VK/TG land later);
|
||||
// it enforces the wallet gate and D36 (a direct purchase requires a confirmed email anchor). No
|
||||
// chips are credited here — only later, by the verified Result callback.
|
||||
// handleWalletOrder opens a pending order to fund a chip pack and returns the rail's launch
|
||||
// details for the client: the Robokassa hosted-payment URL (direct), the order id for
|
||||
// VKWebAppShowOrderBox (VK), or the pack title and star amount the gateway mints into a Stars
|
||||
// invoice link (Telegram). It enforces the wallet gate and, on the direct rail, D36 (a purchase
|
||||
// requires a confirmed email anchor). No chips are credited here — only later, by the verified
|
||||
// provider callback.
|
||||
func (s *Server) handleWalletOrder(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
@@ -80,6 +90,7 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, walletOrderResponse{
|
||||
OrderID: res.OrderID.String(),
|
||||
RedirectURL: s.robokassa.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
|
||||
Rail: providerRobokassa,
|
||||
})
|
||||
case payments.SourceVK:
|
||||
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerVK)
|
||||
@@ -88,7 +99,23 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
// The client passes the order id to VKWebAppShowOrderBox as the item; there is no redirect.
|
||||
c.JSON(http.StatusOK, walletOrderResponse{OrderID: res.OrderID.String()})
|
||||
c.JSON(http.StatusOK, walletOrderResponse{OrderID: res.OrderID.String(), Rail: providerVK})
|
||||
case payments.SourceTelegram:
|
||||
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerTelegram)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
// The gateway mints the Stars invoice link from the title and amount via the bot (only
|
||||
// the bot reaches Telegram) and returns it to the client as RedirectURL; the amount is in
|
||||
// whole stars (the XTR minor unit is the star). No chips are credited until the verified
|
||||
// successful_payment is forwarded back through the bot.
|
||||
c.JSON(http.StatusOK, walletOrderResponse{
|
||||
OrderID: res.OrderID.String(),
|
||||
Rail: providerTelegram,
|
||||
InvoiceTitle: res.Title,
|
||||
InvoiceAmount: res.Amount.Minor(),
|
||||
})
|
||||
default:
|
||||
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available yet"}})
|
||||
}
|
||||
@@ -225,3 +252,148 @@ func (s *Server) handleVKCallback(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, vkErrorResponse(100, "unknown notification", false))
|
||||
}
|
||||
}
|
||||
|
||||
// telegramPreCheckoutRequest is the bot's pre_checkout validation, forwarded through the gateway:
|
||||
// the order in the invoice payload and the amount and currency Telegram is about to charge.
|
||||
type telegramPreCheckoutRequest struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
// telegramPreCheckoutResponse tells the bot whether to approve the pre_checkout_query; Reason is a
|
||||
// short message the bot surfaces to the payer on a decline.
|
||||
type telegramPreCheckoutResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// handleTelegramPreCheckout validates a Telegram Stars pre_checkout_query before the charge,
|
||||
// reached only through the gateway (the bot's ValidatePreCheckout, forwarded on the internal
|
||||
// route). It approves an order that exists, is not already paid (a reusable invoice link paid twice
|
||||
// is refused here, before any star moves) and whose amount and currency match. A malformed
|
||||
// reference is a decline, not an error.
|
||||
func (s *Server) handleTelegramPreCheckout(c *gin.Context) {
|
||||
var req telegramPreCheckoutRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
orderID, err := uuid.Parse(req.OrderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: false, Reason: telegramDeclineText(payments.PreCheckoutGone, "")})
|
||||
return
|
||||
}
|
||||
amount, err := payments.MoneyFromMinor(req.Amount, payments.Currency(req.Currency))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: false, Reason: telegramDeclineText(payments.PreCheckoutGone, "")})
|
||||
return
|
||||
}
|
||||
out, err := s.payments.ValidatePreCheckout(ctx, orderID, amount)
|
||||
if err != nil {
|
||||
s.log.Error("telegram pre_checkout validate failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, errorResponse{Error: errorBody{Code: "internal", Message: "internal error"}})
|
||||
return
|
||||
}
|
||||
reason := ""
|
||||
if !out.OK {
|
||||
// Localise the decline to the order account's preferred language (the reason shows in the
|
||||
// Telegram payment sheet). An unknown order has no account, so it falls back to English.
|
||||
lang := ""
|
||||
if s.accounts != nil && out.AccountID != (uuid.UUID{}) {
|
||||
if acc, aerr := s.accounts.GetByID(ctx, out.AccountID); aerr == nil {
|
||||
lang = acc.PreferredLanguage
|
||||
}
|
||||
}
|
||||
reason = telegramDeclineText(out.Reason, lang)
|
||||
}
|
||||
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: out.OK, Reason: reason})
|
||||
}
|
||||
|
||||
// telegramDeclineText renders a pre-checkout decline reason code in the payer's language (ru or
|
||||
// anything else falls back to English), for display in the Telegram payment sheet.
|
||||
func telegramDeclineText(code, lang string) string {
|
||||
ru := lang == "ru"
|
||||
switch code {
|
||||
case payments.PreCheckoutAlreadyPaid:
|
||||
if ru {
|
||||
return "Этот заказ уже оплачен."
|
||||
}
|
||||
return "This order has already been paid."
|
||||
case payments.PreCheckoutPriceChanged:
|
||||
if ru {
|
||||
return "Цена изменилась — начните покупку заново."
|
||||
}
|
||||
return "The price has changed; please start the purchase again."
|
||||
default:
|
||||
if ru {
|
||||
return "Этот заказ больше недоступен."
|
||||
}
|
||||
return "This order is no longer available."
|
||||
}
|
||||
}
|
||||
|
||||
// telegramPaymentRequest is a completed Stars payment forwarded from the bot's outbox through the
|
||||
// gateway: the order, the Telegram charge id (the idempotency key), the stars paid, and the payer.
|
||||
type telegramPaymentRequest struct {
|
||||
OrderID string `json:"order_id"`
|
||||
TelegramPaymentChargeID string `json:"telegram_payment_charge_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
TelegramUserID int64 `json:"telegram_user_id"`
|
||||
}
|
||||
|
||||
// telegramPaymentResponse reports the durable outcome to the bot: Credited is true once the order
|
||||
// is credited (or already was). A false with a 200 means the payment was recorded but not creditable
|
||||
// (the bot drops it); a 5xx means a transient failure the bot retries.
|
||||
type telegramPaymentResponse struct {
|
||||
Credited bool `json:"credited"`
|
||||
}
|
||||
|
||||
// handleTelegramPayment credits a completed Telegram Stars payment, reached only through the gateway
|
||||
// (the bot's ForwardPayment, forwarded on the internal route). It credits the matched order exactly
|
||||
// once (idempotent on the Telegram charge id, honoured even if the order expired) and records a
|
||||
// succeeded event. A permanent rejection (unknown order, amount mismatch) is answered 200 with
|
||||
// Credited=false so the bot stops retrying a payment it cannot place; a transient failure is a 5xx
|
||||
// the bot retries.
|
||||
func (s *Server) handleTelegramPayment(c *gin.Context) {
|
||||
var req telegramPaymentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
orderID, err := uuid.Parse(req.OrderID)
|
||||
if err != nil {
|
||||
s.log.Warn("telegram payment: bad order id", zap.String("charge", req.TelegramPaymentChargeID))
|
||||
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
|
||||
return
|
||||
}
|
||||
paid, err := payments.MoneyFromMinor(req.Amount, payments.CurrencyStar)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
outcome, err := s.payments.Fund(ctx, orderID, providerTelegram, req.TelegramPaymentChargeID, paid)
|
||||
if err != nil {
|
||||
if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
|
||||
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) {
|
||||
// The star charge already happened but the order cannot be placed; record it loudly for
|
||||
// an operator and tell the bot to stop retrying (an operator refunds or credits by hand).
|
||||
s.log.Error("telegram payment rejected (charge taken, not credited)",
|
||||
zap.String("order", orderID.String()), zap.String("charge", req.TelegramPaymentChargeID), zap.Error(err))
|
||||
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
|
||||
return
|
||||
}
|
||||
s.log.Error("telegram fund failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, errorResponse{Error: errorBody{Code: "internal", Message: "internal error"}})
|
||||
return
|
||||
}
|
||||
if !outcome.AlreadyCredited {
|
||||
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
|
||||
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
|
||||
s.log.Error("record telegram payment event failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: true})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user