Stand up the payments data foundation: a self-contained `payments` Postgres schema for the in-game currency, wallets, benefits, catalog, orders and the append-only operations ledger, behind a domain package — nothing wired to real money yet. - Migration 00010: the `payments` schema and a NOLOGIN confinement role (ALL on payments.*, nothing on backend); the ledger with a BEFORE UPDATE/DELETE append-only trigger and a partial idempotency index; the materialised balances/benefits; the catalog (atoms seeded) + products + per-method prices; the typed single-row config; orders and payment_events. There is no cross-schema foreign key — account_id is a plain uuid kept consistent in code, which keeps the domain extractable. Expand-contract and reversible. - Money is a bigint in the currency's minor units carried by a `Money` value type (exact, math/big): no float ever touches an amount, and a whole-unit currency cannot hold a fraction. - Extend jetgen to generate the payments schema; construct the service in the composition root behind a narrow interface with a boot reachability check. - Tests: integration (role confinement via SET ROLE, the append-only trigger, CHECK constraints, the idempotency index, and a forward+backward migration), Money unit tests, and an import-boundary test keeping the payments jet code private to the domain. - Docs: PLAN.md, docs/PAYMENTS.md (+ _ru mirror) updated to the built model.
34 KiB
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 (E0–E9) 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.mdupdated 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 layersdocs/TESTING.mdcalls out, bake doc updates into the same PR, run local full verification before pushing, feature branch → PR intodevelopment.
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
paymentsin the shared instance (scrabbleDB). All payments tables arepayments.*.backendschema is untouched except for the deprecation of two legacy columns (E2). - DB role. A dedicated NOLOGIN role holds ALL privileges on
payments.*and nothing onbackend— grant-based confinement, asserted by aSET ROLEisolation 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 theinternal/paymentspackage imports the payments jet code and issuespayments.*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.*tobackend.accounts:account_idis a plainuuid, kept referentially honest in code and joined to the tombstoned account /retained_identitiesdossier by the stable id. This keeps the spend transaction (ledger INSERT + balance UPDATE + benefit UPDATE) fully withinpaymentsand the domain extractable into its own database later. Benefits move intopayments(they no longer live onaccounts— see E2); game code reads them through the payments Go interface, never via SQL. - Money. Monetary amounts are a single
bigintin the currency's minor units (RUB kopecks; Votes/Stars/chips are whole units, scale 1) carried in Go by thepayments.Moneyvalue type — the sole constructor/formatter/arithmetic, so nofloat64ever touches an amount and a whole-unit currency structurally cannot hold a fraction.chip_rateis not a table: a chip pack is a product, so its per-method rate isproduct_price.
Domain boundary
- New package
backend/internal/payments/—payments.go(types +Service),store.go(type Store struct{ db *sql.DB }, go-jet againstinternal/postgres/jet/ payments), following theadsdomain shape (backend/internal/ads/{ads,service,store}.go). - Wired in
backend/cmd/backend/main.gorun()(construct afteraccount, beforeserver.New), handed intoserver.Deps(server.go), routes registered inregisterRoutesgatedif s.payments != nil, admin section inregisterConsolegatedif 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/, sequential0000N_*.sql,-- +goose Up/Down, schema-qualified, expand-contract mandatory (image rollback must stay DB-safe). First payments migration doesCREATE SCHEMA IF NOT EXISTS payments, creates the role, grants. - jetgen (
backend/cmd/jetgen/main.go) is currently hardwired toschema=backend. Extend it to also generateschema=paymentsintointernal/postgres/jet/payments/ {model,table}(committed). Regenerate only after a payments migration; revert unrelatedbackend-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 (mirroraccount.SpendHint); the ledger INSERT + materialized-cache UPDATE go in onewithTx. 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-IDviagateway/internal/backendclient. Any user-facing payments call needs the full chain: backend REST handler (u := s.usergroup) →backendclientmethod → Connect-RPC method + transcode (gateway/internal/transcode/) → FlatBuffers schema (pkg/fbs/…, regen withmake -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@gatewaymatcher (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.svelteintoui/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 idempotentDO $$block creates a NOLOGINpaymentsrole;GRANT USAGE+GRANT ALL ON ALL TABLES(+ALTER DEFAULT PRIVILEGES … GRANT ALL) confine it topayments.*with nothing onbackend. NoREFERENCESgrant (there is no cross-schema FK).payments.ledger— append-only.ledger_id(uuid PK, app-generated v7),account_id(plainuuid, 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 aBEFORE UPDATE OR DELETEtrigger (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 timestamptznull,ads_forever bool,hints int CHECK (>=0),updated_at. Created lazily.payments.catalog_atom—atom_typePK (chips|hints|noads_days|tournament); the four atoms are seeded here (thetournamentatom is provisioned for E9).payments.product(product_idPK,title,active boolsoft-delete, timestamps);payments.product_item((product_id, atom_type)PK →quantity);payments.product_price((product_id, COALESCE(method,''), currency)unique →amount bigintminor units): a chip pack carries a money price permethod(vk|telegram|direct), a value carries onecurrency='CHIP', method=NULLrow.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). Nochip_ratetable — the pack rate isproduct_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— theMoneyvalue type +Currency(bigint minor units, exact, no float);Store{db *sql.DB}with aPinghealth read via jet;Serviceover the store. (withTxarrives with E2's first transaction.)cmd/jetgengenerates thepaymentsschema intointernal/postgres/jet/payments/(a secondGenerateDBcall); committed. Constructed incmd/backend/main.goand passed toserver.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 paymentscannot readbackend.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: onlyinternal/paymentsimports 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 was already drifted
from the migrations (robot_blocks, robot_friend_requests, a feedback_messages column), so
that churn was reverted and only jet/payments/ committed — a pre-existing drift worth a separate
cleanup. 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/): addplatform(kind+subtype) to the session row +Sessionstruct; set it at creation and refresh it on a validated cold-start call. - Session resolve (
handlers_auth.go handleResolveSession→resolveResponseDTOdto.go): returnplatformso the gateway can carry it. - Validation: TG
initDatavalidator already exists (platform/telegram/internal/initdata) — reuse. Add VK launch-paramssignverification (HMAC over sorted params with the VK app secret) — new small verifier, unit-tested against known VK vectors. - Gateway (
gateway/internal/backendclient): inject a trustedX-Platformheader (mirrorX-User-IDinjection inclient.go do()), sourced from the resolved session — never from the client request body. - Backend middleware: read
X-Platforminto context alongsideX-User-ID(middleware.go); expose a typed accessorplatform(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 aspendledger row + decrement balance + apply benefit (extendads_paid_until[origin]frommax(now,end)/ setads_forever/ add hints) in one tx, stampingorigin= platform context. Refuse if untrusted (fail-closed) or funds insufficient.Grant(ctx, account, origin, atoms)→admin_grantledger 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/Unlinkhooks: extendaccountmergeto 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 topayments.benefits.SpendHint(game/service.go~:1147) andads.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_aihints 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 inui/src/screens/SettingsHub.svelte—SettingsTabunion, tab button between Friends (:65) and About (:66), body branch in the:42-51switch,'wallet'route inui/src/lib/routeparse.ts+ui/src/App.svelte. Reuse the hub's guest/offline gating. Wallet.sveltescreen: 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(notshowPopup— that eats the user-activation gesture) warning the value will be web/native-only. - Extract all logic into
ui/src/lib/*(unit-testable); keep.sveltethin.
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 + ansibledeploy/ansible/), config + secrets under thePROD_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:
- 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.
- 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→ createorder(pending)(account/platform/product/ expected amount/origin), return the provider-specific launch payload withorder_idthreaded in (RobokassaInvId/ TGinvoice_payload/ VKitem).- Robokassa + VK public webhooks: new edge routes (add to
deploy/caddy/Caddyfile@gatewaymatcher — or they fall to the landing catch-all), signature/HMAC verified, proxied into a payments intake handler. Match byorder_id, verify amount, credit (ledgerfund+ balance UPDATE in one tx), mark orderpaid, emitpayment_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(andpre_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, markforwarded. Retries with backoff; re-drive undelivered on restart. Backend intake dedups bytelegram_payment_charge_id. - Provide the invoice creation path (Stars) from the Mini App via the bot as needed.
Events & notifications.
payment_eventsdispatcher:succeeded/failed/refunded→ live gRPC stream if the user is connected, elsebotlinkpush / 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
vksegment (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
paymentsconfig): global per-user cooldown across all games (default 5 min);vs_ai30 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 anadmin_grantledger row (E2Grant). - Manual refund action: admin-initiated refund of a specific order (ties to the refund
path); records a
refundledger 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 anErrGuestForbidden-style guard, asfeedbackalready 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
directbenefit 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
developmentauto-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.