Files
scrabble-game/ui/e2e/wallet.spec.ts
T
Ilia Denisov 92ba527575
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
feat(payments): settle the direct rail through YooKassa
Replace Robokassa with YooKassa as the RUB direct-rail provider. The wallet
model is untouched: one `direct` segment, the same spend wall, the same
per-channel merchant shops (D42) and `shop` on the order (D44).

The two providers are not shaped alike, and that drives the change:

- Opening a purchase is now an outbound API call (`POST /v3/payments`,
  single-stage capture, redirect confirmation). The order id is both the
  `Idempotence-Key` and `metadata.order_id`, so a retried create cannot mint a
  second payment and a notification always resolves to its order.
- YooKassa does NOT sign notifications, so the body is never evidence: it only
  names a payment, which is re-read with `GET /v3/payments/{id}`, and only that
  answer is acted on. Two guards ride on it — the payment's metadata must name
  the order, and its `test` flag must match the shop's, so a test-shop payment
  can never credit real chips. The sender address is checked against YooKassa's
  published ranges first, which stops a forger turning each fabricated
  notification into an outbound call of ours.
- A notification lost for good would leave the money taken and the chips unowed,
  silently. The existing pending-order reaper now asks the provider about each
  order that reached its expiry age carrying a payment id, and credits the ones
  really paid — one request per order over its whole life, not polling.
- `payment.canceled` records a `failed` event, so a declined payment is finally
  surfaced to the customer as PAYMENTS.md §9 already specified.
- The admin refund moves the money through `POST /v3/refunds` before recording
  anything; a failed call records nothing, so the ledger cannot claim a refund
  that did not happen, and the recorded id is the provider's own.
- YooKassa has no cabinet-side generic receipt: «Чеки от ЮKassa» registers one
  only if the request carries it, so every payment and refund now sends an
  itemized `receipt` to the D36 confirmed email. The VAT rate code is a deploy
  variable; the settlement subject and method are constants.

Robokassa is retired, not deleted: the direct rail falls back to it when no
YooKassa shop is configured and no deployment sets its credentials, so reviving
it is a credentials change rather than a code change. Its variables are removed
from compose, .env.example, write-prod-env.sh and the three workflows, and
recorded in backend/internal/robokassa/README.md together with the cabinet
configuration and the revival steps. Ledger rows keep `provider = 'robokassa'`;
that literal is load-bearing for the idempotency index.

No migration and no wire change: `orders.provider_payment_id` already existed,
and the client is rail-agnostic.

Decisions D47-D51 (revising D41) and stage E12 are baked into the docs.
2026-07-28 08:51:31 +02:00

152 lines
7.2 KiB
TypeScript

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 (120) + vk (400) chip segment and a small catalog (two chip-priced values
// and one rouble-priced chip pack — see lib/mock/client.ts), so the compact balance, the split
// storefront, the exchange-confirm dialog (with its 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: balance, active benefits and the split storefront render for a durable account', async ({ page }) => {
await loginLobby(page);
await openWallet(page);
// The compact balance leads with the context's own (direct/"Web") chips, then the linked vk
// segment behind its logo.
const balance = page.getByTestId('balance');
await expect(balance).toContainText('120');
await expect(balance).toContainText('400');
await expect(balance.locator('.plogo')).toHaveCount(1); // the one linked (vk) platform
// The active benefits (the seeded 5 hints) sit above the store.
await expect(page.getByRole('heading', { name: 'Active' })).toBeVisible();
await expect(page.getByTestId('active')).toContainText('Hints: 5');
await expect(page.getByRole('heading', { name: 'Store' })).toBeVisible();
// Default tab — Buy chips: one rouble-priced pack + the public offer, and no values.
await expect(page.locator('[data-kind="pack"]')).toHaveCount(1);
await expect(page.locator('[data-kind="pack"]')).toContainText('₽');
await expect(page.locator('[data-kind="pack"]').getByTestId('buy-pack')).toBeEnabled();
await expect(page.getByTestId('offer')).toContainText('Public offer');
await expect(page.locator('[data-kind="value"]')).toHaveCount(0);
// Spend chips tab: the two chip-priced values, exchanged (not bought) with the "Exchange" button.
await page.getByTestId('tab-spend').click();
await expect(page.locator('[data-kind="value"]')).toHaveCount(2);
await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toHaveText('Exchange');
});
test('wallet: buying a chip pack opens the provider payment page', async ({ page }) => {
await loginLobby(page);
await openWallet(page);
// The purchase opens the provider's hosted-payment page in a new tab (window.open); capture it.
await page.evaluate(() => {
(window as { __opened?: string }).__opened = '';
window.open = ((u: string) => {
(window as { __opened?: string }).__opened = u;
return null;
}) as typeof window.open;
});
await page.locator('[data-kind="pack"]').getByTestId('buy-pack').click();
await expect
.poll(() => page.evaluate(() => (window as { __opened?: string }).__opened))
.toContain('yookassa');
});
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);
// Buy chips: the chip pack is gone; the stub points at the RuStore build.
await expect(page.getByTestId('gp-stub')).toBeVisible();
await expect(page.locator('[data-kind="pack"]')).toHaveCount(0);
// Spending earned chips still works — the values live in the Spend tab.
await page.getByTestId('tab-spend').click();
await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toBeVisible();
});
test('wallet: the native MVP hides the money purchases behind a neutral note (no store pointer)', async ({ page }) => {
await page.goto('/?nopay');
await page.getByRole('button', { name: /guest/i }).click();
await expect(page.getByText('Your turn')).toBeVisible();
await openWallet(page);
// Buy chips: the chip pack is gone; a neutral note shows and — unlike the Google Play build — it
// carries no RuStore (or any store) pointer.
await expect(page.getByTestId('purchases-hidden')).toBeVisible();
await expect(page.getByTestId('gp-stub')).toHaveCount(0);
await expect(page.locator('[data-kind="pack"]')).toHaveCount(0);
// Spending earned chips still works — the values live in the Spend tab.
await page.getByTestId('tab-spend').click();
await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toBeVisible();
});
test('wallet: a web spend that would draw store chips confirms with a warning, then completes', async ({ page }) => {
await loginLobby(page);
await openWallet(page);
await page.getByTestId('tab-spend').click();
// The 300-chip value drains direct (120) then reaches into vk (180) → a store segment → the confirm
// dialog carries the cross-platform warning.
await page.locator('[data-pid="val-noads-30"]').getByTestId('buy').click();
await expect(page.getByTestId('warn')).toBeVisible();
await page.getByTestId('spend-confirm').click();
// The spend applied: the no-ads benefit now shows an end date in the Active section.
await expect(page.getByTestId('wallet')).toContainText('No ads until');
});
test('wallet: a spend the direct segment covers alone confirms without a warning', async ({ page }) => {
await loginLobby(page);
await openWallet(page);
await page.getByTestId('tab-spend').click();
// The 100-chip value is covered by the direct segment (120) alone → the confirm dialog shows, but
// with no cross-platform warning.
await page.locator('[data-pid="val-hints-50"]').getByTestId('buy').click();
await expect(page.getByTestId('spend-body')).toBeVisible();
await expect(page.getByTestId('warn')).toBeHidden();
await page.getByTestId('spend-confirm').click();
// The hints benefit rose from 5 to 55.
await expect(page.getByTestId('wallet')).toContainText('Hints: 55');
});