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:
@@ -73,6 +73,12 @@ func (s *Server) registerRoutes() {
|
||||
u.GET("/wallet/catalog", s.handleWalletCatalog)
|
||||
u.POST("/wallet/buy", s.handleWalletBuy)
|
||||
}
|
||||
if s.payments != nil && s.robokassa.MerchantLogin != "" {
|
||||
// Direct-rail (Robokassa): open a pending order (user group), and receive the verified
|
||||
// Result callback the gateway forwards (internal group, gateway-only — the single writer).
|
||||
u.POST("/wallet/order", s.handleWalletOrder)
|
||||
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
|
||||
}
|
||||
if s.links != nil {
|
||||
// Account linking & merge. The request step always mails a code;
|
||||
// a required merge is revealed only after the code is verified, and the
|
||||
@@ -266,6 +272,8 @@ func statusForError(err error) (int, string) {
|
||||
return http.StatusNotFound, "product_not_found"
|
||||
case errors.Is(err, payments.ErrNotAValue):
|
||||
return http.StatusBadRequest, "not_a_value"
|
||||
case errors.Is(err, payments.ErrNotAPack):
|
||||
return http.StatusBadRequest, "not_a_pack"
|
||||
case errors.Is(err, account.ErrInvalidEmail):
|
||||
return http.StatusBadRequest, "invalid_email"
|
||||
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"scrabble/backend/internal/payments"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
"scrabble/backend/internal/render"
|
||||
"scrabble/backend/internal/robokassa"
|
||||
"scrabble/backend/internal/session"
|
||||
"scrabble/backend/internal/social"
|
||||
"scrabble/backend/internal/telemetry"
|
||||
@@ -109,6 +110,9 @@ type Deps struct {
|
||||
// Renderer is the image-render sidecar client for the PNG export artifact. A
|
||||
// nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
|
||||
Renderer *render.Client
|
||||
// Robokassa configures the direct-rail (RUB) provider; an empty MerchantLogin leaves the
|
||||
// order and Result-callback endpoints unregistered.
|
||||
Robokassa robokassa.Config
|
||||
}
|
||||
|
||||
// Server owns the gin engine, the underlying HTTP server and the readiness
|
||||
@@ -136,6 +140,7 @@ type Server struct {
|
||||
banview *banview.View
|
||||
ads *ads.Service
|
||||
payments *payments.Service
|
||||
robokassa robokassa.Config
|
||||
notifier notify.Publisher
|
||||
console *adminconsole.Renderer
|
||||
exportKey []byte
|
||||
@@ -189,6 +194,7 @@ func New(addr string, deps Deps) *Server {
|
||||
banview: deps.BanView,
|
||||
ads: deps.Ads,
|
||||
payments: deps.Payments,
|
||||
robokassa: deps.Robokassa,
|
||||
notifier: notifier,
|
||||
renderer: deps.Renderer,
|
||||
http: &http.Server{Addr: addr, Handler: engine},
|
||||
|
||||
Reference in New Issue
Block a user