36a5730e52
Wire the Robokassa direct rail into the backend transport. POST /api/v1/user/wallet/order (walletGate + a D36 confirmed-email gate for the direct rail) opens a pending order and returns the signed Robokassa payment URL. The internal, gateway-only /payments/robokassa/result endpoint verifies the Result signature, credits the matched order exactly once via Fund (honoured even if expired), records a succeeded payment event, and answers Robokassa's "OK<InvId>". Add the Robokassa env config, an account HasConfirmedEmail check (D36), the payment_events writer, and a periodic pending-order reaper. The routes register only when a Robokassa merchant login is configured.
126 lines
4.5 KiB
Go
126 lines
4.5 KiB
Go
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<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)
|
|
}
|
|
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"))
|
|
}
|