92ba527575
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Replace Robokassa with YooKassa as the RUB direct-rail provider. The wallet
model is untouched: one `direct` segment, the same spend wall, the same
per-channel merchant shops (D42) and `shop` on the order (D44).
The two providers are not shaped alike, and that drives the change:
- Opening a purchase is now an outbound API call (`POST /v3/payments`,
single-stage capture, redirect confirmation). The order id is both the
`Idempotence-Key` and `metadata.order_id`, so a retried create cannot mint a
second payment and a notification always resolves to its order.
- YooKassa does NOT sign notifications, so the body is never evidence: it only
names a payment, which is re-read with `GET /v3/payments/{id}`, and only that
answer is acted on. Two guards ride on it — the payment's metadata must name
the order, and its `test` flag must match the shop's, so a test-shop payment
can never credit real chips. The sender address is checked against YooKassa's
published ranges first, which stops a forger turning each fabricated
notification into an outbound call of ours.
- A notification lost for good would leave the money taken and the chips unowed,
silently. The existing pending-order reaper now asks the provider about each
order that reached its expiry age carrying a payment id, and credits the ones
really paid — one request per order over its whole life, not polling.
- `payment.canceled` records a `failed` event, so a declined payment is finally
surfaced to the customer as PAYMENTS.md §9 already specified.
- The admin refund moves the money through `POST /v3/refunds` before recording
anything; a failed call records nothing, so the ledger cannot claim a refund
that did not happen, and the recorded id is the provider's own.
- YooKassa has no cabinet-side generic receipt: «Чеки от ЮKassa» registers one
only if the request carries it, so every payment and refund now sends an
itemized `receipt` to the D36 confirmed email. The VAT rate code is a deploy
variable; the settlement subject and method are constants.
Robokassa is retired, not deleted: the direct rail falls back to it when no
YooKassa shop is configured and no deployment sets its credentials, so reviving
it is a credentials change rather than a code change. Its variables are removed
from compose, .env.example, write-prod-env.sh and the three workflows, and
recorded in backend/internal/robokassa/README.md together with the cabinet
configuration and the revival steps. Ledger rows keep `provider = 'robokassa'`;
that literal is load-bearing for the idempotency index.
No migration and no wire change: `orders.provider_payment_id` already existed,
and the client is rail-agnostic.
Decisions D47-D51 (revising D41) and stage E12 are baked into the docs.
445 lines
19 KiB
Go
445 lines
19 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
|
|
"scrabble/backend/internal/payments"
|
|
"scrabble/backend/internal/robokassa"
|
|
)
|
|
|
|
// Ledger/order provider tags per rail. providerRobokassa is retired but never renamed or reused:
|
|
// historical ledger rows carry it, and it is load-bearing for the (provider, provider_payment_id)
|
|
// idempotency index.
|
|
const (
|
|
providerYooKassa = "yookassa" // the direct (RUB) rail
|
|
providerRobokassa = "robokassa" // the retired direct (RUB) rail
|
|
providerVK = "vk" // the VK Votes rail
|
|
providerTelegram = "telegram" // the Telegram Stars (XTR) rail
|
|
)
|
|
|
|
// yookassaReturnPath is where YooKassa returns the customer's browser after the hosted payment page.
|
|
// The credit never rides this redirect — it rides the server notification — so the page only closes
|
|
// the payment window and drops the customer back into the live app.
|
|
const yookassaReturnPath = "/pay/yookassa/return"
|
|
|
|
// walletOrderRequest is the POST body of a chip-pack purchase: the pack to fund.
|
|
type walletOrderRequest struct {
|
|
ProductID string `json:"product_id"`
|
|
}
|
|
|
|
// walletOrderResponse returns the created order id and the rail's launch details. RedirectURL is
|
|
// the provider's hosted-payment page 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"`
|
|
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 rail's launch
|
|
// details for the client: the provider's 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 {
|
|
return
|
|
}
|
|
var req walletOrderRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid request body"}})
|
|
return
|
|
}
|
|
productID, err := uuid.Parse(req.ProductID)
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid product id"}})
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
cxt, present, err := s.walletGate(ctx, uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
// Operational availability gate (D45/D46): the per-rail kill switch + the per-account override,
|
|
// before any order is opened. Orthogonal to the security gates in the rail branches below — a
|
|
// per-account "allow" override bypasses only this switch, never those. The reason is localized to
|
|
// the account's language for the user.
|
|
lang := ""
|
|
if acc, aerr := s.accounts.GetByID(ctx, uid); aerr == nil {
|
|
lang = acc.PreferredLanguage
|
|
}
|
|
if ok, reason, aerr := s.payments.CanPurchase(ctx, uid, payments.RailKey(cxt.Kind, cxt.Subtype), lang); aerr != nil {
|
|
s.abortErr(c, aerr)
|
|
return
|
|
} else if !ok {
|
|
c.AbortWithStatusJSON(http.StatusServiceUnavailable, errorResponse{Error: errorBody{Code: "payment_unavailable", Message: reason}})
|
|
return
|
|
}
|
|
switch cxt.Kind {
|
|
case payments.SourceDirect:
|
|
// YooKassa settles the direct rail. Robokassa is retired and normally unconfigured, so it is
|
|
// only reached when YooKassa has no shop for this channel — which is what makes reviving the
|
|
// old rail a credentials change rather than a code change. The rail is resolved before any
|
|
// account-level gate, so an unconfigured rail still reads as unavailable rather than as
|
|
// something the customer could fix.
|
|
ykShop, onYooKassa := s.yookassa.Shop(cxt.Subtype)
|
|
rkShop, onRobokassa := s.robokassa.Shop(cxt.Subtype)
|
|
if !onYooKassa && !onRobokassa {
|
|
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available"}})
|
|
return
|
|
}
|
|
// D36: a direct purchase requires a confirmed email anchor, which is also where the fiscal
|
|
// receipt is delivered.
|
|
email, hasEmail, err := s.accounts.ConfirmedEmail(ctx, uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
if !hasEmail {
|
|
c.AbortWithStatusJSON(http.StatusForbidden, errorResponse{Error: errorBody{Code: "email_required", Message: "confirm your email before making a purchase"}})
|
|
return
|
|
}
|
|
if onYooKassa {
|
|
s.orderYooKassa(c, uid, cxt, present, productID, ykShop, email)
|
|
return
|
|
}
|
|
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerRobokassa)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, walletOrderResponse{
|
|
OrderID: res.OrderID.String(),
|
|
RedirectURL: rkShop.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
|
|
Rail: providerRobokassa,
|
|
})
|
|
case payments.SourceVK:
|
|
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerVK)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
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(), 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"}})
|
|
}
|
|
}
|
|
|
|
// handleRobokassaResult is the verified Robokassa Result callback, reached only through the gateway
|
|
// (which forwards the provider's form parameters as a JSON object on the internal, gateway-only
|
|
// route). It verifies the Password2 signature, credits the matched order exactly once (idempotent,
|
|
// and honoured even if the order expired), records a succeeded event, and answers Robokassa's
|
|
// expected "OK<InvId>". A duplicate callback credits nothing but still answers OK.
|
|
func (s *Server) handleRobokassaResult(c *gin.Context) {
|
|
var params map[string]string
|
|
if err := c.ShouldBindJSON(¶ms); err != nil {
|
|
c.String(http.StatusBadRequest, "bad request")
|
|
return
|
|
}
|
|
v := url.Values{}
|
|
for k, val := range params {
|
|
v.Set(k, val)
|
|
}
|
|
// The per-shop Result route carries the channel as ?channel=; the legacy bare route defaults to
|
|
// the web shop. Each channel is verified only by its own shop's Password2 (Verifier is strict).
|
|
channel := c.DefaultQuery("channel", robokassa.ChannelWeb)
|
|
shop, ok := s.robokassa.Verifier(channel)
|
|
if !ok {
|
|
s.log.Warn("robokassa result: unknown shop channel", zap.String("channel", channel))
|
|
c.String(http.StatusBadRequest, "bad sign")
|
|
return
|
|
}
|
|
orderID, outSum, ok := shop.VerifyResult(v)
|
|
if !ok {
|
|
s.log.Warn("robokassa result: bad signature")
|
|
c.String(http.StatusBadRequest, "bad sign")
|
|
return
|
|
}
|
|
paid, err := payments.ParseMoney(outSum, payments.CurrencyRUB)
|
|
if err != nil {
|
|
c.String(http.StatusBadRequest, "bad amount")
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
outcome, err := s.payments.Fund(ctx, orderID, providerRobokassa, orderID.String(), 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) {
|
|
s.log.Warn("robokassa result rejected", zap.String("order", orderID.String()), zap.Error(err))
|
|
c.String(http.StatusBadRequest, "rejected")
|
|
return
|
|
}
|
|
s.log.Error("robokassa fund failed", zap.String("order", orderID.String()), zap.Error(err))
|
|
c.String(http.StatusInternalServerError, "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 payment event failed", zap.String("order", orderID.String()), zap.Error(err))
|
|
}
|
|
}
|
|
// The gateway echoes response verbatim to Robokassa, which requires the body "OK<InvId>".
|
|
c.JSON(http.StatusOK, gin.H{"response": "OK" + v.Get("InvId")})
|
|
}
|
|
|
|
// vkErrorResponse builds VK's error envelope for a payment callback.
|
|
func vkErrorResponse(code int, msg string, critical bool) gin.H {
|
|
return gin.H{"error": gin.H{"error_code": code, "error_msg": msg, "critical": critical}}
|
|
}
|
|
|
|
// handleVKCallback is the VK Mini Apps payment callback, reached only through the gateway (which
|
|
// verifies the VK signature and forwards the provider's parameters as a JSON object on the internal
|
|
// route). It answers VK's two phases: get_item returns the ordered pack's title and vote price; a
|
|
// chargeable order_status_change credits the matched order exactly once (idempotent on VK's order
|
|
// id) and records a succeeded event. Both phases use VK's response envelope; the _test variants are
|
|
// the sandbox notifications and are handled identically.
|
|
func (s *Server) handleVKCallback(c *gin.Context) {
|
|
var params map[string]string
|
|
if err := c.ShouldBindJSON(¶ms); err != nil {
|
|
c.JSON(http.StatusOK, vkErrorResponse(1, "bad request", true))
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
switch params["notification_type"] {
|
|
case "get_item", "get_item_test":
|
|
orderID, err := uuid.Parse(params["item"])
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
|
|
return
|
|
}
|
|
title, amount, err := s.payments.OrderItem(ctx, orderID)
|
|
if err != nil {
|
|
s.log.Warn("vk get_item lookup failed", zap.String("order", orderID.String()), zap.Error(err))
|
|
c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"response": gin.H{
|
|
"item_id": orderID.String(),
|
|
"title": title,
|
|
"price": amount.Minor(),
|
|
}})
|
|
case "order_status_change", "order_status_change_test":
|
|
if params["status"] != "chargeable" {
|
|
c.JSON(http.StatusOK, vkErrorResponse(100, "unsupported status", false))
|
|
return
|
|
}
|
|
orderID, err := uuid.Parse(params["item"])
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
|
|
return
|
|
}
|
|
price, err := strconv.ParseInt(params["item_price"], 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, vkErrorResponse(100, "bad price", false))
|
|
return
|
|
}
|
|
paid, err := payments.MoneyFromMinor(price, payments.CurrencyVote)
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, vkErrorResponse(100, "bad price", false))
|
|
return
|
|
}
|
|
vkOrderID := params["order_id"]
|
|
outcome, err := s.payments.Fund(ctx, orderID, providerVK, vkOrderID, 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) {
|
|
s.log.Warn("vk order rejected", zap.String("order", orderID.String()), zap.Error(err))
|
|
c.JSON(http.StatusOK, vkErrorResponse(100, "cannot process the order", false))
|
|
return
|
|
}
|
|
s.log.Error("vk fund failed", zap.String("order", orderID.String()), zap.Error(err))
|
|
c.JSON(http.StatusOK, vkErrorResponse(100, "internal error", false))
|
|
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 vk payment event failed", zap.String("order", orderID.String()), zap.Error(err))
|
|
}
|
|
}
|
|
// Echo VK's own order id; app_order_id is optional and our order id is a uuid, so omit it.
|
|
appOrderID, _ := strconv.ParseInt(vkOrderID, 10, 64)
|
|
c.JSON(http.StatusOK, gin.H{"response": gin.H{"order_id": appOrderID}})
|
|
default:
|
|
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})
|
|
}
|