package server import ( "encoding/json" "errors" "net/http" "net/url" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" "scrabble/backend/internal/payments" ) // providerRobokassa is the ledger/order provider tag for the direct (RUB) rail. const providerRobokassa = "robokassa" // 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 } if cxt.Kind != payments.SourceDirect { c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available yet"}}) return } 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), }) } // 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)) } } c.String(http.StatusOK, "OK"+v.Get("InvId")) }