docs(payments): add monetization spec and implementation plan
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s

Add docs/PAYMENTS.md (+ docs/PAYMENTS_ru.md mirror) — the monetization
mechanics specification: two-tier «Фишка» currency, per-source wallet
segments, per-origin benefits with the one-directional store-compliance
gate, order-flow payment intake, VK ads, configurable catalog,
append-only ledger, taxes, and native-Android distribution.

Add PLAN.md — the staged technical implementation plan with per-step
touch-points, tests, and done-criteria.
This commit is contained in:
Ilia Denisov
2026-07-07 23:11:49 +02:00
parent ca48b3e18e
commit 4c7bc7baa9
3 changed files with 1269 additions and 0 deletions
+598
View File
@@ -0,0 +1,598 @@
# 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)); 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 (E0E9) 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 | TODO |
| E1 | Trusted platform signal | 1 | TODO |
| E2 | Currency + benefit core | 1 | TODO |
| E3 | Wallet UI | 1 | TODO |
| E4 | Durability (PITR) | 2 | TODO |
| E5 | Payment intake | 2 | TODO |
| E6 | Ads | 2 | TODO |
| E7 | Admin & reports | 2 | TODO |
| E8 | Guest limits | — | TODO |
| E9 | Tournament fee | future | TODO |
**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.
---
## 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 role owns `payments` (ALL on `payments.*`, plus `REFERENCES` on
`backend.accounts(account_id)` for the cross-schema FK — nothing else on `backend`). The
game/backend role keeps no rights on `payments`. Isolation is one-directional: payments
may reference accounts; the rest of the backend never touches `payments` SQL.
- **No cross-schema writes.** Benefits move **into** `payments` (they no longer live on
`accounts` — see E2), so the spend transaction (ledger INSERT + balance UPDATE + benefit
UPDATE) is atomic **within `payments`**. The only cross-schema link is the FK
`payments.*.account_id → backend.accounts.account_id` (referential integrity, not a
write). Game code reads benefits through the payments **Go interface**, never via SQL.
### 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) bool`, `SpendHint(ctx, account, platform) error`,
`Balances(ctx, account, platform) …`). Keep the surface small; this is the seam that lets
payments become a separate process later without touching callers.
### 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:** TODO · **Release 1** · depends on: none · mechanics: PAYMENTS §14, §7, §11.
**Goal.** Stand up the `payments` schema, DB role, jetgen target, domain package skeleton,
and all core tables — with nothing wired to real money yet. This is the substrate every
later stage builds on.
**Migrations (`payments` schema).**
- `CREATE SCHEMA payments`; create the payments DB role + GRANTs (ALL on `payments`,
`REFERENCES` on `backend.accounts(account_id)`).
- `payments.ledger` — append-only. Columns: `ledger_id` (UUIDv7 PK), `account_id` (FK →
`backend.accounts`), `kind` (`fund|spend|admin_grant|refund`), `source` / `origin`
(nullable per kind), `chips_delta` (signed; 0 for admin_grant), `product_id` (nullable,
FK → catalog), `order_id` (nullable, FK → orders), `provider` + `provider_payment_id`
(nullable), `snapshot` (JSONB of sold atoms+price for spend/grant), `created_at`. **No
UPDATE/DELETE ever.** Unique partial index on `(provider, provider_payment_id)` where not
null — the idempotency key.
- `payments.balances` — materialized `(account_id, source)` PK, `chips int CHECK (chips >=
0)`, `updated_at`.
- `payments.benefits` — materialized `(account_id, origin)` PK, `ads_paid_until timestamptz`
null, `ads_forever bool`, `hints int CHECK (hints >= 0)`, `updated_at`.
- `payments.catalog_atom` — atom types (`chips|hints|noads_days|tournament`).
- `payments.product` — `product_id`, `title`, `active bool` (soft-delete), created/updated;
`payments.product_item` (product → atom + quantity); `payments.product_price` (product →
method `vk|telegram|direct` + amount + currency — multi-currency chip packs; value
products carry a single chips price).
- `payments.chip_rate` / rewarded payout config — either dedicated rows or a small
`payments.config` KV (frequency cooldowns, rewarded payout, pending-expiry). Pick one and
document it here at implementation.
- `payments.order` — `order_id` (UUIDv7 PK), `account_id`, `platform`, `product_id`,
`expected_amount` + currency, `origin`, `status` (`pending|paid|expired`), `provider`,
`provider_payment_id` (nullable), timestamps. Index for the pending-expiry sweep.
- `payments.payment_event` — `event_id`, `account_id`, `order_id` (nullable), `type`
(`succeeded|failed|refunded`), `payload`, `created_at`, `dispatched_at` (nullable).
**Backend.**
- `backend/internal/payments/` package skeleton: `Store` + `Service` + a `withTx` helper.
No handlers wired yet beyond a health-style no-op — this stage is schema + package.
- Extend `cmd/jetgen` to generate `payments` schema → `internal/postgres/jet/payments/`;
commit generated code.
**Legacy deprecation (expand phase only).** Add a migration comment/marker that
`accounts.hint_balance` and `accounts.paid_account` are deprecated; do NOT drop yet (drop is
the contract phase, after E2 reads/writes the new tables and after Release 2). Reads still
use the old columns until E2 flips them.
**Tests.**
- integration: schema/role created; role cannot read `backend`-only tables and vice-versa;
ledger rejects UPDATE/DELETE (trigger or role privilege); idempotency unique index holds;
FK to `backend.accounts` enforced; balance/benefit CHECKs hold.
- unit: none material yet (logic arrives in E2).
**Done-criteria.** Migrations apply cleanly forward+backward on a throwaway PG (jetgen path
proves this); `go build ./backend/...` + `go vet` + `gofmt -l .` clean; committed jet code
for `payments`; integration tests green; no behaviour change for users.
**Notes/risks.** jetgen may reorder untouched `backend` tables — revert that churn, commit
only the `payments` additions. Keep the `payments` role creation idempotent (`IF NOT
EXISTS` / `DO $$`), since migrations re-run on fresh volumes.
---
## E1 — Trusted platform signal
**Status:** TODO · **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 and re-confirmed each cold start. This is the foundation the gate
(E2) stands on; without it the gate is meaningless.
**Model.** `platform = {kind: vk|telegram|direct, subtype: ios|android|web}` becomes a
property of the **session**. On cold start the client submits the fresh wrapper signature
(VK launch-params `sign` / TG `initData`); the gateway (or backend session-resolve)
validates it and records/refreshes the session's platform. `direct` needs no signature — it
is implied by a web/native session's creation path.
**Backend.**
- Session store (`backend/internal/session/`): add `platform` (kind+subtype) to the session
row + `Session` struct; set it at creation and refresh it on a validated cold-start call.
- Session resolve (`handlers_auth.go handleResolveSession` → `resolveResponse` DTO
`dto.go`): return `platform` so the gateway can carry it.
- Validation: TG `initData` validator already exists (`platform/telegram/internal/initdata`)
— reuse. Add VK launch-params `sign` verification (HMAC over sorted params with the VK app
secret) — new small verifier, unit-tested against known VK vectors.
- Gateway (`gateway/internal/backendclient`): inject a trusted `X-Platform` header (mirror
`X-User-ID` injection in `client.go do()`), sourced from the resolved session — never from
the client request body.
- Backend middleware: read `X-Platform` into context alongside `X-User-ID`
(`middleware.go`); expose a typed accessor `platform(c)`.
- **Fail-closed default:** absent/invalid/stale platform ⇒ context = untrusted; the payments
interface treats untrusted as "view only" (enforced in E2's gate, but the signal plumbing
lands here).
**Client (`ui/`).** On cold start, submit the wrapper signature to the session-establish/
refresh call: VK via `ui/src/lib/vk.ts` (launch params), TG via `ui/src/lib/telegram.ts`
(`initData`). `direct` submits nothing (web/native). Compose the platform from
`insideVK()` + `insideTelegram()` + `clientChannel()` + `vkPlatform()` (no single
discriminator exists today — see `ui/src/lib/channel.ts`, note VK reads as `web` there).
**Tests.**
- unit (Go): VK sign verifier accepts valid / rejects tampered params; TG initData path
(existing) covered; platform kind+subtype derivation.
- integration: session carries platform; resolve returns it; stale/absent → untrusted.
- UI: cold start submits the right signature per wrapper (mock).
**Done-criteria.** A VK/TG session resolves to a trusted `{kind,subtype}`; a forged body
cannot change it; `direct` sessions resolve to `direct`; untrusted path is reachable and
observable. No user-visible change.
**Notes/risks.** VK iOS must surface as `subtype=ios` (drives the frozen state in E2/E3).
Old sessions predating this stage have no platform → untrusted → fail-closed (acceptable;
re-cold-start fixes it). Keep the VK app secret in config/secret store, not code.
---
## E2 — Currency + benefit core
**Status:** TODO · **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).**
- `Balances(ctx, account, platform)` → the segments spendable in the platform's context
(VK→vk; TG→tg; direct→direct+vk+tg; VK-iOS→frozen/view; untrusted→none).
- `Spend(ctx, account, platform, productID)` → gate-checked purchase of a value with chips:
resolve spendable segments by context, draw by priority (direct→vk→tg on web), write a
`spend` ledger row + 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)` → `admin_grant` ledger row (price 0, concrete values
only, never chips), same benefit application; origin chosen by the admin (E7 UI; here the
service method + an internal/admin entrypoint).
- `AdFree(ctx, account, platform)` / `HintsAvailable(ctx, account, platform)` /
`SpendHint(ctx, account, platform)` → 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.
- `Merge` / `Unlink` hooks: extend `accountmerge` to merge segments+benefits by origin
(same-origin add, different coexist), and make segment availability follow identity
presence (unlink → sleep, re-link → wake). Warn-before-unlink is a UI concern (surface the
balance via the interface).
**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); priority draw; no-ads
stacking (`+=` from max(now,end)) + forever override; per-origin hint applicability;
merge/unlink segment math.
- 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.** With chips seeded via `admin_grant`, a user can buy no-ads/hints; the gate
blocks cross-context spend and cross-origin application; ads gate reads the new benefit;
`SpendHint` uses segments; 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:** TODO · **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.
**UI (`ui/`).**
- New `'wallet'` tab in `ui/src/screens/SettingsHub.svelte` — `SettingsTab` union, tab
button **between Friends (:65) and About (:66)**, body branch in the `:42-51` switch,
`'wallet'` route in `ui/src/lib/routeparse.ts` + `ui/src/App.svelte`. Reuse the hub's
guest/offline gating.
- `Wallet.svelte` screen: **minimal** — context-available chip balances + active benefits
(no-ads until date, hints count). **No history feed** (PAYMENTS §11 — noise). Storefront:
products from the configurable catalog, prices in chips (values) / per-method (chip packs).
- **Guest:** section hidden entirely (durable-only).
- **GP build:** purchase CTA replaced by a stub ("install the RuStore build to make
purchases"); rewarded + spending earned chips still work. Detect the GP native build.
- **Web-spend warning:** before spending vk/tg chips in a web/native (direct) context, show
an own `Modal` (not `showPopup` — that eats the user-activation gesture) warning the value
will be web/native-only.
- Extract all logic into `ui/src/lib/*` (unit-testable); keep `.svelte` thin.
**Tests.**
- unit (vitest): storefront/price formatting, context-available-segment selection, warning
trigger condition.
- UI (Playwright mock, Chromium+WebKit): Wallet renders between Friends/About; guest hides
it; GP stub; warning modal on web vk/tg spend. Mock overlay must stay instant under
`MODE==='mock'` (or it intercepts taps).
**Done-criteria.** Owner can review the Wallet on the deployed test contour (visual sign-off
is on the contour, not local); balances/benefits/storefront correct per context; guest/GP/
warning paths verified. Release 1 is now demonstrable end-to-end via `admin_grant`.
**Notes/risks.** No global `.btn`/`.ghost` in `ui/` — style per-component with scoped CSS +
tokens (mirror NewGame `.invite` for a CTA). Svelte whitespace/`$state` naming gotchas
apply. iOS WebView download/gesture caveats are irrelevant here (no file delivery).
---
## E4 — Durability (PITR)
**Status:** TODO · **Release 2** · depends on: E0 (schema exists) · mechanics: PAYMENTS §14.
**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.
**Work.**
- Add WAL archiving to the prod Postgres (pgBackRest or WAL-G): base backups + continuous
WAL to a second host or object storage. Wire into `deploy/` (compose/prod overlay +
ansible `deploy/ansible/`), config + secrets under the `PROD_` prefix.
- Restore runbook in `deploy/README.md`: base + WAL replay to a timestamp; test the restore
on a scratch target.
- Keep the existing migration-time `pg_dump` (`deploy/prod-deploy.sh`) as belt-and-braces.
**Mandatory pre-prod assessment (owner-required, gates the Release 2 prod rollout).**
Before the first money goes live, produce and record here:
1. **Disk-storage cost estimate** for the WAL archive + base backups (retention window ×
WAL volume; account for both hosts / object-storage pricing). This can change hosting
sizing.
2. **PG performance-degradation assessment** from continuous archiving (write amplification,
archive_command latency, backup I/O contention) on the prod-sized instance.
Neither blocks building the plan, but both **must** be completed and reviewed before the
prod release — surface the numbers to the owner (they may change host settings).
**Tests.** Restore drill on a scratch instance (base + WAL → target timestamp) documented
and passing. No app-level tests.
**Done-criteria.** WAL archiving live on prod PG; a timed restore verified; cost + perf
assessment recorded and owner-reviewed; runbook in `deploy/README.md`.
**Notes/risks.** The tg host is weak (1 vCPU) — if the archive lands there, watch
contention. Migrations stay expand-contract so image rollback remains DB-safe alongside PITR.
---
## E5 — Payment intake
**Status:** TODO · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12.
**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`).
- 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. Add a
**SQLite** store on the bot's disk: on receipt, persist → ack the Telegram update → forward
to a backend payments-intake endpoint (internal, authenticated over the existing reverse
mTLS bot-link) → on backend ack, mark `forwarded`. Retries with backoff; re-drive
undelivered on restart. Backend intake dedups by `telegram_payment_charge_id`.
- Provide the invoice creation path (Stars) from the Mini App via the bot as needed.
**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; admin manual refund (ties to `accountdelete`); external
`refunded` events honoured — best-effort benefit revoke (never negative; record loss + abuse
flag if spent), ledger `refund` row. 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:** TODO · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) ·
mechanics: PAYMENTS §10.
**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 tracked server-side
(per user) or client-mirrored from a server value — pick and document at implementation.
- **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
**Status:** TODO · **Release 2** · depends on: E2 (ledger/grant), E5 (payments/refunds) ·
mechanics: PAYMENTS §11.
**Goal.** The admin console financial surface: per-user report, admin grant UI, manual
refund UI, ledger export.
**Work (`/_gm`, `backend/internal/server/handlers_admin_console.go` + `adminconsole/`).**
- **Per-user financial panel** on the existing user card (`consoleUserDetail` :343,
`UserDetailView`): segment balances, payments, spends, grants, refunds, full history —
read from the append-only ledger + materialized cache.
- **Admin grant** action (mirror the existing `POST /_gm/users/:id/grant-hints`): grant
concrete values (no-ads days / hints), **origin picker**, **never chips**; writes an
`admin_grant` ledger row (E2 `Grant`).
- **Manual refund** action: admin-initiated refund of a specific order (ties to the refund
path); records a `refund` ledger row; best-effort benefit revoke.
- **Ledger export**: CSV/JSON export of the ledger for tax reporting + future Robokassa
reconciliation (export-ready schema; reconciliation itself not built).
- Auth unchanged: gateway Basic-Auth in front of `/_gm` + backend same-origin CSRF on POSTs.
**Tests.**
- unit: report view assembly; grant refuses chips; export shape.
- integration: grant/refund write correct ledger rows; report reflects balances+history.
**Done-criteria.** An operator can see a user's full financial picture, grant concrete
values (origin-picked, never chips), issue a manual refund, and export the ledger.
**Notes/risks.** `/_gm` per-user detail already surfaces `PaidAccount`/`HintBalance` — replace
those with the segmented view as the legacy columns retire.
---
## E8 — Guest limits
**Status:** TODO · **standalone** (game-behaviour change; can run in parallel) · depends on:
none · mechanics: PAYMENTS §6.
**Goal.** A registration funnel: cap what a guest can do, enforced **server-side** (today the
gating is UI-only).
**Finding (verified).** The friend/invitation paths do **not** check `is_guest` on the
server — only the UI hides them: `social/friends.go:50` `SendFriendRequest`,
`robotfriends.go:41` `RequestInGame`, `friendcodes.go:67` `RedeemFriendCode`,
`lobby/invitations.go:208` `CreateInvitation`. A guest with a valid `X-User-ID` can call them
in the UI's stead.
**Work.**
- **Server guest gate** on friend-request / redeem-code / invitation-create: refuse when the
caller `is_guest` (add an `ErrGuestForbidden`-style guard, as `feedback` already has).
- **Active-game limits** for guests: at most **1 random-opponent game + 1 vs_ai** concurrently
(enforce on game/invitation creation in `lobby`/`game`; today no such limit exists).
- Keep the existing UI gating; this adds the server as the source of truth.
**Tests.**
- integration: guest is refused friend/redeem/invitation server-side; guest blocked from a
2nd random / 2nd vs_ai; durable account unaffected.
- UI: guest sees the funnel (existing hides + any new messaging).
**Done-criteria.** Guests are server-limited to 1 random + 1 vs_ai and cannot friend/invite;
durable accounts unchanged; regression that this does not break existing durable flows.
**Notes/risks.** This changes existing game behaviour — its own tests, its own PR, no
mixed-in payments changes. Decide whether a guest may finish an already-started game (limit
on creation, not mid-game) at implementation and record it here.
---
## E9 — Tournament fee (future)
**Status:** TODO · **future** · depends on: E0 (atom provisioned), tournament feature ·
mechanics: PAYMENTS §5, §7.
**Goal.** Charge a chip entry fee for tournaments. The `tournament` atom + a catalog product
are provisioned in E0/E7; the spend mechanic reuses E2 (`Spend`). The actual tournament
feature and its coupling to entry are out of scope until the tournament feature exists.
**Done-criteria.** Deferred — revisit when tournaments are built.
---
## 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.