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" ) // Ledger/order provider tags per rail. const ( providerRobokassa = "robokassa" // the direct (RUB) rail providerVK = "vk" // the VK Votes 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 provider launch URL the client opens. type walletOrderResponse struct { OrderID string `json:"order_id"` RedirectURL string `json:"redirect_url"` } // 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. 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 } switch cxt.Kind { case payments.SourceDirect: if s.robokassa.MerchantLogin == "" { 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: s.robokassa.PaymentURL(res.OrderID, res.Amount.Major(), res.Title), }) 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()}) 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) } orderID, outSum, ok := s.robokassa.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)) } }