3bb9f10fad
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.
228 lines
8.9 KiB
Go
228 lines
8.9 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
|
|
"scrabble/backend/internal/payments"
|
|
)
|
|
|
|
// 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 {
|
|
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
|
|
}
|
|
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"}})
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|
|
// 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))
|
|
}
|
|
}
|