Stand up the internal chip/benefit mechanic behind the narrow payments interface: context-aware balances and benefits, an atomic chip spend, admin grants as zero-price value sales, the one-directional store-compliance gate (VK/TG same- origin only, web draws direct→vk→tg, VK-iOS frozen, untrusted fail-closed), and per-origin hint and no-ads application with term stacking. Reads are served from an in-process, account-keyed write-through cache (mirroring the suspension gate), so hot paths issue no query to the payments schema. Flip the online-game hint wallet and the ad-banner suppression from the deprecated accounts.hint_balance / paid_account columns to the payments benefit (a hint balance no longer suppresses the banner — only a no-ads benefit does), and fold chip segments and benefits by origin on account merge, inside the merge tx. Add the GET/POST /api/v1/user/wallet edge chain (REST → Connect → FlatBuffers) plus its codec unit test; no wallet UI yet. Bring the frozen owner decisions log into the repo at docs/PAYMENTS_DECISIONS_ru.md (it was untracked under .vscode) and reference it from PLAN.md; record the read-cache design and the present-sources interface in PLAN.md and docs/PAYMENTS.md (+ RU mirror).
38 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); the frozen owner agreements they both derive
from (the D1–D41 decisions log) live in
docs/PAYMENTS_DECISIONS_ru.md — the authority when a rule
is disputed. This file is the how. Each stage is written to be self-sufficient: returning
to it gives full context — goal, exact touch-points, tests, done-criteria, and current
status — without re-deriving decisions.
How to use this file
- Stage granularity (E0–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 | DONE |
| E2 | Currency + benefit core | 1 | DONE |
| 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, present) bool,SpendHint(ctx, account, platform, present) error,Balances(ctx, account, platform, present) …), wherepresentis the set of identity sources the account currently holds (vk→vk,telegram→telegram,email→direct;robotignored), computed by the caller fromaccount.Identities— payments carries no cross-schema identity knowledge, so unlink/re-link availability (D14) falls out live. Keep the surface small; this is the seam that lets payments become a separate process later without touching callers. - Read model. Hot reads (
AdFree/HintsAvailable/Balancesand the gate) are served from an in-process, account-keyed, write-through cache inside the payments package (mirroringaccount/suspension.go), invalidated on every payments mutation (spend/grant/fund/refund/merge); the payments DB is touched only on a cache miss, so the steady-state hot path issues no query to thepaymentsschema. The D39 materializedbalances/benefitstables stay the in-transaction write target the cache fronts. Single-instance, matching the deployment (a multi-instance backend would need a shared cache).
Migrations & codegen
- Migrations: same goose dir
backend/internal/postgres/migrations/, 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 had drifted from the
migrations (robot_blocks, robot_friend_requests, the feedback_messages app_version /
browser_tz columns, and UseSchema gaps), so this change also regenerates and commits
jet/backend to bring it back in sync (additive only — all suites stay green). The payments
role creation is idempotent (DO $$ / IF NOT EXISTS) for fresh volumes.
E1 — Trusted platform signal
Status: DONE · Release 1 · depends on: none (parallel to E0) · mechanics: PAYMENTS §8.
Goal. Make the server know the execution platform from a trusted, unforgeable source, carried on the session. This is the foundation the gate (E2) stands on; without it the gate is meaningless. Signal plumbing only — no user-visible change.
Model. platform = {kind: vk|telegram|direct, subtype: ios|android|web} is a property of
the session, captured at creation. kind is always trusted — the gateway derives it from
the validated establish path (VK launch / TG initData / a web-native session), never a client
field. subtype is cryptographically trusted only for VK (it rides inside the signed
vk_platform launch param); for telegram and direct it is client-reported best-effort, and the
gate never relies on it (only the VK-iOS-frozen case is compliance-critical, and VK subtype is
trusted). VK/TG re-mint a session on every cold start, so their platform is re-captured each
launch; web/direct/email reuse the stored token, so their platform is captured once at creation
(direct needs no signature).
Validation stays at the gateway. Both wrapper verifiers already live there and the backend
cannot import either (separate modules under internal/, and the VK app secret is gateway-only):
VK launch params via the in-process gateway/internal/vkauth.Verify (HMAC-SHA256 over the signed
vk_* params under GATEWAY_VK_APP_SECRET, extended here to expose the signed vk_platform →
a trusted Subtype()), TG initData via the validator RPC. The gateway hands the derived
platform to the backend at establish; the backend persists it and returns it on resolve.
Backend.
backend.sessionsgains nullableplatform_kind+platform_subtypecolumns (migration00011, CHECK-constrained, jet regenerated). Asession.Platformvalue type +session.Createcaptures it;Sessioncarries it through the store and the warm cache.handleResolveSession→resolveResponse(dto.go) returns the platform; the establish handlers setkindfrom their own endpoint (/sessions/telegram|vk|guest|email/*) andsubtypefrom the request (VK: gateway-extracted from the signed params; TG/direct: the client best-effort field, defaulting web). The account-merge session mint (link.merge) inherits the caller's platform from the request context.- Middleware (
middleware.go) parses the gateway-injectedX-Platforminto the request context on thes.usergroup;platform(c)exposes it. Absent ⇒ untrusted (fail-closed).
Gateway.
backendclient.WithPlatform(ctx, "<kind>/<subtype>")+do/getRawinjectX-Platformon every authenticated backend call;connectsrv.Executeenriches the request context from the resolved session's platform (carried through the gateway session cache +ResolveSession). Never from a client request body.
Client (ui/). platformSubtype() (ui/src/lib/platform.ts) supplies a best-effort device
subtype on the auth.telegram / auth.guest / auth.email.login requests (new FBS subtype
field): Telegram's WebApp.platform inside a Mini App, else the Capacitor/web channel. VK sends
nothing (the gateway derives the trusted subtype from the signed launch params).
Tests.
- unit (Go):
vkauthexposes the signedvk_platformand maps iPhone/iPad → ios (the frozen case); theX-Platformmiddleware round-trips + reports untrusted when absent; the backend client injectsX-Platformfrom the context and omits it when untrusted. - integration: a session carries its platform through create + a cold-cache (DB) resolve for each
kind; an unattributed session is untrusted; the CHECK constraints bite; migration
00011applies forward and backward on a throwaway PG. - UI: subtype normalization (vitest) + the new
subtypefield on the wire (codec unit test).
Done-criteria (met). A VK/TG session resolves to a trusted {kind, subtype}; a forged body
cannot change kind (nor the VK subtype); direct sessions resolve to direct; the untrusted
path is reachable and observable via platform(c). No user-visible change; all layers green.
Notes/risks. High blast-radius (auth/session table + broad gateway header threading): additive
migration, no mixed-in refactors, X-Platform inert until E2 consumes it. Sessions minted before
E1 carry no platform → untrusted (view-only) until re-login; VK/TG self-heal on the next
cold-start re-mint, direct/email do not (accepted: Release 1 has no money, sessions cycle by
Release 2). TG/direct subtype is not cryptographically trusted — E2 must keep the gate on kind
- the trusted VK subtype only.
E2 — Currency + benefit core
Status: DONE · Release 1 · depends on: E0, E1 · mechanics: PAYMENTS §2–§6, §11.
Goal. The full internal money mechanic, exercisable end-to-end without real money
via admin_grant: chip balances, spend→benefit atomically, the compliance gate by context,
benefit application (no-ads stacking, hints per-origin), the legacy migration, and the
SpendHint rewrite. This is the heart.
Payments service (Go). Every read/gate method also takes present (the account's live
identity sources, see Domain boundary) and is served from the read cache.
Balances(ctx, account, platform, present)→ per-segment{source, chips, spendable}for the context (VK→vk spendable, direct/tg hidden; TG→tg; direct→direct+vk+tg by priority; VK-iOS→vk shown as a frozen number, none spendable; untrusted→view-only, none spendable).Spend(ctx, account, platform, present, productID)→ gate-checked purchase of a value (CHIP price,method=NULL; never a chip pack) with chips: resolve spendable segments by context, draw by priority direct→vk→tg, write aspendledger row (+ a catalog snapshot of atoms+price) + 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)→ a 0-price sale of a value: anadmin_grantledger row (chips_delta=0, + snapshot), same benefit application; concrete values only, never chips (no chips move — it neither requires nor touches a balance). Origin chosen by the admin. In E2 this is the Go service method only, exercised by tests; the admin grant UI- catalog editor are E7.
AdFree(ctx, account, platform, present)/HintsAvailable(…)/SpendHint(…)→ apply the one-directional origin rule: in context P, usable origins = {P} plus, when P is web/native, {vk,tg} (relaxation outward); in VK only vk; in TG only tg. Hint consumption draws by the same priority direct→vk→tg.Merge/Unlinkhooks: extendaccountmergeto merge segments and benefits by origin (same-origin add — chips sum, terms extend per origin; different origins coexist) in the caller's tx (MergeTx), and make both segment and benefit availability follow identity presence — unlink sleeps the balance and the benefit, re-link wakes them (D14), resolved live frompresent. Warn-before-unlink surfaces the balance via the interface; the warning UI itself is not E2.
Backend wiring & migration.
- Deprecate-and-flip
accounts.hint_balance/accounts.paid_account: E0 added the tables; here, move reads/writes 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, incl. VK-iOS frozen +
untrusted); priority draw; no-ads stacking (
+=from max(now,end)) + forever override; per-origin hint applicability + direct→vk→tg draw; merge/unlink segment+benefit math; catalog snapshotting; read-cache invalidation on each mutation. - integration: atomic spend (ledger+balance+benefit in one tx; failure rolls all back); admin_grant credits values not chips; legacy migration zeroes and flips reads; merge/ unlink over Postgres.
- UI: none new (E3 builds the screen); wallet DTO covered by codec unit tests.
Done-criteria. admin_grant applies no-ads/hints directly (a 0-price sale of a value —
no chips involved); the player chip-spend path (Spend) is proven by unit/integration tests
(balance seeded in-test, since a legitimate chip source arrives only with Release 2); the gate
blocks cross-context spend and cross-origin application; the ads gate reads the new benefit;
SpendHint uses segments; the hot read paths hit the read cache, not the payments schema;
all layers green; compliance regression (a direct benefit never activates inside VK/TG)
is a named test.
Notes/risks. This is the high-blast-radius core (money semantics + a live path
SpendHint/ads.Eligible). Minimize surface, keep the interface narrow, no mixed-in
refactors. The legacy flip is expand-contract: reads move first, DROP much later.
E3 — Wallet UI
Status: 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.