# PLAN — monetization implementation

Technical, step-by-step implementation of the monetization domain. Business mechanics and
the rationale for every rule live in [`docs/PAYMENTS.md`](docs/PAYMENTS.md) (RU mirror
[`docs/PAYMENTS_ru.md`](docs/PAYMENTS_ru.md)); the frozen owner agreements they both derive
from (the `D1`–`D51` decisions log) live in
[`docs/PAYMENTS_DECISIONS_ru.md`](docs/PAYMENTS_DECISIONS_ru.md) — the authority when a rule
is disputed. This file is the *how*. Each stage is written to be self-sufficient: returning
to it gives full context — goal, exact touch-points, tests, done-criteria, and current
status — without re-deriving decisions.

## How to use this file

- **Stage granularity (E0–E12) is fixed.** Do not split or merge stages. Plan each stage
  densely enough to execute whole.
- **Never leak stage ids into the product.** Code, comments, commit messages, PR
  titles/descriptions must read as finalized feature copy — never "E5", "stage 3", etc.
  Stage ids exist only in this file.
- **Deviations are allowed** when new unknowns surface, but the plan is then edited **whole
  and in agreement** (not piecemeal), keeping it coherent, and `docs/PAYMENTS.md` updated in
  step.
- **Status marks:** each stage header carries `Status: TODO | WIP | DONE`. When a stage
  lands, flip it to DONE and note the PR. The **Progress** table is the at-a-glance index.
- **Per-change discipline** (repo `CLAUDE.md`): update tests at the layers `docs/TESTING.md`
  calls out, bake doc updates into the same PR, run local full verification before pushing,
  feature branch → PR into `development`.

## Progress

| Stage | Title | Release | Status |
|-------|-------|---------|--------|
| E0 | Payments data foundation | 1 | DONE |
| E1 | Trusted platform signal | 1 | DONE |
| E2 | Currency + benefit core | 1 | DONE |
| E3 | Wallet UI | 1 | DONE |
| E4 | Durability (PITR) | 2 | DONE |
| E5 | Payment intake | 2 | DONE |
| E6 | Ads | 2 | DONE |
| E7 | Admin, reports & catalog | 2 | DONE |
| E8 | Guest limits | — | DONE |
| E9 | Tournament fee | future | TODO |
| E10 | Multi-shop direct rail + ИП fiscalization | 2+ | DONE |
| E11 | Payment availability kill switch + per-account override | 2 | DONE |
| E12 | YooKassa direct rail (Robokassa retired) | 2+ | DONE |

**Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3).
**Release 2** = money (E4→E5→E6→E7). E8 is standalone (game-behaviour change, can run in
parallel). E9 is future. E10 splits the `direct` rail into per-channel Robokassa shops + ИП
fiscalization (post-launch, backend-first).

---

## Architecture baseline (applies to all stages)

Read once; individual stages assume it.

### Schema & isolation

- The payments domain owns its **own Postgres schema `payments`** in the shared instance
  (`scrabble` DB). All payments tables are `payments.*`. `backend` schema is untouched
  except for the deprecation of two legacy columns (E2).
- **DB role.** A dedicated **NOLOGIN** role holds ALL privileges on `payments.*` and
  **nothing** on `backend` — grant-based confinement, asserted by a `SET ROLE` isolation
  test. The application still connects on its single (superuser) pool, so these grants are a
  stepping-stone to a real separate login/process, not the runtime wall. The **runtime wall
  is code-level**: only the `internal/payments` package imports the payments jet code and
  issues `payments.*` SQL (an **import-boundary test** enforces it); every other domain
  reaches payments through the narrow Go interface.
- **No cross-schema link at all.** There is **no** foreign key from `payments.*` to
  `backend.accounts`: `account_id` is a plain `uuid`, kept referentially honest in code and
  joined to the tombstoned account / `retained_identities` dossier by the stable id. This
  keeps the spend transaction (ledger INSERT + balance UPDATE + benefit UPDATE) fully
  **within `payments`** and the domain extractable into its own database later. Benefits move
  **into** `payments` (they no longer live on `accounts` — see E2); game code reads them
  through the payments **Go interface**, never via SQL.
- **Money.** Monetary amounts are a single **`bigint` in the currency's minor units** (RUB
  kopecks; Votes/Stars/chips are whole units, scale 1) carried in Go by the `payments.Money`
  value type — the sole constructor/formatter/arithmetic, so **no `float64` ever touches an
  amount** and a whole-unit currency structurally cannot hold a fraction. `chip_rate` is not a
  table: a chip pack is a product, so its per-method rate **is** `product_price`.

### Domain boundary

- New package `backend/internal/payments/` — `payments.go` (types + `Service`),
  `store.go` (`type Store struct{ db *sql.DB }`, go-jet against `internal/postgres/jet/
  payments`), following the `ads` domain shape (`backend/internal/ads/{ads,service,store}.go`).
- Wired in `backend/cmd/backend/main.go` `run()` (construct after `account`, before
  `server.New`), handed into `server.Deps` (`server.go`), routes registered in
  `registerRoutes` gated `if s.payments != nil`, admin section in `registerConsole` gated
  `if s.payments != nil`.
- Game/other domains depend on payments only through a **narrow interface** (e.g.
  `AdFree(ctx, account, platform, present) bool`, `SpendHint(ctx, account, platform, present)
  error`, `Balances(ctx, account, platform, present) …`), where `present` is the set of
  identity sources the account currently holds (`vk`→vk, `telegram`→telegram, `email`→direct;
  `robot` ignored), computed by the caller from `account.Identities` — payments carries no
  cross-schema identity knowledge, so unlink/re-link availability (D14) falls out live. Keep
  the surface small; this is the seam that lets payments become a separate process later
  without touching callers.
- **Read model.** Hot reads (`AdFree`/`HintsAvailable`/`Balances` and the gate) are served
  from an in-process, account-keyed, write-through cache inside the payments package
  (mirroring `account/suspension.go`), invalidated on every payments mutation
  (spend/grant/fund/refund/merge); the payments DB is touched only on a cache miss, so the
  steady-state hot path issues no query to the `payments` schema. The D39 materialized
  `balances`/`benefits` tables stay the in-transaction write target the cache fronts.
  Single-instance, matching the deployment (a multi-instance backend would need a shared
  cache).

### Migrations & codegen

- Migrations: same goose dir `backend/internal/postgres/migrations/`, sequential
  `0000N_*.sql`, `-- +goose Up/Down`, schema-qualified, **expand-contract mandatory**
  (image rollback must stay DB-safe). First payments migration does `CREATE SCHEMA IF NOT
  EXISTS payments`, creates the role, grants.
- **jetgen** (`backend/cmd/jetgen/main.go`) is currently hardwired to `schema=backend`.
  Extend it to also generate `schema=payments` into `internal/postgres/jet/payments/
  {model,table}` (committed). Regenerate only after a payments migration; revert unrelated
  `backend`-schema churn (jetgen may reorder untouched tables — commit only intended diffs).
- Transactions: reuse the local `withTx(ctx, db, fn)` idiom. Balance/benefit mutations are
  guarded single-row atomic UPDATEs (mirror `account.SpendHint`); the ledger INSERT +
  materialized-cache UPDATE go in one `withTx`. Default Read-Committed is sufficient given
  the guarded updates + unique idempotency keys; do not reach for Serializable without a
  demonstrated need.

### Transport

- client ↔ gateway: Connect-RPC + FlatBuffers (h2c). gateway ↔ backend: REST/JSON +
  `X-User-ID` via `gateway/internal/backendclient`. Any user-facing payments call needs the
  full chain: backend REST handler (`u := s.user` group) → `backendclient` method →
  Connect-RPC method + transcode (`gateway/internal/transcode/`) → FlatBuffers schema
  (`pkg/fbs/…`, regen with `make -C pkg fbs` + `pnpm -C ui codegen`).
- Provider webhooks (Robokassa/VK) do NOT use the Connect path — model on the existing
  public HMAC-signed route `s.public.GET("/dl/:id/:kind", …)` (`handlers.go`): a public
  route with signature verification, proxied by the gateway/Caddy `@gateway` matcher
  (`deploy/caddy/Caddyfile` — a new edge route MUST be added there or it falls to the
  landing catch-all).

### Testing layers (`docs/TESTING.md`)

- **unit** (Go `_test.go`, TS vitest): gate-by-context, no-ads stacking, priority draw,
  rate math, idempotency keys, catalog snapshotting. UI logic must be extracted out of
  `.svelte` into `ui/src/lib/*` to be unit-testable.
- **integration** (`backend/internal/inttest/`, `//go:build integration`, Postgres-backed):
  atomic chip↔benefit spend, callback idempotency, order-flow, TG outbox delivery, merge/
  unlink of segments.
- **UI** (Playwright mock e2e, Chromium+WebKit): Wallet screen, warnings, guest-hidden, GP
  stub. Mock e2e bypasses codec — wire bugs need codec unit tests.
- Local full verification before push: integration (`-tags=integration` + DAWG sibling +
  Ryuk-off), UI check/test/build, codegen (`make -C pkg proto fbs`, `pnpm -C ui codegen`).

---

## E0 — Payments data foundation

**Status:** DONE · **Release 1** · depends on: none · mechanics: PAYMENTS §14, §7, §11.

**Goal.** Stand up the `payments` schema, its confinement role, the jetgen target, the domain
package skeleton and all core tables — nothing wired to real money yet. This is the substrate
every later stage builds on.

**Migration `00010_payments_foundation.sql` (`payments` schema).**

- `CREATE SCHEMA payments`; an idempotent `DO $$` block creates a **NOLOGIN** `payments` role;
  `GRANT USAGE` + `GRANT ALL ON ALL TABLES` (+ `ALTER DEFAULT PRIVILEGES … GRANT ALL`) confine it
  to `payments.*` with nothing on `backend`. No `REFERENCES` grant (there is no cross-schema FK).
- `payments.ledger` — append-only. `ledger_id` (uuid PK, app-generated v7), `account_id` (plain
  `uuid`, **no FK**), `kind` (`fund|spend|admin_grant|refund`), `source`/`origin` (nullable,
  `vk|telegram|direct`), `chips_delta` (signed int, 0 for a grant), `product_id` (nullable
  FK→product), `order_id` (nullable FK→orders), `provider` + `provider_payment_id` (nullable),
  `snapshot` (jsonb, written from E2), `created_at`. Immutability is a **`BEFORE UPDATE OR DELETE`
  trigger** (a superuser bypasses a privilege REVOKE). Unique **partial** index on `(provider,
  provider_payment_id) WHERE provider_payment_id IS NOT NULL` — the idempotency key.
- `payments.balances` — `(account_id, source)` PK, `chips int CHECK (>=0)`, `updated_at`. Created
  **lazily** on first fund (up to three rows/account, absent = zero).
- `payments.benefits` — `(account_id, origin)` PK, `ads_paid_until timestamptz` null,
  `ads_forever bool`, `hints int CHECK (>=0)`, `updated_at`. Created lazily.
- `payments.catalog_atom` — `atom_type` PK (`chips|hints|noads_days|tournament`); the four atoms
  are **seeded** here (the `tournament` atom is provisioned for E9).
- `payments.product` (`product_id` PK, `title`, `active bool` soft-delete, timestamps);
  `payments.product_item` (`(product_id, atom_type)` PK → `quantity`); `payments.product_price`
  (`(product_id, COALESCE(method,''), currency)` unique → `amount bigint` minor units): a chip
  pack carries a money price per `method` (`vk|telegram|direct`), a value carries one
  `currency='CHIP', method=NULL` row. `currency ∈ {RUB, VOTE, XTR, CHIP}`; `amount CHECK (>=0)`.
- `payments.config` — a single typed row (`only_row bool PK CHECK`): `rewarded_payout_chips`,
  `cooldown_global_seconds` (300), `cooldown_vs_ai_seconds` (1800), `cooldown_hint_seconds` (60),
  `order_ttl_seconds` (1800). No `chip_rate` table — the pack rate is `product_price`.
- `payments.orders` — `order_id` (uuid PK), `account_id`, `platform`, `product_id` (FK),
  `expected_amount bigint` + `currency`, `origin`, `status` (`pending|paid|expired`), `provider`,
  `provider_payment_id` (nullable), timestamps. Index `(status, created_at)` for the pending sweep.
- `payments.payment_events` — `event_id` (uuid PK), `account_id`, `order_id` (nullable FK), `type`
  (`succeeded|failed|refunded`), `payload jsonb`, `created_at`, `dispatched_at` (nullable, partial
  index for the dispatch queue).
- Full `-- +goose Down` (DROP SCHEMA CASCADE + DROP OWNED BY + DROP ROLE); expand-contract, proven
  reversible by an integration test.

**Backend.**

- `backend/internal/payments/{payments,service,store}.go` — the `Money` value type + `Currency`
  (bigint minor units, exact, no float); `Store{db *sql.DB}` with a `Ping` health read via jet;
  `Service` over the store. (`withTx` arrives with E2's first transaction.)
- `cmd/jetgen` generates the `payments` schema into `internal/postgres/jet/payments/` (a second
  `GenerateDB` call); committed. Constructed in `cmd/backend/main.go` and passed to `server.Deps`
  (`Payments`) with a boot-time reachability check; its routes are registered from E2.

**Legacy deprecation (expand phase only).** A `--` header note marks `accounts.hint_balance` and
`accounts.paid_account` deprecated; they are **not** dropped (the DROP is the contract phase after
E2 flips reads and after Release 2). Reads still use the old columns until E2.

**Tests.**

- integration (`inttest`): schema + role + seeds present; `SET ROLE payments` cannot read
  `backend.accounts` (grant confinement); ledger rejects UPDATE/DELETE (trigger); idempotency
  partial index holds (NULL-keyed rows repeat); balance/benefit/amount/enum CHECKs bite; migration
  applies forward **and backward** on a throwaway PG.
- unit: `Money` — exact round-trip, per-currency scale, no-float parse rejecting a fraction for a
  whole-unit currency, arithmetic, formatting. Import-boundary test: only `internal/payments`
  imports the payments jet code.

**Done-criteria (met).** Migrations apply forward+backward on a throwaway PG 17; `go build
./backend/...` + `go vet` + `gofmt -l .` clean; committed `jet/payments/`; all tests green; no
behaviour change for users.

**Notes.** jetgen regenerates the whole `backend` schema; its committed jet had drifted from the
migrations (`robot_blocks`, `robot_friend_requests`, the `feedback_messages` `app_version` /
`browser_tz` columns, and `UseSchema` gaps), so this change also **regenerates and commits
`jet/backend`** to bring it back in sync (additive only — all suites stay green). The `payments`
role creation is idempotent (`DO $$` / `IF NOT EXISTS`) for fresh volumes.

---

## E1 — Trusted platform signal

**Status:** DONE · **Release 1** · depends on: none (parallel to E0) · mechanics: PAYMENTS §8.

**Goal.** Make the server know the execution platform from a trusted, unforgeable source,
carried on the session. This is the foundation the gate (E2) stands on; without it the gate is
meaningless. Signal plumbing only — no user-visible change.

**Model.** `platform = {kind: vk|telegram|direct, subtype: ios|android|web}` is a property of
the **session**, captured at creation. `kind` is always trusted — the gateway derives it from
the validated establish path (VK launch / TG initData / a web-native session), never a client
field. `subtype` is **cryptographically trusted only for VK** (it rides inside the signed
`vk_platform` launch param); for telegram and direct it is client-reported best-effort, and the
gate never relies on it (only the VK-iOS-frozen case is compliance-critical, and VK subtype is
trusted). VK/TG re-mint a session on **every cold start**, so their platform is re-captured each
launch; web/direct/email reuse the stored token, so their platform is captured once at creation
(`direct` needs no signature).

**Validation stays at the gateway.** Both wrapper verifiers already live there and the backend
cannot import either (separate modules under `internal/`, and the VK app secret is gateway-only):
VK launch params via the in-process `gateway/internal/vkauth.Verify` (HMAC-SHA256 over the signed
`vk_*` params under `GATEWAY_VK_APP_SECRET`, extended here to expose the signed `vk_platform` →
a trusted `Subtype()`), TG `initData` via the validator RPC. The gateway hands the derived
platform to the backend at establish; the backend persists it and returns it on resolve.

**Backend.**

- `backend.sessions` gains nullable `platform_kind` + `platform_subtype` columns (migration
  `00011`, CHECK-constrained, jet regenerated). A `session.Platform` value type + `session.Create`
  captures it; `Session` carries it through the store and the warm cache.
- `handleResolveSession` → `resolveResponse` (`dto.go`) returns the platform; the establish
  handlers set `kind` from their own endpoint (`/sessions/telegram|vk|guest|email/*`) and
  `subtype` from the request (VK: gateway-extracted from the signed params; TG/direct: the client
  best-effort field, defaulting web). The account-merge session mint (`link.merge`) inherits the
  caller's platform from the request context.
- Middleware (`middleware.go`) parses the gateway-injected `X-Platform` into the request context
  on the `s.user` group; `platform(c)` exposes it. Absent ⇒ untrusted (fail-closed).

**Gateway.**

- `backendclient.WithPlatform(ctx, "<kind>/<subtype>")` + `do`/`getRaw` inject `X-Platform` on
  every authenticated backend call; `connectsrv.Execute` enriches the request context from the
  resolved session's platform (carried through the gateway session cache + `ResolveSession`).
  Never from a client request body.

**Client (`ui/`).** `platformSubtype()` (`ui/src/lib/platform.ts`) supplies a best-effort device
subtype on the `auth.telegram` / `auth.guest` / `auth.email.login` requests (new FBS `subtype`
field): Telegram's `WebApp.platform` inside a Mini App, else the Capacitor/web channel. VK sends
nothing (the gateway derives the trusted subtype from the signed launch params).

**Tests.**

- unit (Go): `vkauth` exposes the signed `vk_platform` and maps iPhone/iPad → ios (the frozen
  case); the `X-Platform` middleware round-trips + reports untrusted when absent; the backend
  client injects `X-Platform` from the context and omits it when untrusted.
- integration: a session carries its platform through create + a cold-cache (DB) resolve for each
  kind; an unattributed session is untrusted; the CHECK constraints bite; migration `00011`
  applies forward **and backward** on a throwaway PG.
- UI: subtype normalization (vitest) + the new `subtype` field on the wire (codec unit test).

**Done-criteria (met).** A VK/TG session resolves to a trusted `{kind, subtype}`; a forged body
cannot change `kind` (nor the VK subtype); `direct` sessions resolve to `direct`; the untrusted
path is reachable and observable via `platform(c)`. No user-visible change; all layers green.

**Notes/risks.** High blast-radius (auth/session table + broad gateway header threading): additive
migration, no mixed-in refactors, `X-Platform` inert until E2 consumes it. **Sessions minted before
E1 carry no platform → untrusted (view-only) until re-login**; VK/TG self-heal on the next
cold-start re-mint, direct/email do not (accepted: Release 1 has no money, sessions cycle by
Release 2). TG/direct subtype is not cryptographically trusted — E2 must keep the gate on `kind`
+ the trusted VK subtype only.

---

## E2 — Currency + benefit core

**Status:** DONE · **Release 1** · depends on: E0, E1 · mechanics: PAYMENTS §2–§6, §11.

**Goal.** The full internal money mechanic, exercisable end-to-end **without real money**
via `admin_grant`: chip balances, spend→benefit atomically, the compliance gate by context,
benefit application (no-ads stacking, hints per-origin), the legacy migration, and the
`SpendHint` rewrite. This is the heart.

**Payments service (Go).** Every read/gate method also takes `present` (the account's live
identity sources, see *Domain boundary*) and is served from the read cache.

- `Balances(ctx, account, platform, present)` → per-segment `{source, chips, spendable}` for
  the context (VK→vk spendable, direct/tg hidden; TG→tg; direct→direct+vk+tg by priority;
  VK-iOS→vk shown as a **frozen** number, none spendable; untrusted→view-only, none spendable).
- `Spend(ctx, account, platform, present, productID)` → gate-checked purchase of a **value**
  (CHIP price, `method=NULL`; never a chip pack) with chips: resolve spendable segments by
  context, draw by priority direct→vk→tg, write a `spend` ledger row (+ a catalog **snapshot**
  of atoms+price) + decrement balance + apply benefit (extend `ads_paid_until[origin]` from
  `max(now,end)` / set `ads_forever` / add hints) **in one tx**, stamping `origin` = platform
  context. Refuse if untrusted (fail-closed) or funds insufficient.
- `Grant(ctx, account, origin, atoms)` → a **0-price sale of a value**: an `admin_grant`
  ledger row (`chips_delta=0`, + snapshot), same benefit application; concrete values only,
  never chips (no chips move — it neither requires nor touches a balance). Origin chosen by
  the admin. In E2 this is the Go service method only, exercised by tests; the admin grant UI
  + catalog editor are E7.
- `AdFree(ctx, account, platform, present)` / `HintsAvailable(…)` / `SpendHint(…)` → apply
  the one-directional origin rule: in context P, usable origins = {P} plus, when P is
  web/native, {vk,tg} (relaxation outward); in VK only vk; in TG only tg. Hint consumption
  draws by the same priority direct→vk→tg.
- `Merge` / `Unlink` hooks: extend `accountmerge` to merge segments **and** benefits by origin
  (same-origin add — chips sum, terms extend per origin; different origins coexist) in the
  caller's tx (`MergeTx`), and make **both** segment *and* benefit availability follow
  identity presence — unlink sleeps the balance *and* the benefit, re-link wakes them (D14),
  resolved live from `present`. Warn-before-unlink surfaces the balance via the interface; the
  warning UI itself is not E2.

**Backend wiring & migration.**

- Deprecate-and-flip `accounts.hint_balance` / `accounts.paid_account`: E0 added the tables;
  here, move reads/writes to `payments.benefits`. `SpendHint` (`game/service.go` ~:1147) and
  `ads.Eligible` (`ads/ads.go` :107) call the payments interface instead of the account
  columns. Legacy values were never set in prod → zero them; the DROP is the contract phase
  (guarded, after Release 2 — keep columns until then for rollback safety).
- `vs_ai` hints stay free/unlimited (unchanged 30-min gate); only online-game hint spend
  routes through the segmented, context-aware path.

**API (user-facing, Connect chain).** Read-only wallet view + spend:
`GET /api/v1/user/wallet` (balances+benefits for the context), `POST /api/v1/user/wallet/
buy` (spend chips on a product). Add the backend handlers (`u := s.user`), `backendclient`
methods, Connect methods + transcode + FBS. Admin grant is internal/admin (E7 UI).

**Tests.**

- unit (Go): gate-by-context matrix (every row of PAYMENTS §4, incl. VK-iOS frozen +
  untrusted); priority draw; no-ads stacking (`+=` from max(now,end)) + forever override;
  per-origin hint applicability + direct→vk→tg draw; merge/unlink segment+benefit math;
  catalog snapshotting; read-cache invalidation on each mutation.
- integration: atomic spend (ledger+balance+benefit in one tx; failure rolls all back);
  admin_grant credits values not chips; legacy migration zeroes and flips reads; merge/
  unlink over Postgres.
- UI: none new (E3 builds the screen); wallet DTO covered by codec unit tests.

**Done-criteria.** `admin_grant` applies no-ads/hints directly (a 0-price sale of a value —
no chips involved); the player chip-spend path (`Spend`) is proven by unit/integration tests
(balance seeded in-test, since a legitimate chip source arrives only with Release 2); the gate
blocks cross-context spend and cross-origin application; the ads gate reads the new benefit;
`SpendHint` uses segments; the hot read paths hit the read cache, not the `payments` schema;
all layers green; **compliance regression** (a `direct` benefit never activates inside VK/TG)
is a named test.

**Notes/risks.** This is the high-blast-radius core (money semantics + a live path
`SpendHint`/`ads.Eligible`). Minimize surface, keep the interface narrow, no mixed-in
refactors. The legacy flip is expand-contract: reads move first, DROP much later.

---

## E3 — Wallet UI

**Status:** DONE · **Release 1** · depends on: E2 · mechanics: PAYMENTS §1, §7, §6, §13, §4.

**Goal.** The user-facing "Кошелёк" (Wallet) section: balances + active benefits + the
catalog storefront, honouring guest-hidden, GP-stub, and the web-spend warning.

**Backend + wire (catalog read — new).** E2 shipped `wallet.get` / `wallet.buy` (segments +
benefits + a chip spend) but no way to *list* the catalog; the storefront needs one, so E3 adds a
read path following the E2 shape:

- `payments.Service.Catalog(ctx, cxt)` + `store.loadCatalog` (`internal/payments/{catalog,
  store_catalog}.go`) read every **active** product with atoms and prices and project them to the
  context (`projectCatalog`, pure + unit-tested): a **value** (no `chips` atom) carries its CHIP
  price and shows everywhere; a **chip pack** (`chips` atom) carries the money price for the
  context method (`cxt.Kind`: vk→VOTE, telegram→XTR, direct→RUB) and shows only where that method
  is priced. Read **uncached** (the catalog is small and rarely edited — unlike the per-account
  balances/benefits the read cache fronts).
- REST `GET /api/v1/user/wallet/catalog` (`handleWalletCatalog`, gated by `walletGate`) →
  `catalogDTO`; gateway op `wallet.catalog` (`transcode.go` + `encodeCatalog`), `backendclient.
  Catalog`; FBS `Catalog`/`CatalogProduct`/`CatalogAtom` (`pkg/fbs/scrabble.fbs`), client
  `decodeCatalog`.

**UI (`ui/`).**

- New `'wallet'` tab in `ui/src/screens/SettingsHub.svelte` — `SettingsTab` union, tab button
  **between Friends and About** (guest-hidden like Friends, offline-disabled like Profile/Friends),
  body branch, `'wallet'` route in `ui/src/lib/routeparse.ts` + `ui/src/App.svelte`.
- `Wallet.svelte` screen: context-available chip balances + active benefits (no-ads until date /
  forever, hints count). **No history feed** (PAYMENTS §11 — noise). Storefront: values priced in
  chips (buyable with `wallet.buy`), chip packs priced per method.
- **Guest:** section hidden entirely (durable-only).
- **GP build:** the chip-pack purchases are hidden behind a RuStore stub; spending earned chips on
  values still works (PAYMENTS §13). Detected by a build-time flag `VITE_GP_BUILD`
  (`ui/src/lib/distribution.ts`), forcible under the mock e2e with `?gp`.
- **Web-spend warning:** before a value spend that would draw vk/tg chips in a direct context, an
  own `Modal` (not `showPopup`) warns the benefit will be web/native-only. The context is inferred
  client-side (`executionContext` — VK/Telegram/direct); the server enforces the real gate on
  `wallet.buy` (fail-closed), so no wallet-DTO change was needed.
- Logic extracted to `ui/src/lib/{wallet,distribution}.ts` (unit-testable); `.svelte` stays thin.

**Pack purchase is display-only in E3.** Buying a chip pack needs the money order flow, which is
**E5**; here a pack card shows its price with a disabled **"Soon"** action. E5 replaces that with the
launch/order CTA. Value spends are wired now (E2 `wallet.buy`), though a Release-1 durable account
has 0 chips until funding exists (E5/E6), so a real value spend returns insufficient-funds until then.

**Tests.**

- unit (Go): `projectCatalog` context matrix (value everywhere; pack per context method; misconfig
  omitted). unit (vitest): money/price formatting, spendable-segment selection, warning trigger,
  the GP flag; `decodeCatalog` codec round-trip (mock e2e bypasses the codec).
- integration (`inttest`): `/wallet/catalog` returns active products with the context-correct price
  over Postgres; soft-deleted excluded.
- UI (Playwright mock, Chromium+WebKit): Wallet renders between Friends/About; guest hides it
  (`?guest` seam); GP stub (`?gp`); warning modal on a vk-drawing web spend, no warning on a
  direct-covered spend. Mock overlay stays instant under `MODE==='mock'` (or it intercepts taps).

**Done-criteria (met).** Balances/benefits/storefront render per context; guest/GP/warning paths
verified by unit + integration + mock e2e on both engines. **A *populated* contour review depends
on later stages** (no products until the E7 catalog editor, no chip funding until E5/E6, no grant
UI until E7): on the contour E3 shows the correct **empty** wallet/storefront states; the populated
UI is proven by the mock e2e. (This replaces the earlier "demonstrable end-to-end via `admin_grant`"
line — the grant UI is E7.)

**Notes/risks.** No global `.btn`/`.ghost` in `ui/` — styled per-component with scoped CSS + tokens
(mirror NewGame `.invite`). Product titles are single-language catalog data (E0 `product.title`),
shown verbatim; currency/chip words are i18n, counts follow the app's label+number convention (no
noun agreement). Svelte whitespace/`$state` naming gotchas apply.

---

## E4 — Durability (PITR)

**Status:** DONE — armed + restore-drilled on prod (v1.13.0, 2026-07-09). · **Release 2** ·
depends on: E0 (schema exists) · mechanics: PAYMENTS §14 (D4).

**Goal.** Continuous WAL archiving with point-in-time recovery, armed **before the first
real money** is accepted (E5 prod). Protects both money and game data.

**Locked decisions (this stage).**

- **Tool: pgBackRest.** **Destination: Selectel S3** object storage (encrypted AES-256-CBC,
  path-style addressing), **prod main host only** — the test contour never archives. (D4 left
  the tool + destination open; resolved here. Warm replica stays deferred, per D4.)
- **Retention 30 days**, **daily full base backup** (a systemd timer) + continuous WAL
  (`archive_command`, a 5-minute forced switch bounds the recovery point). Assessment
  (owner-reviewed prod gate): measured prod WAL **~0.77 MB/day**, DB **~9.6 MB** → archive
  **< 1 ₽/month** on Selectel S3, **negligible** perf impact. Recorded in `deploy/README.md`.
- **Archiving ships gated OFF** (`PGBACKREST_ARCHIVE_MODE` on the DB, `pitr_enabled` for the
  timer — both default off), so the merged/redeployed artifact is inert until armed and an
  un-armed prod deploy **cannot** pile WAL onto the disk.

**Work (landed in the artifacts PR into `development`).**

- pgBackRest in the DB image (`deploy/postgres/Dockerfile`); postgres becomes a built + pushed
  image (prod overlay `image:` + `prod-deploy.yaml` build/push list).
- Repository config + `archive_mode` via `PGBACKREST_*` env on the prod-overlay postgres
  service; secrets/vars rendered by `deploy/write-prod-env.sh` from the `PROD_PGBACKREST_*`
  Gitea set; **parity added to `prod-rollback.yaml`** (a rollback must not disarm archiving).
- Daily base-backup systemd timer (Ansible `main` role, gated by `pitr_enabled`).
- Two Grafana alerts on the exporter's `pg_stat_archiver` metrics (failing / stalled),
  absent/NaN-safe on the contour.
- Belt-and-braces `pg_dump` fixed to dump the **whole DB** (`deploy/prod-deploy.sh` was
  `-n backend`, silently excluding `payments`); manual-restore runbook updated.
- Full PITR runbook + arming sequence + recorded assessment in `deploy/README.md`.

**Prod arming (completed 2026-07-09 with the v1.13.0 release).** Owner created the Selectel S3
bucket (`erudite`, ru-6) + the `PROD_PGBACKREST_*` secrets/variables (incl. `ARCHIVE_MODE=on`);
promoted `development → master` → `prod-deploy` (archive_mode on behind the maintenance window),
then `stanza-create` + first base backup (31.9 MB cluster → 3.7 MB in the repo) + `check`, the
Ansible `-e pitr_enabled=true` timer, and a restore drill on an isolated one-shot target (data
intact, then wiped). Exact steps: `deploy/README.md` (point-in-time recovery — arming).

**Tests.** Restore drill on an isolated one-shot instance (base + WAL → target timestamp),
recorded in `deploy/README.md`. No app-level tests. Local verification: `docker compose config`
valid + the custom PG image builds (archiving inert on the contour).

**Done-criteria (met).** WAL archiving live on prod PG (v1.13.0); a restore verified on an
isolated one-shot target; cost + perf assessment reviewed; runbook current in
`deploy/README.md`.

**Notes/risks.** The repository **cipher passphrase is unrecoverable if lost** — stored apart
from the S3 keys. Manual/timer pgBackRest runs use `docker exec -u postgres … --pg1-user=scrabble`
(docker exec is root; the DB superuser role is `scrabble`, not `postgres`) — the systemd timer
+ the runbook carry this. Enabling `archive_mode` restarts postgres (rode the prod-deploy
maintenance window). Migrations stay expand-contract so image rollback remains DB-safe with PITR.

---

## E5 — Payment intake

**Status:** DONE · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12.

**Delivery & baked decisions.** Shipped as a linear PR stack (owner's choice), Robokassa first.
Resolved: match the order by a Robokassa **`Shp_order`** custom parameter, not the numeric `InvId`
(an order id is a uuid); idempotency key = the order id (`provider_payment_id = order_id`); the НПД
receipt is formed **shop-side in the Robokassa cabinet**, so no `Receipt` parameter is sent; a
chargeback **never drives the balance negative** (D27 stands, `balances_chips_chk` kept), so E5 is
**schema-free** (no migration, no contour wipe). Delivered on `feature/payment-intake-robokassa`:
the offer page (`/offer/`), the order/`fund` engine (idempotent, honours an expired order), the
`internal/robokassa` adapter, the `POST /wallet/order` + internal Result-callback handlers (a D36
confirmed-email gate on `direct`), the pending reaper, the `wallet.order` edge wire + the public
`/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. 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. The **Telegram Stars rail** is delivered on
`feature/payment-intake-tg-stars`: only the bot reaches Telegram, so the **invoice is minted by the
bot** — on the `wallet.order` path the gateway sends a new `CreateInvoice` command over the reverse
bot-link and the bot returns the `createInvoiceLink` (XTR) in its Ack, handed to the client's
`WebApp.openInvoice`. The bot answers `pre_checkout_query` via a new bot→gateway **`ValidatePreCheckout`**
unary (backed by the backend: the order must exist, be still creditable and **not already paid** — the
reusable-invoice double-pay guard — with a matching amount); the decline reason is localised to the
order account's language. A completed `successful_payment` is persisted to a pure-Go **SQLite outbox**
(`modernc.org/sqlite`) then forwarded by a new bot→gateway **`ForwardPayment`** unary into the same
`Fund` (source=`telegram`, idempotent on `telegram_payment_charge_id`, honours an expired order),
re-driven at startup and every 30 s. The rail is wired by `TELEGRAM_STARS_OUTBOX_DIR` (defaults to the
bot `/data` volume) but stays **inert until a chip pack carries an XTR price**, so seeding a Stars price
in the admin is the go-live. Finally **refunds** are delivered on `feature/payment-intake-refunds`: a
single `Refund` engine (`internal/payments`) reverses a paid order best-effort, exactly once —
idempotent on `(provider, provider_refund_id)`, revoking the funded chips **floored at 0** (never
negative, D27), and recording the unrecoverable remainder (chips already spent) as a per-account
**loss + abuse flag** in the new additive `payments.account_risk` table (read by the E7 report). The
refund ledger row's chip delta is what was actually reclaimed (the ledger stays reconcilable); the
full reversal rides in its snapshot; the order stays `paid`. **No rail pushes an unsolicited refund**
— all are admin-triggered (E7): Robokassa refund API / cabinet (auto-polling deferred — a worker not
worth it at low chargeback volume), VK via support, Telegram `refundStarPayment`. `failed` events are
not wired (no rail signals a hard post-charge server decline). The migration is **additive** (a new
table only), so E5 stays rollback-safe / no contour wipe. That closes E5. Deferred to a later stage:
hiding the ad banner on a no-ads purchase (a spend-path `NotifyBanner`, 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,
receipts, and refunds.

**Order-flow & intake (single writer).**

- `POST /api/v1/user/wallet/order` → create `order(pending)` (account/platform/product/
  expected amount/origin), return the provider-specific launch payload with `order_id`
  threaded in (Robokassa `InvId` / TG `invoice_payload` / VK `item`). The storefront's chip-pack
  card wires its purchase CTA to this here, **replacing the disabled "Soon" placeholder E3 left**
  (`ui/src/screens/Wallet.svelte`).
- Robokassa + VK **public webhooks**: new edge routes (add to `deploy/caddy/Caddyfile`
  `@gateway` matcher — or they fall to the landing catch-all), signature/HMAC verified,
  proxied into a payments intake handler. Match by `order_id`, verify amount, credit
  (ledger `fund` + balance UPDATE in one tx), mark order `paid`, emit `payment_event`.
  Idempotent by `(provider, provider_payment_id)`.
- **Pending sweep:** background reaper expires pending orders after the configured timeout
  (~30 min). Expiry is cosmetic — a later valid callback still credits (revive/honour).

**TG bot outbox (`platform/telegram/`).**

- The bot receives `successful_payment` (and `pre_checkout_query`) via Bot API. A **SQLite**
  store on the bot's disk (`internal/outbox`): on receipt, persist → forward over the reverse
  mTLS bot-link to the **gateway** (a `ForwardPayment` unary; the bot cannot dial the backend
  directly) → the gateway proxies to the backend payments-intake REST → on a durable response,
  mark `forwarded`. Re-drive undelivered on startup and on a 30 s tick. Backend intake dedups by
  `telegram_payment_charge_id`.
- Invoice creation (Stars) is minted by the bot (`createInvoiceLink`, XTR) on the gateway's
  `CreateInvoice` bot-link command, returned to the Mini App as the `openInvoice` link.

**Events & notifications.**

- `payment_events` dispatcher: `succeeded`/`failed`/`refunded` → live gRPC stream if the
  user is connected, else `botlink` push / email relay (existing). "failed" = an active
  provider decline (not an abandoned pending) surfaced to the user; "succeeded" hook
  (email/bot message).

**Receipts (§12).** Robokassa self-employed НПД receipt on payment (provider config); VK
handles Votes tax itself; TG Stars — no receipt.

**Refunds (§9).** ToS non-refundable. **All refunds are admin-triggered** (E7): no rail pushes an
unsolicited refund — Robokassa refund API / cabinet (auto-polling deferred as a low-value worker),
VK via support, Telegram `refundStarPayment`. One `Refund` engine reverses a paid order best-effort,
exactly once (idempotent on `(provider, provider_refund_id)`): revoke floored at 0 (never negative),
unrecoverable remainder → per-account loss + abuse flag (`payments.account_risk`), a `refund` ledger
row (chip delta = revoked, full reversal in the snapshot). `failed` events are not wired (no rail
signals a hard post-charge server decline). Ledger export-ready (reconciliation not built).

**Tests.**

- unit: signature/HMAC verifiers per rail; order-id matching; idempotency-key dedup.
- integration: full order→callback→credit per rail; duplicate callback credits once; expired
  order still honoured on late callback; TG outbox store→forward→ack→cleanup + restart
  re-drive; refund revoke best-effort/never-negative.
- UI: purchase flow reaches the provider launch (mock); success/failure surfaced.

**Done-criteria.** A real (sandbox) payment on each rail credits chips exactly once; a
replayed callback does not double-credit; the TG outbox survives a bot restart mid-flight;
failed/succeeded events reach the user; refund path exercised. **PITR (E4) armed and the
cost/perf assessment reviewed before this goes to prod.**

**Notes/risks.** Manual `docker run -p` boot tests fail from the shell — verify the runnable
artifact via the testcontainers integration suite. Distroless services run UID 65532;
bind-mounted secrets must be 0644. Edge routes silently fall through if not added to the
Caddyfile — add a CI probe. The prod rolling deploy skips caddy on config-only changes —
force-recreate when the Caddyfile changes.

---

## E6 — Ads

**Status:** DONE · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) ·
mechanics: PAYMENTS §10.

**Delivery & baked decisions.** Shipped as a linear PR stack (owner's choice): **rewarded first**,
then interstitial. Baked: the interstitial cooldowns already exist in `payments.config` (E0) and the
per-origin banner suppression is already done (E2 `AdFree`), so E6 is the two ad DISPLAY paths + the
rewarded credit. **VK reality (checked live in the VK docs via Playwright):** VK Mini App ads
(`VKWebAppShowNativeAds`, both `reward` and `interstitial`) expose **only a client-side `data.result`
boolean** — no server verify, no signature. So **D29 is amended**: rewarded is **client-attested**,
guarded by a server **daily + hourly cap** (config `reward_daily_cap` / `reward_hourly_cap`, default
50 / 10) that is both anti-abuse and an economic conversion lever (limits free chips so players buy);
the cooldown state for the interstitial is **client-mirrored** (owner's pick). Delivered on
`feature/ads-rewarded` (the **rewarded** slice): the ads-network abstraction (`ui/src/lib/ads.ts`, VK
impl) + the VK bridge (`vkRewardedReady` / `vkShowRewarded`), the backend `CreditReward` (VK-only,
order-less, idempotent on a client nonce, floored by the caps, payout from config
`rewarded_payout_chips` default 0 = off), the `wallet.reward` edge op returning the updated wallet
(with `reward_chips` gating the "watch for chips" CTA), and a **contour test stub** (`VITE_ADS_STUB` →
a toast instead of a real ad; prod always real). A temporary diagnostic confirmed on the contour that
VK returns **only `{result:true}`** (no token/signature) — client-attested is final, no hardening
possible; the diagnostic is removed. The slice also **corrects the VK-iOS freeze to purchase-only**
(rewarded on VK-iOS earns chips, which the old blanket "spend freeze" then blocked from spending —
Apple forbids only *buying* in-app values, not spending or earning them; `vkFrozen()` now gates only
`CreateOrder`, not `spendableSources`, so VK-wallet chips spend on VK-iOS). Delivered on
`feature/ads-interstitial` (the **interstitial + D31** slice): the post-move fullscreen interstitial
as a **client-mirrored** gate — the backend `adsFor` puts the config cooldowns + a `suppressed` flag
(the no-ads / `no_banner` gate, same as the banner) on the profile (`Profile.ads`), and
`ui/src/lib/ads.ts` `maybeShowInterstitial` self-gates on the last-shown time per kind in
`localStorage`, showing a VK interstitial (`vkShowInterstitial`) after a **confirmed play or a hint
only** (never a pass / exchange / resign), VK-only, offline banner-only, with the same `VITE_ADS_STUB`
toast on the contour. The slice also lands **D31 step 1 (contract-code)**: the domain no longer reads
or writes the deprecated `accounts.hint_balance` / `paid_account` columns — the `Account` fields, the
dead `account.SpendHint`, `account.GrantHints` and the admin **grant-hints** action are removed, and
the in-game hint display now comes wholly from the payments benefit (`HintsAvailable`). The **columns
stay** (no migration → image rollback is DB-safe); a later contract-PR does the `DROP` once E6 is
stable on prod.

**Goal.** VK video ads: the post-move interstitial (frequency-gated) and the rewarded video
(credits chips via server verify), plus extending the existing banner suppression to
per-origin.

**Work.**

- **Provider abstraction:** a small ads-network interface (VK impl now) so a future network
  for other platforms slots in without rework. VK integration via `ui/src/lib/vk.ts`.
- **Rewarded:** voluntary view → the network's **server verify callback** → payments intake
  credits chips to the `vk` segment (client not believed, like a payment). On-launch
  anti-fraud = provider verify only (no own daily cap; abstraction allows later).
- **Interstitial** (post-move fullscreen), configurable server values (from `payments`
  config): global per-user cooldown across all games (default 5 min); `vs_ai` 30 min; a hint
  application triggers a post-move interstitial independently with its own 1-min cooldown;
  offline banner-only; respect VK's own frequency caps. Cooldown state is **client-mirrored**
  (the chosen option): the server sends the cooldowns + `suppressed` on the profile and the
  client self-gates on a per-kind last-shown time in `localStorage` — no per-move round-trip.
- **Banner suppression:** extend `ads.Eligible` (`backend/internal/ads/ads.go` :107) to gate
  on the **origin benefit applicable in the current context** (E2 interface) instead of the
  single legacy flag. No-ads suppresses banner + interstitial; rewarded never suppressed.

**Tests.**

- unit: cooldown logic (global 5m / vs_ai 30m / hint-trigger 1m independence); rewarded
  credit path; banner eligibility per context.
- integration: rewarded verify → credit exactly once (idempotent like a payment).
- UI: interstitial shows/respects cooldown (mock); rewarded button; no-ads hides banner +
  interstitial; rewarded still available under no-ads.

**Done-criteria.** VK rewarded credits chips once per verified view; interstitial respects
all cooldowns incl. the hint trigger; no-ads suppresses banner+interstitial but not
rewarded; offline shows banner only.

**Notes/risks.** Crypto-payout networks stay rejected. Rewarded-without-payout is pointless —
only enable where the network pays (VK). Keep the interstitial frequency as server config so
it tunes without a store release.

---

## E7 — Admin, reports & catalog

**Status:** DONE · **Release 2** · depends on: E2 (ledger/grant/spend), E5 (payments/refunds),
E6 (D31 retired the legacy `hint_balance`/`paid_account`) · mechanics: PAYMENTS §11, §12, D32.

**Delivery & baked decisions (this planning round).** A linear PR stack into `development`.

- **Archived product = the existing `product.active` flag** (no new column, no migration):
  `active=false` **is** "archived". Its three behaviours already hold — hidden from the user
  storefront (`store_catalog.go` filters `active`), an **in-flight external payment still
  credits** (the `fund` credit path resolves the order and never re-checks `active`; only order
  *creation* / chip *spend* require `active`), and a product with any order/ledger row **cannot
  be hard-deleted** (FK `orders→product` / `ledger→product` are RESTRICT). The admin toggle is
  labelled **Archive / Unarchive**.
- **Delete vs archive:** the editor offers a hard **Delete** only for a product with **no
  orders and no ledger rows** (never transacted — the FK is the DB backstop); a transacted
  product is **archive-only**.
- **Admin grant = raw atoms + by-product.** Keep the quick raw-atom grant (N hints / no-ads
  days / forever) AND add grant-by-product: pick a defined product (including archived "reward"
  bundles) and grant its atoms. Both write an `admin_grant` ledger row; by-product records
  `product_id` + the snapshot. **Both refuse any set containing `chips`** (admin never grants
  currency) **or `tournament`** (no credit target until E9) — an explicit refusal, never a
  silent no-op.
- **Manual refund = full order only** for now (the E5 `Refund` engine takes an amount; partial
  is a later add if needed).
- **Tournament stays atom-only (E0); its entry economy is E9.** The catalog editor can compose
  products carrying the `tournament` atom (archived templates for E9), but granting/spending a
  `tournament` atom is refused until E9 designs the storage (recurring types, each its own
  benefit + price) — see E9. Adding a `benefits.tournament` counter now was rejected: the model
  is multi-type, so a single column would be wrong and force a second DB break.
- The admin grant **no longer mirrors `grant-hints`** (E6/D31 removed that action); it is a
  fresh action on `payments.Grant`.

**Goal.** The admin console financial surface — per-user report, admin grant, manual refund,
ledger export — plus the **configurable product catalog editor** (D32).

**Work (`/_gm`, `handlers_admin_console.go` + `adminconsole/`, `internal/payments/`).**

- **Per-user financial panel** on the user card (`consoleUserDetail`, `UserDetailView`): segment
  chip balances `(account, source)`, benefits `(account, origin)` (hints, no-ads until/forever),
  and the full append-only ledger (fund/spend/admin_grant/refund — amount, origin, product,
  provider, snapshot) from the ledger + materialized cache. Replaces the retired
  `PaidAccount`/`HintBalance` fields with the segmented view.
- **Catalog editor** (`/_gm/catalog`): list every product (active + archived) with its atoms and
  per-method/currency prices; create/edit (title, atom items `atom_type→quantity`, price rows
  `method+currency→amount`); **Archive/Unarchive** (`active`); **Delete** (never-transacted
  only). Enforce the projection's shape: a **pack** (carries `chips`) needs a money price per
  method; a **value** (no `chips`) needs a single `CHIP` price. A `tournament`-bearing product is
  allowed in composition but cannot be activated for sale until E9.
- **Admin grant** action: raw atoms (hints / no-ads days / forever) and by-product (a value
  product, incl. archived); **origin picker**; refuses `chips`/`tournament`; `admin_grant` ledger
  row (+ `product_id`/snapshot for by-product) via `payments.Grant`.
- **Manual refund** action: refund a specific paid order **in full** — a `refund` ledger row +
  best-effort floor-0 benefit revoke (E5 `Refund`).
- **Ledger export**: CSV/JSON for tax + future Robokassa reconciliation (export-ready;
  reconciliation itself not built).
- Auth unchanged: gateway Basic-Auth in front of `/_gm` + backend same-origin CSRF on POSTs.

**Tests.**

- unit: panel view assembly; catalog pack/value shape projection; grant refuses chips + tournament;
  editor validation (pack⇒money price, value⇒CHIP price); export shape.
- integration: grant (raw + by-product) writes the right `admin_grant` row + benefit; refund writes
  a `refund` row + floor-0 revoke; catalog CRUD round-trips (create→edit→archive→delete-if-clean);
  delete refused on a transacted product; panel reflects balances + benefits + history.

**Done-criteria.** An operator can: see a user's full financial picture; create/edit/archive
products + prices and delete only never-transacted ones; grant concrete values raw or by-product
(origin-picked, never chips/tournament); refund an order in full; export the ledger.

**PR stack (linear into `development`, all merged).**

1. ~~**Per-user financial panel**~~ (#230) — read-only ledger/segments/benefits on the user card;
   retired the `PaidAccount`/`HintBalance` display.
2. ~~**Catalog editor**~~ (#231) — product/atom/price CRUD + archive/unarchive + delete-if-clean +
   shape validation.
3. ~~**Admin grant**~~ (#232, + the D31 hint-wallet wire cleanup that surfaced there) — raw +
   by-product, refuse chips/tournament, origin-picked, `admin_grant` + snapshot.
4. ~~**Manual refund** (full order via E5 `Refund`) + **ledger CSV export**~~ — `RefundOrderFull`
   (idempotent, floor-0 revoke) on each fund row; `/_gm/ledger.csv`.

**Notes/risks.** High-blast-radius (money, ledger — append-only, trigger-enforced). No mixed-in
refactors. The catalog editor becomes the source of truth for products; the contour SQL seeds
become bootstrap-only.

---

## E8 — Guest limits

**Status:** DONE · **standalone** (game-behaviour change) · depends on: none · mechanics: PAYMENTS §6.

**Goal.** A registration funnel: cap what a guest can do, enforced **server-side** (today UI-only),
with configurable per-tier × per-kind limits so the pressure tunes without a release.

**Finding (verified).** Two gaps. (a) The friend/invitation paths do **not** check `is_guest` on the
server — only the UI hides them: `social/friends.go`, `robotfriends.go`, `friendcodes.go`,
`lobby/invitations.go`. A guest with a valid `X-User-ID` can call them. (b) A **pre-existing** flat
cap already existed: `game.MaxActiveQuickGames`=10 — a **combined** cap on `active`+`open` quick
games (vs_ai+random together, friend games excluded), counted by `CountActiveQuickGames`, enforced at
the handler (`ensureUnderGameLimit`) with **409 `game_limit_reached`**, surfaced as `at_game_limit`.
E8's per-tier × per-kind config **subsumes and replaces** it (decided: option A).

**Delivery & baked decisions (this planning round).** A linear PR stack; E8 is game-behaviour, no
payments mixed in.

- **`games.game_kind smallint DEFAULT 0`** (0=unknown — pre-E8 games, never gated; 1=vs_ai; 2=random;
  3=friends), set on creation (`StartVsAI`=1, `Enqueue`=2, a friend invitation=3). Existing games
  stay 0 and fall outside the gate.
- **Limits are per-tier × per-kind**, in a **new single-row `backend.config`** table:
  `guest_{vs_ai,random,friends}_limit` + `durable_{vs_ai,random,friends}_limit` (smallint,
  **`-1`=unlimited**), seeded `(1,1,0, 10,10,10)`. A guest is capped at 1 vs_ai + 1 random (friends
  is moot — the guest gate blocks friend games); a durable account is **10 per kind** — the old flat
  MaxActiveQuickGames=10, now split per kind. Editable in the admin.
- **The old flat cap is removed** (option A, owner-agreed): `MaxActiveQuickGames`,
  `CountActiveQuickGames`, `atGameLimit` deleted; the per-tier/kind config is the single mechanism,
  enforced at the **same handler gate** (`ensureUnderGameLimit(kind)` for `enqueue`
  random/vs_ai) plus the durable **friends cap** inside `CreateInvitation`. The gate stays out of the
  game domain — `game.Service.AtGameLimit(account, kind)` only resolves tier + counts.
- **A hot in-memory cache** fronts the config (read once on start, invalidated on the admin edit —
  mirrors the payments read-cache) so a login / game-create never queries it.
- **"Active" = `open` + `active`** (an open, unmatched random game holds a slot); the limit is on
  **creation** — an existing game is grandfathered, never interrupted.
- **Limits ride the wire (forward-compat, no double break):** `Profile.game_limits`
  (`GameLimits{vs_ai, random, friends}` — the caller's tier resolved server-side) + `GameView.kind`,
  so the client counts active games **per kind** from its lobby and locks the right start button. On
  a guest → durable upgrade (register / link) the client **re-fetches the profile** so the new
  (durable) limits apply. The client picks the lock's message by tier: a **guest** → the login funnel;
  a **durable** account at its cap → a plain "finish a current game first" notice.

**Work.**

- ~~**PR1 — backend + admin (server-side)** — DONE.~~ The migration (`game_kind` + `backend.config`,
  seeded `1,1,0 / 10,10,10` + jetgen); `game_kind` set on creation and projected onto `game.Game`;
  the **guest gate** (`ErrGuestForbidden`, mapped to **403 `guest_forbidden`**) on friend-request /
  redeem-code / befriend-in-game / invitation-create; the **active-limit enforce** — the old flat
  `MaxActiveQuickGames` mechanism removed and replaced by `ensureUnderGameLimit(kind)` on `enqueue`
  (random/vs_ai) plus the durable friends cap in `CreateInvitation`, keyed off
  `game.Service.AtGameLimit`; the **`internal/gamelimits` config + hot cache** (loaded at boot,
  invalidated on edit); the admin **kind column** in both game lists (`/_gm/games` + the user card)
  and a **config editor** (`/_gm/limits`) for the six limits.
- ~~**PR2 — wire + client** — DONE.~~ `Profile.game_limits` (the caller's tier) + `GameView.kind` (FBS
  + gateway transcode + client codec, committed regen); the client counts active games per kind from
  the lobby cache (`gamelimits.ts`) and locks a capped new-game start — an **outline 🔒** button that
  opens `GameLimitModal` instead of a game, native (Telegram `showPopup`) or the in-app `Modal`
  elsewhere, with two messages by tier: a **guest** sign-in funnel ("Войдите или создайте учётную
  запись…", Отмена / Вход→`/settings`) and, for a **durable** account at its cap, a plain notice
  ("Вы достигли лимита одновременных игр, сначала завершите текущие", ОК). The lock lifts via the
  existing profile re-fetch after a guest→durable upgrade.
  - **Decision (owner-agreed): the lobby's old `at_game_limit` New-Game tab-disable + notice is
    removed.** The `at_game_limit` flag (now the random-kind cap) conflicted with the per-kind lock —
    it hid the New-Game screen where the lock lives, and wrongly blocked starting an unfulfilled kind.
    The tab is always enabled; the per-kind lock on the start button is the only gate. The wire field
    `GameList.at_game_limit` stays (unused by the client) for a later cleanup.

**Tests.**

- integration (PR1, done): `game_kind` persisted per path; guest refused friend/redeem/invitation
  (domain + HTTP 403); guest blocked from a 2nd vs_ai / 2nd random (+ `at_game_limit`); durable on the
  higher tier; the durable friends cap + config cache reflecting an admin edit; accept stays exempt.
- unit (PR1, done): the per-tier/kind limit resolution (`Cap`, `LimitsFor`).
- unit + UI (PR2, done): the client lock logic (`gamelimits.ts` — count/cap/lock), the codec kind +
  game_limits roundtrip, the gateway transcode game_limits encode, the popup builders, and a mock e2e
  (`gamelimit.spec.ts`) — a capped start shows 🔒, opens the modal without navigating, and the lock
  clears when the profile refetch lifts the cap.

**Done-criteria.** A guest is server-capped (default 1 vs_ai + 1 random, configurable) and cannot
friend/invite; a durable account is capped at 10 per kind (configurable); the client shows the lock +
the tier-appropriate modal and the cap lifts on registration; durable flows otherwise unchanged.

**Notes/risks.** Game-behaviour change — own PRs, own tests, no payments mixed in. The limit is on
creation (existing games grandfathered). The config cache is single-instance (matching the deploy).

---

## E9 — Tournament fee (future)

**Status:** TODO · **future** · depends on: E0 (atom provisioned), tournament feature ·
mechanics: PAYMENTS §5, §7.

**Goal.** A chip entry economy for tournaments. The `tournament` atom is provisioned (E0) and
E7's catalog editor can compose tournament products; E7 deliberately **defers the entry storage
+ credit/spend** here, to avoid guessing the schema.

**Design to settle here (not before — avoid a double DB break).** Tournaments are expected in
**several recurring types** (daily / weekly / monthly …), each its **own benefit with its own
price** — so a single `benefits.tournament` counter is wrong. Model the entry store as
**per-tournament-type** (e.g. a `tournament_type` catalog + a per-`(account, type)` entry
balance), design the pricing, then:

- lift E7's **refusal** of granting/spending the `tournament` atom (admin grant by-product +
  chip spend);
- add the **spend** (charge an entry) reusing E2 `Spend`;
- wire the coupling to the actual tournament feature (out of scope until it exists).

**Done-criteria.** Deferred — revisit when tournaments are built; this stage owns the
tournament-entry storage + pricing design.

---

## E10 — Multi-shop direct rail + ИП fiscalization

**Status:** DONE (merged — the multi-shop PR) · **Release 2+ (post-launch, backend-first)** · depends on: E5 (intake/`Fund`,
the `robokassa` adapter, the Result callback), E7 (the per-user report — channel breakdown) ·
mechanics: PAYMENTS §9, §12; decisions D41 (rev), D42–D44.

**Goal.** Split the single Robokassa `direct` rail into **per-channel merchant shops** (web,
android; ios later) — separate merchant accounts / withdrawal / accounting and a per-channel
breakdown in the E7 report — while keeping **one `direct` wallet** (D42). Land the wallet/identity
rules the native apps need (email-only anchor, D43) and revise fiscalization for the owner's move
to **ИП / 54-ФЗ** (D41 rev). All **additive / contour-safe** and **money-live** → expand-contract
throughout.

**Locked decisions (owner interview 2026-07-14): D41 (rev), D42, D43, D44.** No wallet-model /
spend-wall change; the split is merchant-account routing under one `direct` segment. Route the shop
by the **trusted** `X-Platform` subtype, never a client field; unknown → `web`.

**B1 — Config: one shop → a set of shops.**

- `robokassa.Config` (single) → a **shops registry** keyed by channel (`web`, `android`; `ios`
  later), each 4 fields (`MerchantLogin`/`Password1`/`Password2`/`IsTest`). New env
  `BACKEND_ROBOKASSA_WEB_*`, `BACKEND_ROBOKASSA_ANDROID_*`.
- **Expand-contract, no flag-day:** the legacy `BACKEND_ROBOKASSA_*` seeds the `web` shop; add the
  new vars; retire the legacy set after the prod rollout sets the split vars. `validate()`: a shop
  with a login must carry both passwords (mirror the current check), per shop.
- Config/README + `deploy/.env.example` + the deploy ansible vars.

**B2 — Route the shop by channel (intake).**

- `handleWalletOrder` `SourceDirect` branch: pick the shop by `cxt.Subtype` (`web`/`android`;
  unknown → `web`). Build the payment request from that shop. The subtype rides the trusted
  gateway-injected `X-Platform` (`<kind>/<subtype>`, `parsePlatformHeader`) — no spoofable client
  field. **Verify the gateway emits `direct/android` for the native build**; if it sends a bare
  `direct/`, add the android subtype (small gateway change).
- Record the chosen `shop` on the order (feeds B5).

**B3 — Per-shop Result callbacks.**

- The callback must select the right `Password2` → each shop gets its own Result URL:
  **per-shop public edge routes** (mirror the existing single Robokassa Result route, e.g.
  `…/result/web`, `…/result/android`), each forwarded to the backend and verified with that shop's
  config, then the same `Fund` (source stays `direct`). Add the routes to the **Caddyfile
  `@gateway` matcher** (else they fall to the landing catch-all) **+ a CI probe per route**.
- Keep the legacy single route alive (shop = `web`) through the expand-contract window until the
  cabinet Result URLs are cut over.

**B4 — ИП fiscalization (D41 rev) — RESOLVED (owner 2026-07-14): cabinet-side only.** The owner keeps
Robokassa's cabinet auto-fiscalization (a generic чек is acceptable for the ИП); the optional
itemized-`Receipt` / `Email` code below is **dropped** — not needed. (Kept for context.)

- **Owner/cabinet:** enable the 54-ФЗ cloud kassa in the Robokassa ЛКК (kassa + ОФД + СНО).
  Required for ИП regardless of code.
- **Code (optional, itemized чеки):** send a one-line `Receipt` (pack name, qty 1, `sum`, `tax`
  per СНО, `payment_object`/`payment_method`) + the customer `Email` (the D36 confirmed anchor) in
  the payment request; extend the signature to `MerchantLogin:OutSum:InvId:Receipt:Password1`
  (URL-encode `Receipt`). One line fits the current GET redirect; POST-form only as a URL-length
  fallback. One feature, all shops. **Sequenced last** — the split (B1–B3, B5, B6) needs no
  fiscalization. Supersedes the E5 §12 shop-side НПД receipt note.

**B5 — Channel in the report (D44).**

- Additive `shop` column on the order (default `web` / backfill from `origin`); a per-channel
  breakdown in the E7 per-user report (D40). Expand-contract migration (nullable/defaulted column),
  **contour-safe** (a schema touch → note the contour `DROP SCHEMA` step in `PRERELEASE.md`).

**B6 — Tests + docs.**

- unit: the `robokassa` shops registry + per-shop `VerifyResult` (right `Password2`);
  signature-with-`Receipt` (B4); intake shop routing by subtype (web/android/unknown→web).
- integration: order→per-shop callback→credit (each route), a duplicate credits once, an expired
  order still honoured; the `shop` recorded + reported.
- docs: `PAYMENTS.md` (+`_ru`) the multi-shop topology + the ИП/54-ФЗ receipt revision;
  `deploy/README` + `.env.example` the new vars + the per-shop Result routes + probes;
  `PRERELEASE.md` the `shop` column contour step.

**Done-criteria.** A `direct/web` order pays through the web shop and `direct/android` through the
android shop, each verified with its own `Password2`, both crediting one `direct` wallet exactly
once; the E7 report breaks payments down by channel; the split stays contour-safe (the legacy shop
still works until the cabinet cutover). B4 (fiscalization) verified when the owner's ИП/ОФД/СНО are
live.

**Notes/risks.** Edge routes fall through if not in the Caddyfile `@gateway` matcher — add a CI
probe (field note). The prod rolling deploy skips caddy on config-only changes — force-recreate on a
Caddyfile change. Money-live: expand-contract only, image rollback DB-safe. Confirm the gateway
emits `direct/android` for the native build before relying on subtype routing.

---

## E11 — Payment availability kill switch + per-account override

**Status:** DONE · **Release 2** · depends on: E5 (intake/order), E7 (admin console) · mechanics:
PAYMENTS §9; decisions D45, D46.

**Delivered.** An operator disables purchases live from `/_gm` — a whole rail/channel or one account
— and the user sees a localized reason on the next purchase attempt. Motivation (owner): real apps
show broken payments with no explanation; this gives ops a live switch + a clear user message.

- **Rail kill switch** — `payments.rail_status` (per rail `direct:web` / `direct:android` / `vk` /
  `telegram`): `enabled` + a per-language message, edited on the catalog page. **Fail-open** (no row
  ⇒ enabled, so payments are never accidentally killed). The intake gate — `CanPurchase` in
  `handleWalletOrder`, before the order — returns `payment_unavailable` + the localized message;
  orthogonal to the security gates.
- **Per-account override** — `payments.account_payment_override` (a row only for non-default; default
  = no row, cleared by delete): allow / deny / default, edited on the user card. `allow` bypasses
  **only** the ops rail switch, never the security gates (D46).
- **Wire** — the message rides an additive `ExecuteResponse.message` (envelope layer,
  frozen-contract-safe; the gateway forwards a backend domain-error message via `DomainMessage`); the
  client reads it into `GatewayError.message` and shows it on a `payment_unavailable` buy attempt.
- **Tests** — the pure gate `PurchaseGate` (unit, TDD); the store + gate + override end-to-end
  (integration, migration `00016`); the client (svelte-check / vitest). **Docs** — PAYMENTS (+`_ru`),
  decisions D45 / D46.

**Contour-safe:** additive migration (two new tables, no wipe); the wire add is additive; fail-open,
so nothing is disabled until an operator acts.

---

## E12 — YooKassa direct rail (Robokassa retired)

**Status:** DONE · **Release 2+ (money-live)** · depends on: E5 (intake/`Fund`, the order flow), E7
(the admin refund + report), E10 (per-channel shops), E11 (the kill switch) · mechanics: PAYMENTS §2,
§9, §11, §12; decisions D47–D51 (revising D41).

**Delivered.** The `direct` (RUB) rail settles through **YooKassa** instead of Robokassa. The wallet
model is untouched — one `direct` segment, the same spend wall, the same per-channel shops (D42) and
`shop` on the order (D44). Robokassa is **dormant, not deleted**: the direct rail falls back to it
when no YooKassa shop is configured, so reviving it is a credentials change (D47).

- **Provider glue** — `backend/internal/yookassa`: an API client (`CreatePayment` / `GetPayment` /
  `CreateRefund`, HTTP Basic, `Idempotence-Key`), a per-channel `Shops` registry with `ByShopID` for
  attributing a notification, the notification envelope + the sender-IP allowlist, and the fiscal
  receipt builder. No DB, no payments-domain coupling — the same shape as the `robokassa` package.
- **Order** — `handleWalletOrder` prefers a YooKassa shop and otherwise falls through to Robokassa.
  It creates the payment (order id as both `Idempotence-Key` and `metadata.order_id`), records the
  provider payment id on the order (`AttachProviderPayment`; no migration — the column existed) and
  returns `confirmation_url`. D36's email anchor now also feeds the receipt (`ConfirmedEmail`).
- **Notification** — `/pay/yookassa/notify` (gateway: rate limit + raw-body proxy; backend: the
  confirming `GetPayment`). Nothing in the body is acted on (D48). Guards: the payment's metadata must
  name the order, and its `test` flag must match the shop's. The sender address is deliberately not
  checked (D48 rev) — it adds no security, and the order lookup that precedes any provider call is
  the tighter guard against a forger driving traffic at the provider through us.
  `payment.canceled` records a `failed` event. 200 = durably decided, 5xx = redeliver.
- **Reconcile** — `runOrderReaper` asks the provider about each pending order older than
  `payments.ReconcileAfter` (a minute) carrying a payment id, and credits the ones really paid
  (D49 rev — the threshold is deliberately not the order lifetime, which answers a different
  question and made a failed notification cost the customer half an hour).
- **Refund** — the `/_gm` button calls `POST /v3/refunds` first and records only on a **succeeded**
  refund, under the provider's own refund id (D50); a failure — or a still-`pending` refund — records
  nothing, and pressing again is safe. `refund.succeeded` is handled too (D52), because the merchant
  cabinet can issue a refund that never passes through our API; a **partial** refund records nothing
  and is logged for an operator (the engine is full-refund-only).
- **Fiscalization** — **no receipt is sent** (D51 rev): the merchant is on НПД, outside 54-ФЗ, and
  YooKassa does not serve receipts for that regime; reporting goes to «Мой налог» instead (its own
  task — the data it needs is already recorded, bar a per-operation "reported" marker). The fiscal
  code stays dormant behind `BACKEND_YOOKASSA_VAT_CODE`: unset sends nothing, a 54-ФЗ rate code turns
  «Чеки от ЮKassa» back on — the path a lost НПД regime (annual income ceiling) would take.
- **Email anchor** — D36 still gates a direct purchase (the recovery anchor stands on its own now
  that the receipt address is gone). The wallet now says so **before** the buy tap: a player signed in
  through VK/Telegram in a browser sees "add an email in your profile" instead of a bare error.
- **Config/deploy** — `BACKEND_YOOKASSA_{WEB,ANDROID}_{SHOP_ID,SECRET_KEY,TEST}` +
  `BACKEND_YOOKASSA_VAT_CODE`; every `ROBOKASSA_*` mapping removed from compose, `.env.example`,
  `write-prod-env.sh` and the three workflows, and recorded in `backend/internal/robokassa/README.md`.
  The CI edge probe now targets `/pay/yookassa/notify`.
- **Tests** — unit (`yookassa`: request building, receipt, idempotence key, error retryability, shop
  registry, IP allowlist); integration (`payments_yookassa_test.go`: credit-once + redelivery, a
  forged notification credits nothing, a foreign sender is refused, a live payment on a test shop is
  refused, a decline records `failed`, the reconcile sweep credits a lost notification and leaves an
  unpaid order alone, the refund moves money then records — and records nothing when it fails, the
  order path mints a payment with a receipt, D36 still gates the rail, a cabinet refund is reversed
  once, the event after a console refund is a no-op, a partial refund changes nothing, an unconfirmed
  refund reverses nothing, and a pending refund records nothing until it settles).

**Contour-safe:** no migration, no wire change; the client is rail-agnostic (only the mock URL and an
e2e assertion name a provider).

**Verified on the contour** against a YooKassa **test shop** (test mode exists — a separate test shop
with its own credentials and test cards), then in prod with one real payment.

---

## Verification & CI (all stages)

- Per-stage tests at the layers above; **compliance-gate regression is mandatory** (a
  `direct` benefit must never activate inside VK/TG) and runs from E2 onward.
- Local full verification before every push: `go build/vet ./backend/... && gofmt -l .`,
  integration (`-tags=integration` + DAWG sibling + Ryuk-off), `pnpm -C ui check/test:unit/
  build`, Playwright mock e2e, codegen (`make -C pkg proto fbs`, `pnpm -C ui codegen`).
- Contour: each PR into `development` auto-deploys the test contour; owner does visual sign-
  off there (not local). Keep a multi-PR batch a linear stack (one shared contour,
  last-deploy-wins).
- Prod (Release 2): expand-contract migrations only (image rollback DB-safe); PITR armed +
  the E4 cost/perf assessment reviewed **before** the first money; new edge routes added to
  the Caddyfile with a CI probe; caddy force-recreated on config-only changes.
- Release framing: **shipped docs/code/commits/PRs carry no stage ids** — finalize copy for
  the release, not the plan.
