feat(payments): wallet screen with balances, benefits and storefront
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s

Add the "Кошелёк" section to the settings hub: context-visible chip
balances, active benefits (no-ads term/forever, hints) and a storefront of
chip-priced values and money-priced chip packs. Guests have no wallet; the
Google Play build hides the money purchases behind a RuStore stub; a web
purchase that would draw VK/Telegram chips warns first.

Add the catalog read path the storefront needs — a context-projected
GET /api/v1/user/wallet/catalog (payments service + store, gateway op
wallet.catalog, FBS Catalog/CatalogProduct/CatalogAtom, client decode) —
plus the client leg for the existing wallet.get/buy ops. Value spends reuse
the existing spend path; the chip-pack purchase (money order flow) arrives
with payment intake, so its action is a disabled placeholder for now.

Covered by Go unit (catalog projection) + integration (/wallet/catalog over
Postgres), vitest (formatting, spendable selection, web-spend warning, GP
flag, codec + gateway encode round-trips) and Playwright mock e2e (render,
guest-hidden, GP stub, warning) on Chromium + WebKit.
This commit is contained in:
Ilia Denisov
2026-07-08 12:31:38 +02:00
parent 41f4fd3497
commit 4aa6174968
41 changed files with 2090 additions and 39 deletions
+13
View File
@@ -34,6 +34,8 @@ import type {
Tile,
Variant,
WordCheckResult,
Wallet,
Catalog,
} from './model';
/** GatewayError carries a stable code (the gateway result_code, or an edge code). */
@@ -135,6 +137,17 @@ export interface GatewayClient {
/** feedbackUnread reports whether an operator reply awaits delivery (the badge). */
feedbackUnread(): Promise<boolean>;
// --- wallet ---
/** wallet returns the caller's chip segments and active benefits, visible in their current
* trusted execution context (view-only when the platform is untrusted or VK-iOS frozen). */
wallet(): Promise<Wallet>;
/** catalog returns the storefront for the caller's context: chip-priced values and the chip
* packs priced in the context's payment method. */
catalog(): Promise<Catalog>;
/** walletBuy spends chips on a chip-priced value and returns the updated wallet. Gate-checked
* server-side: an untrusted/frozen context or an insufficient balance is refused. */
walletBuy(productId: string): Promise<Wallet>;
// --- friends ---
friendsList(): Promise<AccountRef[]>;
friendsIncoming(): Promise<AccountRef[]>;
+49
View File
@@ -15,6 +15,7 @@ import {
decodeOutgoingList,
decodeProfile,
decodeWallet,
decodeCatalog,
encodeWalletBuy,
decodeSession,
decodeFeedbackState,
@@ -72,6 +73,54 @@ describe('codec', () => {
expect(w.hints).toBe(5);
});
it('round-trips the catalog view (value + pack)', () => {
const b = new Builder(256);
// a chip-priced value with one atom
const vKind = b.createString('value');
const vId = b.createString('val-1');
const vTitle = b.createString('50 hints');
const aType = b.createString('hints');
fb.CatalogAtom.startCatalogAtom(b);
fb.CatalogAtom.addAtomType(b, aType);
fb.CatalogAtom.addQuantity(b, 50);
const atom = fb.CatalogAtom.endCatalogAtom(b);
const vAtoms = fb.CatalogProduct.createAtomsVector(b, [atom]);
fb.CatalogProduct.startCatalogProduct(b);
fb.CatalogProduct.addKind(b, vKind);
fb.CatalogProduct.addProductId(b, vId);
fb.CatalogProduct.addTitle(b, vTitle);
fb.CatalogProduct.addChips(b, 100);
fb.CatalogProduct.addMoneyAmount(b, BigInt(0));
fb.CatalogProduct.addAtoms(b, vAtoms);
const value = fb.CatalogProduct.endCatalogProduct(b);
// a RUB-priced chip pack (no atoms exercised)
const pKind = b.createString('pack');
const pId = b.createString('pack-1');
const pTitle = b.createString('100 chips');
const pCur = b.createString('RUB');
fb.CatalogProduct.startCatalogProduct(b);
fb.CatalogProduct.addKind(b, pKind);
fb.CatalogProduct.addProductId(b, pId);
fb.CatalogProduct.addTitle(b, pTitle);
fb.CatalogProduct.addChips(b, 0);
fb.CatalogProduct.addMoneyAmount(b, BigInt(14900));
fb.CatalogProduct.addMoneyCurrency(b, pCur);
const pack = fb.CatalogProduct.endCatalogProduct(b);
const prods = fb.Catalog.createProductsVector(b, [value, pack]);
fb.Catalog.startCatalog(b);
fb.Catalog.addProducts(b, prods);
b.finish(fb.Catalog.endCatalog(b));
const c = decodeCatalog(b.asUint8Array());
expect(c.products).toEqual([
{ kind: 'value', productId: 'val-1', title: '50 hints', chips: 100, moneyAmount: 0, moneyCurrency: '', atoms: [{ atomType: 'hints', quantity: 50 }] },
{ kind: 'pack', productId: 'pack-1', title: '100 chips', chips: 0, moneyAmount: 14900, moneyCurrency: 'RUB', atoms: [] },
]);
});
it('round-trips the export-url request and response', () => {
const req = fb.ExportUrlRequest.getRootAsExportUrlRequest(
new ByteBuffer(
+30
View File
@@ -40,6 +40,9 @@ import type {
Profile,
Wallet,
WalletSegment,
Catalog,
CatalogProduct,
CatalogAtom,
ProfileUpdate,
PushEvent,
Seat,
@@ -388,6 +391,33 @@ export function decodeWallet(buf: Uint8Array): Wallet {
};
}
// decodeCatalog reads the storefront payload: the context-visible products, each a chip-priced
// value or a chip pack priced in the context's payment method, with its atom composition.
export function decodeCatalog(buf: Uint8Array): Catalog {
const c = fb.Catalog.getRootAsCatalog(new ByteBuffer(buf));
const products: CatalogProduct[] = [];
for (let i = 0; i < c.productsLength(); i++) {
const p = c.products(i);
if (!p) continue;
const atoms: CatalogAtom[] = [];
for (let j = 0; j < p.atomsLength(); j++) {
const a = p.atoms(j);
if (!a) continue;
atoms.push({ atomType: s(a.atomType()), quantity: a.quantity() });
}
products.push({
kind: s(p.kind()) === 'pack' ? 'pack' : 'value',
productId: s(p.productId()),
title: s(p.title()),
chips: p.chips(),
moneyAmount: Number(p.moneyAmount()),
moneyCurrency: s(p.moneyCurrency()),
atoms,
});
}
return { products };
}
export function decodeProfile(buf: Uint8Array): Profile {
const p = fb.Profile.getRootAsProfile(new ByteBuffer(buf));
return {
+10
View File
@@ -0,0 +1,10 @@
import { describe, it, expect } from 'vitest';
import { isGooglePlayBuild } from './distribution';
describe('isGooglePlayBuild', () => {
it('is false by default (no VITE_GP_BUILD flag, not the mock ?gp force)', () => {
// The unit env is neither the Google Play build nor the mock ?gp force, so the normal purchase
// actions show. The Google Play stub and the mock force are covered by the Playwright e2e.
expect(isGooglePlayBuild()).toBe(false);
});
});
+21
View File
@@ -0,0 +1,21 @@
// Distribution channel of the native build. Google Play forbids selling in-app currency through
// an external gate, so the Google Play build hides the purchase actions and shows a stub pointing
// the user to the RuStore build; every other build (web, VK, Telegram, RuStore native) sells
// normally. The channel is a build-time flag the Google Play build sets, not a runtime guess.
/**
* isGooglePlayBuild reports whether this is the Google Play native build, where in-app-currency
* purchases are hidden. It reads the build-time flag VITE_GP_BUILD (set to "1" only by the Google
* Play build); every other build reads false. Under the mock e2e it can be forced on with a `?gp`
* query parameter so the stub path is exercisable without a separate build.
*/
export function isGooglePlayBuild(): boolean {
if (
import.meta.env.MODE === 'mock' &&
typeof window !== 'undefined' &&
new URLSearchParams(window.location.search).has('gp')
) {
return true;
}
return (import.meta.env as Record<string, string | undefined>).VITE_GP_BUILD === '1';
}
+27
View File
@@ -225,6 +225,33 @@ export const en = {
'offline.promptYes': 'Enable',
'offline.promptNo': 'Keep trying',
// --- wallet ---
'wallet.title': 'Wallet',
'wallet.tab': 'Wallet',
'wallet.balance': 'Balance',
'wallet.noChips': 'No chips yet',
'wallet.source.vk': 'VK',
'wallet.source.telegram': 'Telegram',
'wallet.source.direct': 'Web',
'wallet.viewOnly': 'view only',
'wallet.benefits': 'Benefits',
'wallet.noAdsUntil': 'No ads until {date}',
'wallet.noAdsForever': 'No ads forever',
'wallet.adsOn': 'Ads are on',
'wallet.hints': 'Hints {n}',
'wallet.store': 'Store',
'wallet.empty': 'The store is empty for now',
'wallet.buy': 'Buy',
'wallet.soon': 'Soon',
'wallet.gpStub': 'To buy chips, install the RuStore build.',
'wallet.cur.RUB': '₽',
'wallet.cur.VOTE': 'votes',
'wallet.cur.XTR': '⭐',
'wallet.warnTitle': 'Web-only purchase',
'wallet.warnBody':
'This spends your VK/Telegram chips, and the benefit will work on the web and in the app only — because of store rules.',
'wallet.warnConfirm': 'Continue',
'wallet.warnCancel': 'Cancel',
'about.title': 'About',
'about.tab': 'Info',
'about.description': 'A multiplatform Scrabble game.',
+27
View File
@@ -225,6 +225,33 @@ export const ru: Record<MessageKey, string> = {
'offline.promptYes': 'Включить',
'offline.promptNo': 'Ждать сеть',
// --- wallet ---
'wallet.title': 'Кошелёк',
'wallet.tab': 'Кошелёк',
'wallet.balance': 'Баланс',
'wallet.noChips': 'Пока нет фишек',
'wallet.source.vk': 'VK',
'wallet.source.telegram': 'Telegram',
'wallet.source.direct': 'Веб',
'wallet.viewOnly': 'просмотр',
'wallet.benefits': 'Блага',
'wallet.noAdsUntil': 'Без рекламы до {date}',
'wallet.noAdsForever': 'Без рекламы навсегда',
'wallet.adsOn': 'Реклама включена',
'wallet.hints': 'Подсказки {n}',
'wallet.store': 'Магазин',
'wallet.empty': 'Магазин пока пуст',
'wallet.buy': 'Купить',
'wallet.soon': 'Скоро',
'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.',
'wallet.cur.RUB': '₽',
'wallet.cur.VOTE': 'голосов',
'wallet.cur.XTR': '⭐',
'wallet.warnTitle': 'Покупка только для веба',
'wallet.warnBody':
'Оплата спишет фишки VK/Telegram, и благо будет работать только в вебе и приложении — из-за правил магазинов.',
'wallet.warnConfirm': 'Продолжить',
'wallet.warnCancel': 'Отмена',
'about.title': 'О программе',
'about.tab': 'Инфо',
'about.description': 'Мультиплатформенная игра в «Эрудит».',
+67
View File
@@ -40,6 +40,8 @@ import type {
Stats,
Variant,
WordCheckResult,
Wallet,
Catalog,
} from '../model';
import { valueForLetter } from '../alphabet';
import { seedMockAlphabets } from './alphabet';
@@ -112,10 +114,37 @@ export class MockGateway implements GatewayClient {
private readonly stats: Stats = { ...MOCK_STATS };
private readonly drafts = new Map<string, string>();
// The mock chip wallet: a web/native (direct) context holding a direct and a vk segment, so the
// storefront, the priority draw (direct→vk→tg) and the web-spend warning (a value whose price
// reaches into the vk segment) are all exercisable without a backend.
private readonly mockWallet: Wallet = {
segments: [
{ source: 'direct', chips: 120, spendable: true },
{ source: 'vk', chips: 400, spendable: true },
],
adsForever: false,
adsPaidUntilMs: 0,
hints: 5,
};
// The mock storefront: two chip-priced values (one cheap enough to draw direct only, one that
// reaches into vk → the warning) and one RUB-priced chip pack (the direct-context method).
private readonly mockCatalog: Catalog = {
products: [
{ kind: 'value', productId: 'val-hints-50', title: '50 подсказок', chips: 100, moneyAmount: 0, moneyCurrency: '', atoms: [{ atomType: 'hints', quantity: 50 }] },
{ kind: 'value', productId: 'val-noads-30', title: 'Без рекламы: 30 дней', chips: 300, moneyAmount: 0, moneyCurrency: '', atoms: [{ atomType: 'noads_days', quantity: 30 }] },
{ kind: 'pack', productId: 'pack-chips-100', title: '100 фишек', chips: 0, moneyAmount: 14900, moneyCurrency: 'RUB', atoms: [{ atomType: 'chips', quantity: 100 }] },
],
};
constructor() {
// Seed the per-variant alphabet cache the rack, blank chooser and scoring read, so the
// mock-driven UI is alphabet-agnostic without a backend.
seedMockAlphabets();
// e2e seam: `?guest` seeds a guest profile so the durable-only surfaces (Friends, Wallet) can
// be asserted hidden. The mock profile is otherwise a durable account.
if (typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('guest')) {
this.profile.isGuest = true;
}
}
setToken(_token: string | null): void {
@@ -166,6 +195,44 @@ export class MockGateway implements GatewayClient {
// The mock never blocks; the blocked screen is driven directly via the mock-mode __block seam.
return { blocked: false, permanent: false, until: '', reason: '' };
}
// --- wallet ---
async wallet(): Promise<Wallet> {
return this.cloneWallet();
}
async catalog(): Promise<Catalog> {
return { products: this.mockCatalog.products.map((p) => ({ ...p, atoms: p.atoms.map((a) => ({ ...a })) })) };
}
async walletBuy(productId: string): Promise<Wallet> {
const p = this.mockCatalog.products.find((x) => x.productId === productId);
if (!p || p.kind !== 'value') throw new GatewayError('product_not_found');
// Draw the chip price across the segments by priority direct→vk→tg, mirroring the backend.
let remaining = p.chips;
for (const src of ['direct', 'vk', 'telegram'] as const) {
if (remaining <= 0) break;
const seg = this.mockWallet.segments.find((s) => s.source === src);
if (!seg) continue;
const take = Math.min(seg.chips, remaining);
seg.chips -= take;
remaining -= take;
}
if (remaining > 0) throw new GatewayError('insufficient_chips');
for (const a of p.atoms) {
if (a.atomType === 'hints') this.mockWallet.hints += a.quantity;
if (a.atomType === 'noads_days') {
this.mockWallet.adsPaidUntilMs = Math.max(Date.now(), this.mockWallet.adsPaidUntilMs) + a.quantity * 86_400_000;
}
}
return this.cloneWallet();
}
private cloneWallet(): Wallet {
return {
segments: this.mockWallet.segments.map((s) => ({ ...s })),
adsForever: this.mockWallet.adsForever,
adsPaidUntilMs: this.mockWallet.adsPaidUntilMs,
hints: this.mockWallet.hints,
};
}
async fetchDict(variant: Variant, _version: string, opts?: { signal?: AbortSignal; reload?: boolean }): Promise<ArrayBuffer> {
// The offline e2e serves the real per-variant dawgs from the preview build's /e2edict/ (copied
// in by scripts/e2e-dict.mjs from the scrabble-dictionary release, never committed), so a local
+26
View File
@@ -170,6 +170,32 @@ export interface Wallet {
hints: number;
}
/** One atom line of a storefront product: the base value type it grants ("chips"/"hints"/
* "noads_days"/"tournament") and how many of it the product carries. */
export interface CatalogAtom {
atomType: string;
quantity: number;
}
/** One storefront product for the caller's context. A "value" is bought with chips (chips is its
* uniform price, money fields zero); a "pack" funds chips with money (moneyAmount is its price in
* the context currency's minor units under moneyCurrency, chips zero). atoms lists what it grants. */
export interface CatalogProduct {
kind: 'value' | 'pack';
productId: string;
title: string;
chips: number;
moneyAmount: number;
moneyCurrency: string;
atoms: CatalogAtom[];
}
/** The storefront: the products visible and purchasable in the caller's context — the chip-priced
* values and the chip packs priced in the context's payment method. */
export interface Catalog {
products: CatalogProduct[];
}
export interface Profile {
userId: string;
displayName: string;
+3
View File
@@ -13,6 +13,7 @@ export type RouteName =
| 'settings'
| 'about'
| 'friends'
| 'wallet'
| 'feedback'
| 'stats'
| 'confirm'
@@ -55,6 +56,8 @@ export function parse(hash: string): Route {
return { name: 'about', params: {} };
case 'friends':
return { name: 'friends', params: {} };
case 'wallet':
return { name: 'wallet', params: {} };
case 'feedback':
return { name: 'feedback', params: {} };
case 'stats':
+10
View File
@@ -228,6 +228,16 @@ export function createTransport(baseUrl: string): GatewayClient {
return codec.decodeFeedbackUnread(await exec('feedback.unread', codec.empty()));
},
async wallet() {
return codec.decodeWallet(await exec('wallet.get', codec.empty()));
},
async catalog() {
return codec.decodeCatalog(await exec('wallet.catalog', codec.empty()));
},
async walletBuy(productId: string) {
return codec.decodeWallet(await exec('wallet.buy', codec.encodeWalletBuy(productId)));
},
async friendsList() {
return codec.decodeFriendList(await exec('friends.list', codec.empty()));
},
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import type { Wallet, WalletSegment } from './model';
import { majorAmount, formatAmount, spendableChips, needsWebSpendWarning } from './wallet';
function seg(source: string, chips: number, spendable = true): WalletSegment {
return { source, chips, spendable };
}
function wallet(segments: WalletSegment[]): Wallet {
return { segments, adsForever: false, adsPaidUntilMs: 0, hints: 0 };
}
describe('money formatting', () => {
it('scales roubles by 100 and leaves whole-unit currencies as-is', () => {
expect(majorAmount(14900, 'RUB')).toBe(149);
expect(majorAmount(20, 'VOTE')).toBe(20);
expect(majorAmount(25, 'XTR')).toBe(25);
});
it('formats roubles with two decimals and whole-unit currencies as integers', () => {
expect(formatAmount(14900, 'RUB')).toBe('149.00');
expect(formatAmount(15050, 'RUB')).toBe('150.50');
expect(formatAmount(20, 'VOTE')).toBe('20');
expect(formatAmount(25, 'XTR')).toBe('25');
});
});
describe('spendableChips', () => {
it('sums only the spendable segments', () => {
expect(spendableChips(wallet([seg('direct', 100), seg('vk', 50)]))).toBe(150);
expect(spendableChips(wallet([seg('direct', 100, false), seg('vk', 50, false)]))).toBe(0);
expect(spendableChips(wallet([seg('direct', 100), seg('vk', 50, false)]))).toBe(100);
});
});
describe('needsWebSpendWarning', () => {
it('does not warn when the direct segment alone covers the price', () => {
expect(needsWebSpendWarning('direct', [seg('direct', 200), seg('vk', 400)], 150)).toBe(false);
});
it('warns when the priority draw reaches into a store (vk/tg) segment', () => {
// direct 120 + vk covers the rest of a 300 price → touches vk
expect(needsWebSpendWarning('direct', [seg('direct', 120), seg('vk', 400)], 300)).toBe(true);
});
it('warns when only a store segment is spendable and it covers the price', () => {
expect(needsWebSpendWarning('direct', [seg('vk', 400)], 100)).toBe(true);
expect(needsWebSpendWarning('direct', [seg('telegram', 400)], 100)).toBe(true);
});
it('does not warn when the price exceeds all spendable chips (buy is disabled anyway)', () => {
expect(needsWebSpendWarning('direct', [seg('direct', 120), seg('vk', 100)], 500)).toBe(false);
});
it('never warns inside a VK or Telegram context (spend stays in the same store segment)', () => {
expect(needsWebSpendWarning('vk', [seg('vk', 400)], 100)).toBe(false);
expect(needsWebSpendWarning('telegram', [seg('telegram', 400)], 100)).toBe(false);
});
it('ignores non-spendable segments (a frozen or untrusted wallet never warns)', () => {
expect(needsWebSpendWarning('direct', [seg('vk', 400, false)], 100)).toBe(false);
});
});
+78
View File
@@ -0,0 +1,78 @@
// Wallet storefront logic, kept pure and out of the .svelte screen so it unit-tests in the node
// environment: money/price formatting, the spendable-segment selection, and the web-spend warning
// trigger. The server owns the real store-compliance gate; these helpers drive display only.
import type { Wallet, WalletSegment } from './model';
import { insideVK } from './vk';
import { insideTelegram } from './telegram';
/** The client's execution context for the wallet UI, mirroring the server's platform kind. */
export type SpendContext = 'vk' | 'telegram' | 'direct';
/**
* executionContext reports the client's best-effort context: a VK Mini App, a Telegram Mini App,
* else a web/native (direct) session. It only drives the display-only web-spend warning; the
* server enforces the real gate on every spend (fail-closed), never trusting this.
*/
export function executionContext(): SpendContext {
if (insideVK()) return 'vk';
if (insideTelegram()) return 'telegram';
return 'direct';
}
/**
* majorAmount converts a money amount in a currency's minor units to its major unit — the rouble
* has 100 kopecks; Votes and Stars are whole units (scale 1).
*/
export function majorAmount(minor: number, currency: string): number {
return minor / (currency === 'RUB' ? 100 : 1);
}
/**
* formatAmount renders a money price in major units: two fractional digits for the rouble, a whole
* number for the whole-unit currencies (Votes / Stars). The currency label is added by the caller
* (it is localized), so this stays a pure number formatter.
*/
export function formatAmount(minor: number, currency: string): string {
return currency === 'RUB' ? (minor / 100).toFixed(2) : String(minor);
}
/**
* spendableChips totals the chips the player can actually spend in the current context — the sum of
* the segments the server marked spendable (zero for a VK-iOS-frozen or untrusted wallet, where no
* segment is spendable).
*/
export function spendableChips(w: Wallet): number {
return w.segments.reduce((sum, s) => sum + (s.spendable ? s.chips : 0), 0);
}
// drawPriority is the segment draw order on the web, mirroring the backend spend: the home direct
// segment first, then the store-funded segments.
const drawPriority: readonly string[] = ['direct', 'vk', 'telegram'];
/**
* needsWebSpendWarning reports whether buying a chip-priced value in the current context would draw
* store-funded (vk / telegram) chips — the case the UI must warn about, because the benefit it buys
* (origin=direct) is then usable on the web/native only, by the store-compliance rule. It fires only
* in a direct context and only when the priority draw (direct→vk→tg) actually reaches a store
* segment to cover the price; a spend that the direct segment covers alone, or a spend in a VK/
* Telegram context (which stays within the same store segment), never warns.
*/
export function needsWebSpendWarning(
context: SpendContext,
segments: WalletSegment[],
chipPrice: number,
): boolean {
if (context !== 'direct') return false;
let remaining = chipPrice;
let reachesStore = false;
for (const src of drawPriority) {
if (remaining <= 0) break;
const seg = segments.find((s) => s.source === src && s.spendable);
if (!seg || seg.chips <= 0) continue;
const take = Math.min(seg.chips, remaining);
if (src !== 'direct' && take > 0) reachesStore = true;
remaining -= take;
}
return remaining <= 0 && reachesStore;
}