1507ceb793
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m0s
Let an operator disable purchases live from the admin — a whole rail/channel or one account — and show the user a localized reason on their next attempt, so a provider outage or a misconfig is explained instead of a silent dead button. - rail kill switch (payments.rail_status, per rail direct:web / direct:android / vk / telegram): enabled + a per-language message, edited on the catalog page. Fail-open — a rail with no row stays enabled, so payments are never accidentally killed. The intake gate (CanPurchase in handleWalletOrder, before the order) returns payment_unavailable + the localized message, orthogonal to the security gates. - per-account override (payments.account_payment_override, a row only for non-default): allow / deny / default, edited on the user card. "allow" bypasses ONLY the ops rail switch, never the security gates (trusted platform, the email anchor, the VK-iOS freeze, the min client version). - wire: an additive ExecuteResponse.message envelope field (frozen-contract-safe); the gateway forwards a backend domain-error message; the client shows it on a payment_unavailable buy attempt. - admin: rail toggles on the catalog page, the override control on the user card. - tests: the pure gate (unit, TDD), the store + gate + override end-to-end (integration, migration 00016), the client (svelte-check / vitest). - docs: PAYMENTS (+ru), the decisions log (D45/D46). Fiscalization stays cabinet-side (owner decision) — no itemized-receipt code. Contour-safe: additive migration (two new tables, no wipe), the wire add is additive, and fail-open so nothing is disabled until an operator acts.
426 lines
18 KiB
Go
426 lines
18 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.
|
|
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.
|
|
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 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"`
|
|
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 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 {
|
|
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:
|
|
shop, ok := s.robokassa.Shop(cxt.Subtype)
|
|
if !ok {
|
|
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.
|
|
hasEmail, err := s.accounts.HasConfirmedEmail(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
|
|
}
|
|
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: shop.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})
|
|
}
|