Merge pull request 'feat(payments): VK Votes payment rail' (#224) from feature/payment-intake-vk into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 20s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m56s

This commit was merged in pull request #224.
This commit is contained in:
2026-07-09 18:23:24 +00:00
14 changed files with 465 additions and 50 deletions
+7 -2
View File
@@ -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,
@@ -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) {
@@ -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
+9 -4
View File
@@ -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;
+123 -22
View File
@@ -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(&params); 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))
}
}
+1
View File
@@ -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,
+10
View File
@@ -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"`
+65 -18
View File
@@ -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
+40
View File
@@ -0,0 +1,40 @@
// Package vkpay verifies VK Mini Apps payment ("голоса") callback signatures. VK signs each
// payment notification (get_item / order_status_change) and expects the receiving server to verify
// it with the app's secret before acting. The signature is MD5 — mandated by VK's payment protocol,
// not a security choice on our part — so this package uses crypto/md5 deliberately.
package vkpay
import (
"crypto/md5"
"encoding/hex"
"sort"
"strings"
)
// Verify reports whether params carry a valid VK payment signature under secret. VK computes the
// signature as the MD5 of the sig-excluded parameters, sorted alphabetically by name and
// concatenated as key=value with no separators, with the app secret appended. The comparison is
// case-insensitive over the hex digest.
func Verify(params map[string]string, secret string) bool {
got := params["sig"]
if got == "" || secret == "" {
return false
}
keys := make([]string, 0, len(params))
for k := range params {
if k == "sig" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for _, k := range keys {
b.WriteString(k)
b.WriteString("=")
b.WriteString(params[k])
}
b.WriteString(secret)
sum := md5.Sum([]byte(b.String()))
return strings.EqualFold(hex.EncodeToString(sum[:]), got)
}
+74
View File
@@ -0,0 +1,74 @@
package vkpay
import (
"crypto/md5"
"encoding/hex"
"maps"
"sort"
"strings"
"testing"
)
// vkSig computes a valid VK signature the way VK does, for the fixtures (sig excluded, sorted
// key=value concatenation, secret appended, MD5 hex).
func vkSig(params map[string]string, secret string) string {
keys := make([]string, 0, len(params))
for k := range params {
if k == "sig" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for _, k := range keys {
b.WriteString(k + "=" + params[k])
}
b.WriteString(secret)
sum := md5.Sum([]byte(b.String()))
return hex.EncodeToString(sum[:])
}
func clone(m map[string]string) map[string]string {
out := make(map[string]string, len(m))
maps.Copy(out, m)
return out
}
func TestVerify(t *testing.T) {
const secret = "app_secret"
base := map[string]string{
"notification_type": "order_status_change",
"app_id": "123",
"user_id": "456",
"receiver_id": "456",
"order_id": "789",
"date": "1700000000",
"status": "chargeable",
"item": "019f47a0-6f9e-7000-8000-000000000000",
"item_price": "10",
}
valid := clone(base)
// VK sends the digest uppercase; the verifier must accept it case-insensitively.
valid["sig"] = strings.ToUpper(vkSig(base, secret))
if !Verify(valid, secret) {
t.Fatal("valid VK signature rejected")
}
tampered := clone(valid)
tampered["item_price"] = "1"
if Verify(tampered, secret) {
t.Error("accepted a tampered amount")
}
if Verify(valid, "wrong-secret") {
t.Error("accepted under the wrong secret")
}
noSig := clone(valid)
delete(noSig, "sig")
if Verify(noSig, secret) {
t.Error("accepted a callback with no signature")
}
}
+4
View File
@@ -243,6 +243,10 @@ export const en = {
'wallet.empty': 'The store is empty for now',
'wallet.buy': 'Buy',
'wallet.offer': 'Public offer',
'wallet.platformNoBuy': 'Purchases are not available on this platform',
'wallet.iosBlockedPre': 'Purchases on iOS are prohibited by Apple policy. Try the ',
'wallet.iosBlockedLink': 'other version',
'wallet.iosBlockedPost': ' of the game.',
'wallet.gpStub': 'To buy chips, install the RuStore build.',
'wallet.cur.RUB': '₽',
'wallet.cur.VOTE': 'votes',
+4
View File
@@ -243,6 +243,10 @@ export const ru: Record<MessageKey, string> = {
'wallet.empty': 'Магазин пока пуст',
'wallet.buy': 'Купить',
'wallet.offer': 'Публичная оферта',
'wallet.platformNoBuy': 'На данной платформе покупки невозможны',
'wallet.iosBlockedPre': 'Покупки на iOS запрещены политикой Apple. Попробуйте воспользоваться ',
'wallet.iosBlockedLink': 'другой версией',
'wallet.iosBlockedPost': ' игры.',
'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.',
'wallet.cur.RUB': '₽',
'wallet.cur.VOTE': 'голосов',
+15
View File
@@ -182,6 +182,21 @@ export async function vkShowImages(url: string): Promise<boolean> {
}
}
/**
* 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<boolean> {
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 <a download>. Resolves false
+42 -4
View File
@@ -1,12 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte';
import Modal from '../components/Modal.svelte';
import { app, handleError } from '../lib/app.svelte';
import { app, handleError, showToast } from '../lib/app.svelte';
import { gateway } from '../lib/gateway';
import { normalizeSubtype } from '../lib/platform';
import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte';
import { executionContext, formatAmount, needsWebSpendWarning, type SpendContext } from '../lib/wallet';
import { isGooglePlayBuild } from '../lib/distribution';
import { openExternalUrl } from '../lib/links';
import { onExternalLinkClick, openExternalUrl } from '../lib/links';
import { vkPlatform, 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.
@@ -24,6 +26,10 @@
const context: SpendContext = executionContext();
const gpBuild = isGooglePlayBuild();
// Money purchases are not permitted inside the VK iOS app (Apple ToS); the pack CTA is shown
// muted and taps explain why, rather than hiding the storefront. VK's device family is not on the
// client platformSubtype (server-derived) — read it from the signed vk_platform launch param.
const purchaseBlocked = context === 'vk' && normalizeSubtype(vkPlatform()) === 'ios';
const values = $derived(catalog?.products.filter((p) => p.kind === 'value') ?? []);
const packs = $derived(catalog?.products.filter((p) => p.kind === 'pack') ?? []);
@@ -107,7 +113,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 {
@@ -141,6 +152,10 @@
<section>
<h3>{t('wallet.store')}</h3>
{#if purchaseBlocked}
<!-- prettier-ignore -->
<p class="ios-note" data-testid="ios-note">{t('wallet.iosBlockedPre')}<a href="https://erudit-game.ru/" target="_blank" rel="noopener noreferrer" onclick={onExternalLinkClick}>{t('wallet.iosBlockedLink')}</a>{t('wallet.iosBlockedPost')}</p>
{/if}
{#each values as p (p.productId)}
<div class="row product" data-testid="product" data-kind="value" data-pid={p.productId}>
<span class="name">{p.title}</span>
@@ -156,7 +171,16 @@
<div class="row product" data-testid="product" data-kind="pack" data-pid={p.productId}>
<span class="name">{p.title}</span>
<span class="price">{formatAmount(p.moneyAmount, p.moneyCurrency)}&nbsp;{currencyLabel(p.moneyCurrency)}</span>
<button class="buy" data-testid="buy-pack" disabled={busy} onclick={() => onOrder(p)}>{t('wallet.buy')}</button>
<button
class="buy"
class:blocked={purchaseBlocked}
data-testid="buy-pack"
disabled={busy}
onclick={() => {
if (purchaseBlocked) showToast(t('wallet.platformNoBuy'));
else void onOrder(p);
}}>{t('wallet.buy')}</button
>
</div>
{/each}
{#if packs.length > 0}
@@ -238,6 +262,20 @@
opacity: 0.5;
cursor: default;
}
.buy.blocked {
opacity: 0.5;
}
.ios-note {
margin: 0 0 4px;
padding: 8px 12px;
color: var(--text-muted);
font-size: 0.85rem;
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
}
.ios-note a {
color: var(--accent);
}
.empty,
.stub {
margin: 0;