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
+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