From 3bb9f10fad601d0066bb3a467a0ed673c16aad80 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 19:27:57 +0200 Subject: [PATCH] feat(payments): VK Votes payment rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- PLAN.md | 9 +- .../internal/inttest/payments_intake_test.go | 52 +++++++ backend/internal/payments/service_intake.go | 19 +++ backend/internal/server/handlers.go | 13 +- backend/internal/server/handlers_intake.go | 145 +++++++++++++++--- gateway/cmd/gateway/main.go | 1 + gateway/internal/backendclient/api.go | 10 ++ gateway/internal/connectsrv/server.go | 83 +++++++--- ui/src/lib/vk.ts | 15 ++ ui/src/screens/Wallet.svelte | 8 +- 10 files changed, 308 insertions(+), 47 deletions(-) diff --git a/PLAN.md b/PLAN.md index 854a31d..51a5d9c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -518,8 +518,13 @@ confirmed-email gate on `direct`), the pending reaper, the `wallet.order` edge w `/pay/*` routes, the Wallet purchase CTA, the contour deploy env (IsTest forced), and the `payment_events` dispatcher — an in-app wallet-refresh push (KindNotification `"payment"`) with a self-closing provider-return page (the payment opens in a separate window) and a return-focus -refetch fallback. Remaining: the VK and TG-Stars rails and refunds; and hiding the ad banner on a -no-ads purchase (a spend-path `NotifyBanner`, deferred with the owner's agreement). +refetch fallback. The **VK Votes rail** is delivered too: the client opens +`VKWebAppShowOrderBox({item: order_id})`; a two-phase signed server callback (`get_item` → the pack +title + vote price; a chargeable `order_status_change` → the same `Fund` with source=`vk`, idempotent +on VK's own order id) is verified at the gateway with the app protected key (`GATEWAY_VK_APP_SECRET`, +already deployed) and proxied to the backend intake. Remaining: the TG-Stars rail and refunds; and +hiding the ad banner on a no-ads purchase (a spend-path `NotifyBanner`, deferred with the owner's +agreement). **Goal.** Accept real money on all three rails into the payments domain: order-flow, verified provider callbacks, idempotency, the TG bot SQLite outbox, the event dispatcher, diff --git a/backend/internal/inttest/payments_intake_test.go b/backend/internal/inttest/payments_intake_test.go index 17b4f2b..3eb97dd 100644 --- a/backend/internal/inttest/payments_intake_test.go +++ b/backend/internal/inttest/payments_intake_test.go @@ -77,6 +77,58 @@ func TestPaymentsOrderFundCreditsOnce(t *testing.T) { } } +// TestPaymentsVKOrderFundCredits exercises the VK Votes rail over Postgres: a VK order prices the +// pack in votes; the get_item lookup returns its title and vote price; a chargeable +// order_status_change credits the vk segment exactly once (idempotent on VK's own order id). +func TestPaymentsVKOrderFundCredits(t *testing.T) { + ctx := context.Background() + svc := newPaymentsService() + acc := uuid.New() + prod := seedPackProduct(t, 200, methodPrice{method: "vk", currency: "VOTE", amount: 30}) + + res, err := svc.CreateOrder(ctx, acc, payments.NewContext("vk", "web"), []payments.Source{payments.SourceVK}, prod, "vk") + if err != nil { + t.Fatalf("create vk order: %v", err) + } + if res.Amount.Currency() != payments.CurrencyVote || res.Amount.Minor() != 30 { + t.Fatalf("vk order amount = %s, want 30 VOTE", res.Amount) + } + + // get_item phase: title + vote price. + title, amount, err := svc.OrderItem(ctx, res.OrderID) + if err != nil { + t.Fatalf("order item: %v", err) + } + if title == "" || amount.Minor() != 30 || amount.Currency() != payments.CurrencyVote { + t.Errorf("order item = %q / %s, want a title + 30 VOTE", title, amount) + } + + // order_status_change phase: VK's own order id is the idempotency key. + paid, _ := payments.MoneyFromMinor(30, payments.CurrencyVote) + out, err := svc.Fund(ctx, res.OrderID, "vk", "vk-order-777", paid) + if err != nil { + t.Fatalf("vk fund: %v", err) + } + if out.AlreadyCredited || out.Chips != 200 || out.Source != payments.SourceVK { + t.Fatalf("vk fund outcome = %+v, want 200 chips credited to vk", out) + } + if got := readBalance(t, acc, "vk"); got != 200 { + t.Errorf("vk balance after fund = %d, want 200", got) + } + + // A duplicate VK callback (same VK order id) credits nothing more. + out2, err := svc.Fund(ctx, res.OrderID, "vk", "vk-order-777", paid) + if err != nil { + t.Fatalf("duplicate vk fund: %v", err) + } + if !out2.AlreadyCredited { + t.Error("duplicate VK callback not flagged AlreadyCredited") + } + if got := readBalance(t, acc, "vk"); got != 200 { + t.Errorf("vk balance after duplicate = %d, want 200 (credited once)", got) + } +} + // TestPaymentsFundAmountMismatch verifies a callback whose paid amount does not match the order is // refused and credits nothing (§9: verify the amount after matching by order id). func TestPaymentsFundAmountMismatch(t *testing.T) { diff --git a/backend/internal/payments/service_intake.go b/backend/internal/payments/service_intake.go index ccf0ffe..6be8372 100644 --- a/backend/internal/payments/service_intake.go +++ b/backend/internal/payments/service_intake.go @@ -52,6 +52,25 @@ func (s *Service) CreateOrder(ctx context.Context, accountID uuid.UUID, cxt Cont return OrderResult{OrderID: orderID, Amount: pack.price, Title: pack.title}, nil } +// OrderItem returns a pending order's human title and the amount it charges, in the order's own +// currency — the details a provider's item-lookup phase needs (VK's get_item). It reads the order +// and the pack title, honouring the pack even if it was later deactivated (mirrors Fund). +func (s *Service) OrderItem(ctx context.Context, orderID uuid.UUID) (title string, amount Money, err error) { + ord, err := s.store.orderByID(ctx, orderID) + if err != nil { + return "", Money{}, err + } + _, title, err = s.store.packForCredit(ctx, ord.productID) + if err != nil { + return "", Money{}, err + } + amount, err = MoneyFromMinor(ord.expectedAmount, Currency(ord.currency)) + if err != nil { + return "", Money{}, err + } + return title, amount, nil +} + // Fund credits a paid order into its funded segment exactly once, from a verified provider callback // — the single writer for every rail. It matches the order, verifies the paid amount, appends the // fund ledger row (idempotent on (provider, provider_payment_id)), credits the balance and marks diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 6f9a721..3ab4b46 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -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; diff --git a/backend/internal/server/handlers_intake.go b/backend/internal/server/handlers_intake.go index 6c3184f..70be4cf 100644 --- a/backend/internal/server/handlers_intake.go +++ b/backend/internal/server/handlers_intake.go @@ -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". 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)) + } +} diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index 0bb975c..c7a270b 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -208,6 +208,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { Tracker: tracker, Banlist: banlist, Honeytoken: cfg.Abuse.Honeytoken, + VKAppSecret: cfg.VKAppSecret, Hub: hub, RateLimit: cfg.RateLimit, Heartbeat: cfg.PushHeartbeatInterval, diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 8d033b4..ca5466c 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -428,6 +428,16 @@ func (c *Client) RobokassaResult(ctx context.Context, params map[string]string) return out.Response, err } +// VKCallback forwards a gateway-verified VK payment callback's parameters to the backend intake and +// returns the backend's raw VK response envelope (`{"response":…}` or `{"error":…}`) for the gateway +// to relay to VK verbatim. The backend answers 200 in every case (VK's protocol), so a transport +// error here is a genuine proxy failure. +func (c *Client) VKCallback(ctx context.Context, params map[string]string) (json.RawMessage, error) { + var out json.RawMessage + err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/vk/callback", "", "", params, &out) + return out, err +} + // CatalogAtomResp is one atom line of a storefront product: the value type it grants and quantity. type CatalogAtomResp struct { AtomType string `json:"atom_type"` diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index 9549732..72b1a74 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -31,6 +31,7 @@ import ( "scrabble/gateway/internal/ratelimit" "scrabble/gateway/internal/session" "scrabble/gateway/internal/transcode" + "scrabble/gateway/internal/vkpay" "scrabble/gateway/internal/webui" edgev1 "scrabble/gateway/proto/edge/v1" "scrabble/gateway/proto/edge/v1/edgev1connect" @@ -70,18 +71,19 @@ const ( // Server implements edgev1connect.GatewayHandler. type Server struct { - registry *transcode.Registry - sessions *session.Cache - backend *backendclient.Client - limiter *ratelimit.Limiter - tracker *ratelimit.Tracker - banlist *ratelimit.Banlist - honeytoken string - hub *push.Hub - heartbeat time.Duration - log *zap.Logger - adminProxy http.Handler - metrics *serverMetrics + registry *transcode.Registry + sessions *session.Cache + backend *backendclient.Client + limiter *ratelimit.Limiter + tracker *ratelimit.Tracker + banlist *ratelimit.Banlist + honeytoken string + vkAppSecret string + hub *push.Hub + heartbeat time.Duration + log *zap.Logger + adminProxy http.Handler + metrics *serverMetrics maxBodyBytes int @@ -110,12 +112,15 @@ type Deps struct { // Honeytoken, when non-empty, is the planted bearer value whose presentation // bans the caller and raises a high-severity alarm. Honeytoken string - Hub *push.Hub - RateLimit config.RateLimitConfig - Heartbeat time.Duration - Logger *zap.Logger - AdminProxy http.Handler - Meter metric.Meter + // VKAppSecret is the VK Mini App protected key; it verifies the VK payment callback signature + // (the same key the launch-signature auth uses). Empty leaves the VK callback rejecting. + VKAppSecret string + Hub *push.Hub + RateLimit config.RateLimitConfig + Heartbeat time.Duration + Logger *zap.Logger + AdminProxy http.Handler + Meter metric.Meter // MaxBodyBytes caps one inbound request body and one Connect message read; // zero or negative selects config.DefaultMaxBodyBytes. MaxBodyBytes int @@ -151,6 +156,7 @@ func NewServer(d Deps) *Server { registry: d.Registry, sessions: d.Sessions, backend: d.Backend, + vkAppSecret: d.VKAppSecret, limiter: limiter, tracker: tracker, banlist: banlist, @@ -199,6 +205,7 @@ func (s *Server) HTTPHandler() http.Handler { // Direct-rail (Robokassa) return + callback routes: the server Result callback (the single // crediting signal, proxied to the backend intake) and the browser Success/Fail redirects. mux.Handle("/pay/robokassa/result", s.robokassaResultHandler()) + mux.Handle("/pay/vk/callback", s.vkCallbackHandler()) mux.Handle("/pay/robokassa/success", s.robokassaReturnHandler("Оплата принята.")) mux.Handle("/pay/robokassa/fail", s.robokassaReturnHandler("Оплата не завершена.")) // The client posts its local-move-preview adoption telemetry here (session-gated). @@ -522,6 +529,46 @@ func (s *Server) robokassaResultHandler() http.Handler { }) } +// vkCallbackHandler proxies a VK Mini Apps payment callback to the backend intake. It rate-limits +// per IP, verifies the VK signature with the app's protected key (the backend holds no VK secret), +// forwards the provider's parameters, and relays the backend's VK response envelope. A missing or +// bad signature is rejected before reaching the backend. +func (s *Server) vkCallbackHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.backend == nil { + http.NotFound(w, r) + return + } + ip := peerIP(r.RemoteAddr, r.Header) + if !s.limiter.Allow("public:"+ip, s.publicPolicy) { + s.noteRateLimited(r.Context(), classPublic, ip, "vk-callback") + http.Error(w, "rate limited", http.StatusTooManyRequests) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + params := make(map[string]string, len(r.Form)) + for k := range r.Form { + params[k] = r.Form.Get(k) + } + if !vkpay.Verify(params, s.vkAppSecret) { + s.log.Warn("vk callback: bad signature") + http.Error(w, "bad signature", http.StatusForbidden) + return + } + resp, err := s.backend.VKCallback(r.Context(), params) + if err != nil { + s.log.Warn("vk callback proxy failed", zap.Error(err)) + http.Error(w, "bad gateway", http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, _ = w.Write(resp) + }) +} + // robokassaReturnHandler serves a minimal self-closing page for Robokassa's Success/Fail browser // return. The payment opens in a separate window, so on return this closes that window and drops the // customer back into the live app (never a cold app start); a link is the fallback if the window diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts index 2fb04fe..1d9647c 100644 --- a/ui/src/lib/vk.ts +++ b/ui/src/lib/vk.ts @@ -182,6 +182,21 @@ export async function vkShowImages(url: string): Promise { } } +/** + * vkShowOrderBox opens VK's payment box for a "голоса" item purchase; item is the order id the + * backend created (VK echoes it to our payment callback, which credits). Resolves true when the + * payment completes, false on cancel/failure or outside VK. The chips are credited by the server + * callback, so the wallet refreshes from the payment push regardless of this result. + */ +export async function vkShowOrderBox(item: string): Promise { + try { + await (await bridge()).send('VKWebAppShowOrderBox', { type: 'item', item }); + return true; + } catch { + return false; + } +} + /** * vkDownloadFile downloads url as filename through the VK client (VKWebAppDownloadFile) — * the mobile in-app file delivery, where the webview ignores . Resolves false diff --git a/ui/src/screens/Wallet.svelte b/ui/src/screens/Wallet.svelte index 8377342..f574533 100644 --- a/ui/src/screens/Wallet.svelte +++ b/ui/src/screens/Wallet.svelte @@ -7,6 +7,7 @@ import { executionContext, formatAmount, needsWebSpendWarning, type SpendContext } from '../lib/wallet'; import { isGooglePlayBuild } from '../lib/distribution'; import { openExternalUrl } from '../lib/links'; + import { vkShowOrderBox } from '../lib/vk'; import type { Wallet, Catalog, CatalogProduct } from '../lib/model'; // The Wallet section: the context-visible chip balances, the active benefits, and the storefront. @@ -107,7 +108,12 @@ busy = true; try { const order = await gateway.walletOrder(p.productId); - openExternalUrl(order.redirectUrl); + if (context === 'vk') { + // VK settles the payment in-app via the bridge; the chips arrive on the server callback. + await vkShowOrderBox(order.orderId); + } else { + openExternalUrl(order.redirectUrl); + } } catch (e) { handleError(e); } finally {