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
+99
View File
@@ -0,0 +1,99 @@
import { expect, test, type Page } from './fixtures';
// The Wallet section against the mock transport (no backend). The mock seeds a web/native (direct)
// account holding a direct + vk chip segment and a small catalog (two chip-priced values and one
// rouble-priced chip pack — see lib/mock/client.ts), so the storefront, the store-compliance
// warning and the Google Play stub are all exercisable without a backend.
async function loginLobby(page: Page): Promise<void> {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await expect(page.getByText('Your turn')).toBeVisible();
}
async function openSettings(page: Page): Promise<void> {
await page.getByRole('button', { name: /Settings/ }).click(); // lobby ⚙️ tab
await expect(page.locator('.pane')).toHaveCount(1); // let the slide settle
}
async function openWallet(page: Page): Promise<void> {
await openSettings(page);
await page.getByRole('button', { name: 'Wallet', exact: true }).click();
await expect(page.getByTestId('wallet')).toBeVisible();
}
test('wallet: balances, benefits and the storefront render for a durable account', async ({ page }) => {
await loginLobby(page);
await openWallet(page);
// Balances: the seeded direct ("Web") and vk segments.
await expect(page.getByRole('heading', { name: 'Balance' })).toBeVisible();
await expect(page.getByText('Web', { exact: true })).toBeVisible();
await expect(page.getByText('VK', { exact: true })).toBeVisible();
// Benefits + the storefront: two values priced in chips, one pack priced in roubles.
await expect(page.getByRole('heading', { name: 'Benefits' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Store' })).toBeVisible();
await expect(page.getByTestId('product')).toHaveCount(3);
const pack = page.locator('[data-kind="pack"]');
await expect(pack).toContainText('₽');
// The pack purchase (money intake) is not wired yet, so its action is the disabled 'Soon'.
await expect(pack.getByRole('button', { name: 'Soon' })).toBeDisabled();
});
test('wallet: the tab sits between Friends and About', async ({ page }) => {
await loginLobby(page);
await openSettings(page);
const labels = await page.locator('.tab .lbl').allInnerTexts();
const iFriends = labels.indexOf('Friends');
const iWallet = labels.indexOf('Wallet');
const iAbout = labels.indexOf('Info');
expect(iFriends).toBeGreaterThanOrEqual(0);
expect(iWallet).toBe(iFriends + 1);
expect(iAbout).toBe(iWallet + 1);
});
test('wallet: a guest has no wallet (nor friends) tab', async ({ page }) => {
await page.goto('/?guest');
await page.getByRole('button', { name: /guest/i }).click();
await expect(page.getByText('Your turn')).toBeVisible();
await openSettings(page);
await expect(page.getByRole('button', { name: 'Wallet', exact: true })).toBeHidden();
await expect(page.getByRole('button', { name: 'Friends', exact: true })).toBeHidden();
});
test('wallet: the Google Play build hides the money purchases behind the RuStore stub', async ({ page }) => {
await page.goto('/?gp');
await page.getByRole('button', { name: /guest/i }).click();
await expect(page.getByText('Your turn')).toBeVisible();
await openWallet(page);
// The chip pack is gone; the stub points at the RuStore build. Spending earned chips still works.
await expect(page.getByTestId('gp-stub')).toBeVisible();
await expect(page.locator('[data-kind="pack"]')).toHaveCount(0);
await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toBeVisible();
});
test('wallet: a web spend that would draw store chips warns first, then completes', async ({ page }) => {
await loginLobby(page);
await openWallet(page);
// The 300-chip value drains direct (120) then reaches into vk (180) → a store segment → warn.
await page.locator('[data-pid="val-noads-30"]').getByTestId('buy').click();
await expect(page.getByTestId('warn')).toBeVisible();
await page.getByTestId('warn-confirm').click();
await expect(page.getByTestId('warn')).toBeHidden();
// The spend applied: the no-ads benefit now shows an end date.
await expect(page.getByTestId('wallet')).toContainText('No ads until');
});
test('wallet: a spend the direct segment covers alone does not warn', async ({ page }) => {
await loginLobby(page);
await openWallet(page);
// The 100-chip value is covered by the direct segment (120) alone → no store draw, no warning.
await page.locator('[data-pid="val-hints-50"]').getByTestId('buy').click();
await expect(page.getByTestId('warn')).toBeHidden();
// The hints benefit rose from 5 to 55.
await expect(page.getByTestId('wallet')).toContainText('Hints 55');
});