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". 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". 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}) }