feat(payments): direct-rail order + intake handlers, config and reaper
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.
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
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"))
|
||||
}
|
||||
Reference in New Issue
Block a user