feat(payments): VK Votes payment rail
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
Wire the VK Mini Apps ("голоса") rail end to end, reusing the intake engine. The
wallet.order endpoint branches by rail: a VK context opens a pending order
(provider vk) and returns its id, which the client passes to VKWebAppShowOrderBox.
VK's two-phase payment callback is verified at the gateway with the app protected
key (GATEWAY_VK_APP_SECRET) and proxied to a backend intake handler: get_item
returns the ordered pack's title and vote price; a chargeable order_status_change
credits the vk segment exactly once (the same Fund, idempotent on VK's own order
id) and records a succeeded event, so the dispatcher push refreshes the wallet.
Integration test for the VK order->credit path.
This commit is contained in:
@@ -73,11 +73,16 @@ 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).
|
||||
if s.payments != nil {
|
||||
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
|
||||
// unsupported or unconfigured rail returns 501 from the handler. The provider callbacks are
|
||||
// gateway-only (the single writer): the VK payment callback (both phases handled here), and
|
||||
// the Robokassa Result callback when a merchant is configured.
|
||||
u.POST("/wallet/order", s.handleWalletOrder)
|
||||
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
|
||||
s.internal.POST("/payments/vk/callback", s.handleVKCallback)
|
||||
if s.robokassa.MerchantLogin != "" {
|
||||
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
|
||||
}
|
||||
}
|
||||
if s.links != nil {
|
||||
// Account linking & merge. The request step always mails a code;
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -13,8 +14,11 @@ import (
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// providerRobokassa is the ledger/order provider tag for the direct (RUB) rail.
|
||||
const providerRobokassa = "robokassa"
|
||||
// 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 {
|
||||
@@ -52,28 +56,42 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
if cxt.Kind != payments.SourceDirect {
|
||||
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"}})
|
||||
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
|
||||
@@ -124,3 +142,86 @@ func (s *Server) handleRobokassaResult(c *gin.Context) {
|
||||
// The gateway echoes response verbatim to Robokassa, which requires the body "OK<InvId>".
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user