Files
scrabble-game/PLAN.md
T
Ilia Denisov bab57343e1
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
chore(jet): regenerate the backend jet to match the migrations
The committed go-jet code had drifted from the schema: robot_blocks and
robot_friend_requests (created in the baseline) had no generated tables, the
feedback_messages model was missing app_version/browser_tz (added in 00003 and
00004), and UseSchema omitted account_best_move and the two robot tables.
Regenerate so the jet layer mirrors the migrations again.

Additive only — two nullable columns appended to feedback_messages plus two new
tables; the full build, vet, unit and integration suites stay green.
2026-07-08 01:18:36 +02:00

35 KiB
Raw Blame History

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 (RU mirror 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 DONE
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 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) 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: 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_atomatom_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.ordersorder_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_eventsevent_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: 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 handleResolveSessionresolveResponse 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.svelteSettingsTab 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.