Split the single Robokassa direct rail into one merchant shop per channel (web / android; ios later), chosen by the trusted X-Platform subtype, while every shop still credits the one `direct` wallet — merchant-account separation for accounting and receipts, not a new wallet. - config: a channel-keyed shops registry (web seeds from the legacy vars or _WEB_*, android from _ANDROID_*); an empty shop leaves the rail dormant; per-shop validation. - intake: the order picks its shop by subtype (unknown falls back to web); the per-shop Result callback is verified by that shop's own Password2 at /pay/robokassa/result/<channel> (the gateway extracts the channel; Caddy's /pay/* glob already forwards it — no Caddyfile change). - persistence: an additive `shop` column on the order (migration 00015), recorded from the payment context, surfaced per entry in the admin report. - standalone apps sign in by email only, so a direct purchase keeps its email anchor. - docs: PAYMENTS (+ru) topology, deploy env vars + compose mapping, the decisions log (D41 revised for the ИП / 54-ФЗ move; D42-D44), the plan. Contour-safe: dormant until shops are configured; the migration is additive (no wipe); no client wire change. Fiscalization (Receipt/Email) and the gateway `direct/android` subtype follow when the ИП / RuStore are live.
65 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 | DONE |
| E4 | Durability (PITR) | 2 | DONE |
| E5 | Payment intake | 2 | DONE |
| E6 | Ads | 2 | DONE |
| E7 | Admin, reports & catalog | 2 | DONE |
| E8 | Guest limits | — | DONE |
| E9 | Tournament fee | future | TODO |
| E10 | Multi-shop direct rail + ИП fiscalization | 2+ | 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. E10 splits the direct rail into per-channel Robokassa shops + ИП
fiscalization (post-launch, backend-first).
Architecture baseline (applies to all stages)
Read once; individual stages assume it.
Schema & isolation
- The payments domain owns its own Postgres schema
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: DONE · Release 1 · depends on: E2 · mechanics: PAYMENTS §1, §7, §6, §13, §4.
Goal. The user-facing "Кошелёк" (Wallet) section: balances + active benefits + the catalog storefront, honouring guest-hidden, GP-stub, and the web-spend warning.
Backend + wire (catalog read — new). E2 shipped wallet.get / wallet.buy (segments +
benefits + a chip spend) but no way to list the catalog; the storefront needs one, so E3 adds a
read path following the E2 shape:
payments.Service.Catalog(ctx, cxt)+store.loadCatalog(internal/payments/{catalog, store_catalog}.go) read every active product with atoms and prices and project them to the context (projectCatalog, pure + unit-tested): a value (nochipsatom) carries its CHIP price and shows everywhere; a chip pack (chipsatom) carries the money price for the context method (cxt.Kind: vk→VOTE, telegram→XTR, direct→RUB) and shows only where that method is priced. Read uncached (the catalog is small and rarely edited — unlike the per-account balances/benefits the read cache fronts).- REST
GET /api/v1/user/wallet/catalog(handleWalletCatalog, gated bywalletGate) →catalogDTO; gateway opwallet.catalog(transcode.go+encodeCatalog),backendclient. Catalog; FBSCatalog/CatalogProduct/CatalogAtom(pkg/fbs/scrabble.fbs), clientdecodeCatalog.
UI (ui/).
- New
'wallet'tab inui/src/screens/SettingsHub.svelte—SettingsTabunion, tab button between Friends and About (guest-hidden like Friends, offline-disabled like Profile/Friends), body branch,'wallet'route inui/src/lib/routeparse.ts+ui/src/App.svelte. Wallet.sveltescreen: context-available chip balances + active benefits (no-ads until date / forever, hints count). No history feed (PAYMENTS §11 — noise). Storefront: values priced in chips (buyable withwallet.buy), chip packs priced per method.- Guest: section hidden entirely (durable-only).
- GP build: the chip-pack purchases are hidden behind a RuStore stub; spending earned chips on
values still works (PAYMENTS §13). Detected by a build-time flag
VITE_GP_BUILD(ui/src/lib/distribution.ts), forcible under the mock e2e with?gp. - Web-spend warning: before a value spend that would draw vk/tg chips in a direct context, an
own
Modal(notshowPopup) warns the benefit will be web/native-only. The context is inferred client-side (executionContext— VK/Telegram/direct); the server enforces the real gate onwallet.buy(fail-closed), so no wallet-DTO change was needed. - Logic extracted to
ui/src/lib/{wallet,distribution}.ts(unit-testable);.sveltestays thin.
Pack purchase is display-only in E3. Buying a chip pack needs the money order flow, which is
E5; here a pack card shows its price with a disabled "Soon" action. E5 replaces that with the
launch/order CTA. Value spends are wired now (E2 wallet.buy), though a Release-1 durable account
has 0 chips until funding exists (E5/E6), so a real value spend returns insufficient-funds until then.
Tests.
- unit (Go):
projectCatalogcontext matrix (value everywhere; pack per context method; misconfig omitted). unit (vitest): money/price formatting, spendable-segment selection, warning trigger, the GP flag;decodeCatalogcodec round-trip (mock e2e bypasses the codec). - integration (
inttest):/wallet/catalogreturns active products with the context-correct price over Postgres; soft-deleted excluded. - UI (Playwright mock, Chromium+WebKit): Wallet renders between Friends/About; guest hides it
(
?guestseam); GP stub (?gp); warning modal on a vk-drawing web spend, no warning on a direct-covered spend. Mock overlay stays instant underMODE==='mock'(or it intercepts taps).
Done-criteria (met). Balances/benefits/storefront render per context; guest/GP/warning paths
verified by unit + integration + mock e2e on both engines. A populated contour review depends
on later stages (no products until the E7 catalog editor, no chip funding until E5/E6, no grant
UI until E7): on the contour E3 shows the correct empty wallet/storefront states; the populated
UI is proven by the mock e2e. (This replaces the earlier "demonstrable end-to-end via admin_grant"
line — the grant UI is E7.)
Notes/risks. No global .btn/.ghost in ui/ — styled per-component with scoped CSS + tokens
(mirror NewGame .invite). Product titles are single-language catalog data (E0 product.title),
shown verbatim; currency/chip words are i18n, counts follow the app's label+number convention (no
noun agreement). Svelte whitespace/$state naming gotchas apply.
E4 — Durability (PITR)
Status: DONE — armed + restore-drilled on prod (v1.13.0, 2026-07-09). · Release 2 · depends on: E0 (schema exists) · mechanics: PAYMENTS §14 (D4).
Goal. Continuous WAL archiving with point-in-time recovery, armed before the first real money is accepted (E5 prod). Protects both money and game data.
Locked decisions (this stage).
- Tool: pgBackRest. Destination: Selectel S3 object storage (encrypted AES-256-CBC, path-style addressing), prod main host only — the test contour never archives. (D4 left the tool + destination open; resolved here. Warm replica stays deferred, per D4.)
- Retention 30 days, daily full base backup (a systemd timer) + continuous WAL
(
archive_command, a 5-minute forced switch bounds the recovery point). Assessment (owner-reviewed prod gate): measured prod WAL ~0.77 MB/day, DB ~9.6 MB → archive < 1 ₽/month on Selectel S3, negligible perf impact. Recorded indeploy/README.md. - Archiving ships gated OFF (
PGBACKREST_ARCHIVE_MODEon the DB,pitr_enabledfor the timer — both default off), so the merged/redeployed artifact is inert until armed and an un-armed prod deploy cannot pile WAL onto the disk.
Work (landed in the artifacts PR into development).
- pgBackRest in the DB image (
deploy/postgres/Dockerfile); postgres becomes a built + pushed image (prod overlayimage:+prod-deploy.yamlbuild/push list). - Repository config +
archive_modeviaPGBACKREST_*env on the prod-overlay postgres service; secrets/vars rendered bydeploy/write-prod-env.shfrom thePROD_PGBACKREST_*Gitea set; parity added toprod-rollback.yaml(a rollback must not disarm archiving). - Daily base-backup systemd timer (Ansible
mainrole, gated bypitr_enabled). - Two Grafana alerts on the exporter's
pg_stat_archivermetrics (failing / stalled), absent/NaN-safe on the contour. - Belt-and-braces
pg_dumpfixed to dump the whole DB (deploy/prod-deploy.shwas-n backend, silently excludingpayments); manual-restore runbook updated. - Full PITR runbook + arming sequence + recorded assessment in
deploy/README.md.
Prod arming (completed 2026-07-09 with the v1.13.0 release). Owner created the Selectel S3
bucket (erudite, ru-6) + the PROD_PGBACKREST_* secrets/variables (incl. ARCHIVE_MODE=on);
promoted development → master → prod-deploy (archive_mode on behind the maintenance window),
then stanza-create + first base backup (31.9 MB cluster → 3.7 MB in the repo) + check, the
Ansible -e pitr_enabled=true timer, and a restore drill on an isolated one-shot target (data
intact, then wiped). Exact steps: deploy/README.md (point-in-time recovery — arming).
Tests. Restore drill on an isolated one-shot instance (base + WAL → target timestamp),
recorded in deploy/README.md. No app-level tests. Local verification: docker compose config
valid + the custom PG image builds (archiving inert on the contour).
Done-criteria (met). WAL archiving live on prod PG (v1.13.0); a restore verified on an
isolated one-shot target; cost + perf assessment reviewed; runbook current in
deploy/README.md.
Notes/risks. The repository cipher passphrase is unrecoverable if lost — stored apart
from the S3 keys. Manual/timer pgBackRest runs use docker exec -u postgres … --pg1-user=scrabble
(docker exec is root; the DB superuser role is scrabble, not postgres) — the systemd timer
- the runbook carry this. Enabling
archive_moderestarts postgres (rode the prod-deploy maintenance window). Migrations stay expand-contract so image rollback remains DB-safe with PITR.
E5 — Payment intake
Status: DONE · Release 2 · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12.
Delivery & baked decisions. Shipped as a linear PR stack (owner's choice), Robokassa first.
Resolved: match the order by a Robokassa Shp_order custom parameter, not the numeric InvId
(an order id is a uuid); idempotency key = the order id (provider_payment_id = order_id); the НПД
receipt is formed shop-side in the Robokassa cabinet, so no Receipt parameter is sent; a
chargeback never drives the balance negative (D27 stands, balances_chips_chk kept), so E5 is
schema-free (no migration, no contour wipe). Delivered on feature/payment-intake-robokassa:
the offer page (/offer/), the order/fund engine (idempotent, honours an expired order), the
internal/robokassa adapter, the POST /wallet/order + internal Result-callback handlers (a D36
confirmed-email gate on direct), the pending reaper, the wallet.order edge wire + the public
/pay/* routes, the Wallet purchase CTA, the contour deploy env (IsTest forced), and the
payment_events dispatcher — an in-app wallet-refresh push (KindNotification "payment") with a
self-closing provider-return page (the payment opens in a separate window) and a return-focus
refetch fallback. The VK Votes rail is delivered too: the client opens
VKWebAppShowOrderBox({item: order_id}); a two-phase signed server callback (get_item → the pack
title + vote price; a chargeable order_status_change → the same Fund with source=vk, idempotent
on VK's own order id) is verified at the gateway with the app protected key (GATEWAY_VK_APP_SECRET,
already deployed) and proxied to the backend intake. The Telegram Stars rail is delivered on
feature/payment-intake-tg-stars: only the bot reaches Telegram, so the invoice is minted by the
bot — on the wallet.order path the gateway sends a new CreateInvoice command over the reverse
bot-link and the bot returns the createInvoiceLink (XTR) in its Ack, handed to the client's
WebApp.openInvoice. The bot answers pre_checkout_query via a new bot→gateway ValidatePreCheckout
unary (backed by the backend: the order must exist, be still creditable and not already paid — the
reusable-invoice double-pay guard — with a matching amount); the decline reason is localised to the
order account's language. A completed successful_payment is persisted to a pure-Go SQLite outbox
(modernc.org/sqlite) then forwarded by a new bot→gateway ForwardPayment unary into the same
Fund (source=telegram, idempotent on telegram_payment_charge_id, honours an expired order),
re-driven at startup and every 30 s. The rail is wired by TELEGRAM_STARS_OUTBOX_DIR (defaults to the
bot /data volume) but stays inert until a chip pack carries an XTR price, so seeding a Stars price
in the admin is the go-live. Finally refunds are delivered on feature/payment-intake-refunds: a
single Refund engine (internal/payments) reverses a paid order best-effort, exactly once —
idempotent on (provider, provider_refund_id), revoking the funded chips floored at 0 (never
negative, D27), and recording the unrecoverable remainder (chips already spent) as a per-account
loss + abuse flag in the new additive payments.account_risk table (read by the E7 report). The
refund ledger row's chip delta is what was actually reclaimed (the ledger stays reconcilable); the
full reversal rides in its snapshot; the order stays paid. No rail pushes an unsolicited refund
— all are admin-triggered (E7): Robokassa refund API / cabinet (auto-polling deferred — a worker not
worth it at low chargeback volume), VK via support, Telegram refundStarPayment. failed events are
not wired (no rail signals a hard post-charge server decline). The migration is additive (a new
table only), so E5 stays rollback-safe / no contour wipe. That closes E5. Deferred to a later stage:
hiding the ad banner on a no-ads purchase (a spend-path NotifyBanner, with the owner's agreement).
Goal. Accept real money on all three rails into the payments domain: order-flow, verified provider callbacks, idempotency, the TG bot SQLite outbox, the event dispatcher, receipts, and refunds.
Order-flow & intake (single writer).
POST /api/v1/user/wallet/order→ createorder(pending)(account/platform/product/ expected amount/origin), return the provider-specific launch payload withorder_idthreaded in (RobokassaInvId/ TGinvoice_payload/ VKitem). The storefront's chip-pack card wires its purchase CTA to this here, replacing the disabled "Soon" placeholder E3 left (ui/src/screens/Wallet.svelte).- Robokassa + VK public webhooks: new edge routes (add to
deploy/caddy/Caddyfile@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. A SQLite store on the bot's disk (internal/outbox): on receipt, persist → forward over the reverse mTLS bot-link to the gateway (aForwardPaymentunary; the bot cannot dial the backend directly) → the gateway proxies to the backend payments-intake REST → on a durable response, markforwarded. Re-drive undelivered on startup and on a 30 s tick. Backend intake dedups bytelegram_payment_charge_id. - Invoice creation (Stars) is minted by the bot (
createInvoiceLink, XTR) on the gateway'sCreateInvoicebot-link command, returned to the Mini App as theopenInvoicelink.
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. All refunds are admin-triggered (E7): no rail pushes an
unsolicited refund — Robokassa refund API / cabinet (auto-polling deferred as a low-value worker),
VK via support, Telegram refundStarPayment. One Refund engine reverses a paid order best-effort,
exactly once (idempotent on (provider, provider_refund_id)): revoke floored at 0 (never negative),
unrecoverable remainder → per-account loss + abuse flag (payments.account_risk), a refund ledger
row (chip delta = revoked, full reversal in the snapshot). failed events are not wired (no rail
signals a hard post-charge server decline). Ledger export-ready (reconciliation not built).
Tests.
- unit: signature/HMAC verifiers per rail; order-id matching; idempotency-key dedup.
- integration: full order→callback→credit per rail; duplicate callback credits once; expired order still honoured on late callback; TG outbox store→forward→ack→cleanup + restart re-drive; refund revoke best-effort/never-negative.
- UI: purchase flow reaches the provider launch (mock); success/failure surfaced.
Done-criteria. A real (sandbox) payment on each rail credits chips exactly once; a replayed callback does not double-credit; the TG outbox survives a bot restart mid-flight; failed/succeeded events reach the user; refund path exercised. PITR (E4) armed and the cost/perf assessment reviewed before this goes to prod.
Notes/risks. Manual docker run -p boot tests fail from the shell — verify the runnable
artifact via the testcontainers integration suite. Distroless services run UID 65532;
bind-mounted secrets must be 0644. Edge routes silently fall through if not added to the
Caddyfile — add a CI probe. The prod rolling deploy skips caddy on config-only changes —
force-recreate when the Caddyfile changes.
E6 — Ads
Status: DONE · Release 2 · depends on: E2 (chips), E5 (rewarded credits via intake) · mechanics: PAYMENTS §10.
Delivery & baked decisions. Shipped as a linear PR stack (owner's choice): rewarded first,
then interstitial. Baked: the interstitial cooldowns already exist in payments.config (E0) and the
per-origin banner suppression is already done (E2 AdFree), so E6 is the two ad DISPLAY paths + the
rewarded credit. VK reality (checked live in the VK docs via Playwright): VK Mini App ads
(VKWebAppShowNativeAds, both reward and interstitial) expose only a client-side data.result
boolean — no server verify, no signature. So D29 is amended: rewarded is client-attested,
guarded by a server daily + hourly cap (config reward_daily_cap / reward_hourly_cap, default
50 / 10) that is both anti-abuse and an economic conversion lever (limits free chips so players buy);
the cooldown state for the interstitial is client-mirrored (owner's pick). Delivered on
feature/ads-rewarded (the rewarded slice): the ads-network abstraction (ui/src/lib/ads.ts, VK
impl) + the VK bridge (vkRewardedReady / vkShowRewarded), the backend CreditReward (VK-only,
order-less, idempotent on a client nonce, floored by the caps, payout from config
rewarded_payout_chips default 0 = off), the wallet.reward edge op returning the updated wallet
(with reward_chips gating the "watch for chips" CTA), and a contour test stub (VITE_ADS_STUB →
a toast instead of a real ad; prod always real). A temporary diagnostic confirmed on the contour that
VK returns only {result:true} (no token/signature) — client-attested is final, no hardening
possible; the diagnostic is removed. The slice also corrects the VK-iOS freeze to purchase-only
(rewarded on VK-iOS earns chips, which the old blanket "spend freeze" then blocked from spending —
Apple forbids only buying in-app values, not spending or earning them; vkFrozen() now gates only
CreateOrder, not spendableSources, so VK-wallet chips spend on VK-iOS). Delivered on
feature/ads-interstitial (the interstitial + D31 slice): the post-move fullscreen interstitial
as a client-mirrored gate — the backend adsFor puts the config cooldowns + a suppressed flag
(the no-ads / no_banner gate, same as the banner) on the profile (Profile.ads), and
ui/src/lib/ads.ts maybeShowInterstitial self-gates on the last-shown time per kind in
localStorage, showing a VK interstitial (vkShowInterstitial) after a confirmed play or a hint
only (never a pass / exchange / resign), VK-only, offline banner-only, with the same VITE_ADS_STUB
toast on the contour. The slice also lands D31 step 1 (contract-code): the domain no longer reads
or writes the deprecated accounts.hint_balance / paid_account columns — the Account fields, the
dead account.SpendHint, account.GrantHints and the admin grant-hints action are removed, and
the in-game hint display now comes wholly from the payments benefit (HintsAvailable). The columns
stay (no migration → image rollback is DB-safe); a later contract-PR does the DROP once E6 is
stable on prod.
Goal. VK video ads: the post-move interstitial (frequency-gated) and the rewarded video (credits chips via server verify), plus extending the existing banner suppression to per-origin.
Work.
- Provider abstraction: a small ads-network interface (VK impl now) so a future network
for other platforms slots in without rework. VK integration via
ui/src/lib/vk.ts. - Rewarded: voluntary view → the network's server verify callback → payments intake
credits chips to the
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 is client-mirrored (the chosen option): the server sends the cooldowns +suppressedon the profile and the client self-gates on a per-kind last-shown time inlocalStorage— no per-move round-trip. - Banner suppression: extend
ads.Eligible(backend/internal/ads/ads.go:107) to gate on the origin benefit applicable in the current context (E2 interface) instead of the single legacy flag. No-ads suppresses banner + interstitial; rewarded never suppressed.
Tests.
- unit: cooldown logic (global 5m / vs_ai 30m / hint-trigger 1m independence); rewarded credit path; banner eligibility per context.
- integration: rewarded verify → credit exactly once (idempotent like a payment).
- UI: interstitial shows/respects cooldown (mock); rewarded button; no-ads hides banner + interstitial; rewarded still available under no-ads.
Done-criteria. VK rewarded credits chips once per verified view; interstitial respects all cooldowns incl. the hint trigger; no-ads suppresses banner+interstitial but not rewarded; offline shows banner only.
Notes/risks. Crypto-payout networks stay rejected. Rewarded-without-payout is pointless — only enable where the network pays (VK). Keep the interstitial frequency as server config so it tunes without a store release.
E7 — Admin, reports & catalog
Status: DONE · Release 2 · depends on: E2 (ledger/grant/spend), E5 (payments/refunds),
E6 (D31 retired the legacy hint_balance/paid_account) · mechanics: PAYMENTS §11, §12, D32.
Delivery & baked decisions (this planning round). A linear PR stack into development.
- Archived product = the existing
product.activeflag (no new column, no migration):active=falseis "archived". Its three behaviours already hold — hidden from the user storefront (store_catalog.gofiltersactive), an in-flight external payment still credits (thefundcredit path resolves the order and never re-checksactive; only order creation / chip spend requireactive), and a product with any order/ledger row cannot be hard-deleted (FKorders→product/ledger→productare RESTRICT). The admin toggle is labelled Archive / Unarchive. - Delete vs archive: the editor offers a hard Delete only for a product with no orders and no ledger rows (never transacted — the FK is the DB backstop); a transacted product is archive-only.
- Admin grant = raw atoms + by-product. Keep the quick raw-atom grant (N hints / no-ads
days / forever) AND add grant-by-product: pick a defined product (including archived "reward"
bundles) and grant its atoms. Both write an
admin_grantledger row; by-product recordsproduct_id+ the snapshot. Both refuse any set containingchips(admin never grants currency) ortournament(no credit target until E9) — an explicit refusal, never a silent no-op. - Manual refund = full order only for now (the E5
Refundengine takes an amount; partial is a later add if needed). - Tournament stays atom-only (E0); its entry economy is E9. The catalog editor can compose
products carrying the
tournamentatom (archived templates for E9), but granting/spending atournamentatom is refused until E9 designs the storage (recurring types, each its own benefit + price) — see E9. Adding abenefits.tournamentcounter now was rejected: the model is multi-type, so a single column would be wrong and force a second DB break. - The admin grant no longer mirrors
grant-hints(E6/D31 removed that action); it is a fresh action onpayments.Grant.
Goal. The admin console financial surface — per-user report, admin grant, manual refund, ledger export — plus the configurable product catalog editor (D32).
Work (/_gm, handlers_admin_console.go + adminconsole/, internal/payments/).
- Per-user financial panel on the user card (
consoleUserDetail,UserDetailView): segment chip balances(account, source), benefits(account, origin)(hints, no-ads until/forever), and the full append-only ledger (fund/spend/admin_grant/refund — amount, origin, product, provider, snapshot) from the ledger + materialized cache. Replaces the retiredPaidAccount/HintBalancefields with the segmented view. - Catalog editor (
/_gm/catalog): list every product (active + archived) with its atoms and per-method/currency prices; create/edit (title, atom itemsatom_type→quantity, price rowsmethod+currency→amount); Archive/Unarchive (active); Delete (never-transacted only). Enforce the projection's shape: a pack (carrieschips) needs a money price per method; a value (nochips) needs a singleCHIPprice. Atournament-bearing product is allowed in composition but cannot be activated for sale until E9. - Admin grant action: raw atoms (hints / no-ads days / forever) and by-product (a value
product, incl. archived); origin picker; refuses
chips/tournament;admin_grantledger row (+product_id/snapshot for by-product) viapayments.Grant. - Manual refund action: refund a specific paid order in full — a
refundledger row + best-effort floor-0 benefit revoke (E5Refund). - Ledger export: CSV/JSON for tax + future Robokassa reconciliation (export-ready; reconciliation itself not built).
- Auth unchanged: gateway Basic-Auth in front of
/_gm+ backend same-origin CSRF on POSTs.
Tests.
- unit: panel view assembly; catalog pack/value shape projection; grant refuses chips + tournament; editor validation (pack⇒money price, value⇒CHIP price); export shape.
- integration: grant (raw + by-product) writes the right
admin_grantrow + benefit; refund writes arefundrow + floor-0 revoke; catalog CRUD round-trips (create→edit→archive→delete-if-clean); delete refused on a transacted product; panel reflects balances + benefits + history.
Done-criteria. An operator can: see a user's full financial picture; create/edit/archive products + prices and delete only never-transacted ones; grant concrete values raw or by-product (origin-picked, never chips/tournament); refund an order in full; export the ledger.
PR stack (linear into development, all merged).
Per-user financial panel(#230) — read-only ledger/segments/benefits on the user card; retired thePaidAccount/HintBalancedisplay.Catalog editor(#231) — product/atom/price CRUD + archive/unarchive + delete-if-clean + shape validation.Admin grant(#232, + the D31 hint-wallet wire cleanup that surfaced there) — raw + by-product, refuse chips/tournament, origin-picked,admin_grant+ snapshot.Manual refund (full order via E5—Refund) + ledger CSV exportRefundOrderFull(idempotent, floor-0 revoke) on each fund row;/_gm/ledger.csv.
Notes/risks. High-blast-radius (money, ledger — append-only, trigger-enforced). No mixed-in refactors. The catalog editor becomes the source of truth for products; the contour SQL seeds become bootstrap-only.
E8 — Guest limits
Status: DONE · standalone (game-behaviour change) · depends on: none · mechanics: PAYMENTS §6.
Goal. A registration funnel: cap what a guest can do, enforced server-side (today UI-only), with configurable per-tier × per-kind limits so the pressure tunes without a release.
Finding (verified). Two gaps. (a) The friend/invitation paths do not check is_guest on the
server — only the UI hides them: social/friends.go, robotfriends.go, friendcodes.go,
lobby/invitations.go. A guest with a valid X-User-ID can call them. (b) A pre-existing flat
cap already existed: game.MaxActiveQuickGames=10 — a combined cap on active+open quick
games (vs_ai+random together, friend games excluded), counted by CountActiveQuickGames, enforced at
the handler (ensureUnderGameLimit) with 409 game_limit_reached, surfaced as at_game_limit.
E8's per-tier × per-kind config subsumes and replaces it (decided: option A).
Delivery & baked decisions (this planning round). A linear PR stack; E8 is game-behaviour, no payments mixed in.
games.game_kind smallint DEFAULT 0(0=unknown — pre-E8 games, never gated; 1=vs_ai; 2=random; 3=friends), set on creation (StartVsAI=1,Enqueue=2, a friend invitation=3). Existing games stay 0 and fall outside the gate.- Limits are per-tier × per-kind, in a new single-row
backend.configtable:guest_{vs_ai,random,friends}_limit+durable_{vs_ai,random,friends}_limit(smallint,-1=unlimited), seeded(1,1,0, 10,10,10). A guest is capped at 1 vs_ai + 1 random (friends is moot — the guest gate blocks friend games); a durable account is 10 per kind — the old flat MaxActiveQuickGames=10, now split per kind. Editable in the admin. - The old flat cap is removed (option A, owner-agreed):
MaxActiveQuickGames,CountActiveQuickGames,atGameLimitdeleted; the per-tier/kind config is the single mechanism, enforced at the same handler gate (ensureUnderGameLimit(kind)forenqueuerandom/vs_ai) plus the durable friends cap insideCreateInvitation. The gate stays out of the game domain —game.Service.AtGameLimit(account, kind)only resolves tier + counts. - A hot in-memory cache fronts the config (read once on start, invalidated on the admin edit — mirrors the payments read-cache) so a login / game-create never queries it.
- "Active" =
open+active(an open, unmatched random game holds a slot); the limit is on creation — an existing game is grandfathered, never interrupted. - Limits ride the wire (forward-compat, no double break):
Profile.game_limits(GameLimits{vs_ai, random, friends}— the caller's tier resolved server-side) +GameView.kind, so the client counts active games per kind from its lobby and locks the right start button. On a guest → durable upgrade (register / link) the client re-fetches the profile so the new (durable) limits apply. The client picks the lock's message by tier: a guest → the login funnel; a durable account at its cap → a plain "finish a current game first" notice.
Work.
PR1 — backend + admin (server-side) — DONE.The migration (game_kind+backend.config, seeded1,1,0 / 10,10,10+ jetgen);game_kindset on creation and projected ontogame.Game; the guest gate (ErrGuestForbidden, mapped to 403guest_forbidden) on friend-request / redeem-code / befriend-in-game / invitation-create; the active-limit enforce — the old flatMaxActiveQuickGamesmechanism removed and replaced byensureUnderGameLimit(kind)onenqueue(random/vs_ai) plus the durable friends cap inCreateInvitation, keyed offgame.Service.AtGameLimit; theinternal/gamelimitsconfig + hot cache (loaded at boot, invalidated on edit); the admin kind column in both game lists (/_gm/games+ the user card) and a config editor (/_gm/limits) for the six limits.PR2 — wire + client — DONE.Profile.game_limits(the caller's tier) +GameView.kind(FBS- gateway transcode + client codec, committed regen); the client counts active games per kind from
the lobby cache (
gamelimits.ts) and locks a capped new-game start — an outline 🔒 button that opensGameLimitModalinstead of a game, native (TelegramshowPopup) or the in-appModalelsewhere, with two messages by tier: a guest sign-in funnel ("Войдите или создайте учётную запись…", Отмена / Вход→/settings) and, for a durable account at its cap, a plain notice ("Вы достигли лимита одновременных игр, сначала завершите текущие", ОК). The lock lifts via the existing profile re-fetch after a guest→durable upgrade.
- Decision (owner-agreed): the lobby's old
at_game_limitNew-Game tab-disable + notice is removed. Theat_game_limitflag (now the random-kind cap) conflicted with the per-kind lock — it hid the New-Game screen where the lock lives, and wrongly blocked starting an unfulfilled kind. The tab is always enabled; the per-kind lock on the start button is the only gate. The wire fieldGameList.at_game_limitstays (unused by the client) for a later cleanup.
- gateway transcode + client codec, committed regen); the client counts active games per kind from
the lobby cache (
Tests.
- integration (PR1, done):
game_kindpersisted per path; guest refused friend/redeem/invitation (domain + HTTP 403); guest blocked from a 2nd vs_ai / 2nd random (+at_game_limit); durable on the higher tier; the durable friends cap + config cache reflecting an admin edit; accept stays exempt. - unit (PR1, done): the per-tier/kind limit resolution (
Cap,LimitsFor). - unit + UI (PR2, done): the client lock logic (
gamelimits.ts— count/cap/lock), the codec kind + game_limits roundtrip, the gateway transcode game_limits encode, the popup builders, and a mock e2e (gamelimit.spec.ts) — a capped start shows 🔒, opens the modal without navigating, and the lock clears when the profile refetch lifts the cap.
Done-criteria. A guest is server-capped (default 1 vs_ai + 1 random, configurable) and cannot friend/invite; a durable account is capped at 10 per kind (configurable); the client shows the lock + the tier-appropriate modal and the cap lifts on registration; durable flows otherwise unchanged.
Notes/risks. Game-behaviour change — own PRs, own tests, no payments mixed in. The limit is on creation (existing games grandfathered). The config cache is single-instance (matching the deploy).
E9 — Tournament fee (future)
Status: TODO · future · depends on: E0 (atom provisioned), tournament feature · mechanics: PAYMENTS §5, §7.
Goal. A chip entry economy for tournaments. The tournament atom is provisioned (E0) and
E7's catalog editor can compose tournament products; E7 deliberately **defers the entry storage
- credit/spend** here, to avoid guessing the schema.
Design to settle here (not before — avoid a double DB break). Tournaments are expected in
several recurring types (daily / weekly / monthly …), each its own benefit with its own
price — so a single benefits.tournament counter is wrong. Model the entry store as
per-tournament-type (e.g. a tournament_type catalog + a per-(account, type) entry
balance), design the pricing, then:
- lift E7's refusal of granting/spending the
tournamentatom (admin grant by-product + chip spend); - add the spend (charge an entry) reusing E2
Spend; - wire the coupling to the actual tournament feature (out of scope until it exists).
Done-criteria. Deferred — revisit when tournaments are built; this stage owns the tournament-entry storage + pricing design.
E10 — Multi-shop direct rail + ИП fiscalization
Status: TODO · Release 2+ (post-launch, backend-first) · depends on: E5 (intake/Fund,
the robokassa adapter, the Result callback), E7 (the per-user report — channel breakdown) ·
mechanics: PAYMENTS §9, §12; decisions D41 (rev), D42–D44.
Goal. Split the single Robokassa direct rail into per-channel merchant shops (web,
android; ios later) — separate merchant accounts / withdrawal / accounting and a per-channel
breakdown in the E7 report — while keeping one direct wallet (D42). Land the wallet/identity
rules the native apps need (email-only anchor, D43) and revise fiscalization for the owner's move
to ИП / 54-ФЗ (D41 rev). All additive / contour-safe and money-live → expand-contract
throughout.
Locked decisions (owner interview 2026-07-14): D41 (rev), D42, D43, D44. No wallet-model /
spend-wall change; the split is merchant-account routing under one direct segment. Route the shop
by the trusted X-Platform subtype, never a client field; unknown → web.
B1 — Config: one shop → a set of shops.
robokassa.Config(single) → a shops registry keyed by channel (web,android;ioslater), each 4 fields (MerchantLogin/Password1/Password2/IsTest). New envBACKEND_ROBOKASSA_WEB_*,BACKEND_ROBOKASSA_ANDROID_*.- Expand-contract, no flag-day: the legacy
BACKEND_ROBOKASSA_*seeds thewebshop; add the new vars; retire the legacy set after the prod rollout sets the split vars.validate(): a shop with a login must carry both passwords (mirror the current check), per shop. - Config/README +
deploy/.env.example+ the deploy ansible vars.
B2 — Route the shop by channel (intake).
handleWalletOrderSourceDirectbranch: pick the shop bycxt.Subtype(web/android; unknown →web). Build the payment request from that shop. The subtype rides the trusted gateway-injectedX-Platform(<kind>/<subtype>,parsePlatformHeader) — no spoofable client field. Verify the gateway emitsdirect/androidfor the native build; if it sends a baredirect/, add the android subtype (small gateway change).- Record the chosen
shopon the order (feeds B5).
B3 — Per-shop Result callbacks.
- The callback must select the right
Password2→ each shop gets its own Result URL: per-shop public edge routes (mirror the existing single Robokassa Result route, e.g.…/result/web,…/result/android), each forwarded to the backend and verified with that shop's config, then the sameFund(source staysdirect). Add the routes to the Caddyfile@gatewaymatcher (else they fall to the landing catch-all) + a CI probe per route. - Keep the legacy single route alive (shop =
web) through the expand-contract window until the cabinet Result URLs are cut over.
B4 — ИП fiscalization (D41 rev) — orthogonal, gated on the owner's ИП/ОФД/СНО.
- Owner/cabinet: enable the 54-ФЗ cloud kassa in the Robokassa ЛКК (kassa + ОФД + СНО). Required for ИП regardless of code.
- Code (optional, itemized чеки): send a one-line
Receipt(pack name, qty 1,sum,taxper СНО,payment_object/payment_method) + the customerEmail(the D36 confirmed anchor) in the payment request; extend the signature toMerchantLogin:OutSum:InvId:Receipt:Password1(URL-encodeReceipt). One line fits the current GET redirect; POST-form only as a URL-length fallback. One feature, all shops. Sequenced last — the split (B1–B3, B5, B6) needs no fiscalization. Supersedes the E5 §12 shop-side НПД receipt note.
B5 — Channel in the report (D44).
- Additive
shopcolumn on the order (defaultweb/ backfill fromorigin); a per-channel breakdown in the E7 per-user report (D40). Expand-contract migration (nullable/defaulted column), contour-safe (a schema touch → note the contourDROP SCHEMAstep inPRERELEASE.md).
B6 — Tests + docs.
- unit: the
robokassashops registry + per-shopVerifyResult(rightPassword2); signature-with-Receipt(B4); intake shop routing by subtype (web/android/unknown→web). - integration: order→per-shop callback→credit (each route), a duplicate credits once, an expired
order still honoured; the
shoprecorded + reported. - docs:
PAYMENTS.md(+_ru) the multi-shop topology + the ИП/54-ФЗ receipt revision;deploy/README+.env.examplethe new vars + the per-shop Result routes + probes;PRERELEASE.mdtheshopcolumn contour step.
Done-criteria. A direct/web order pays through the web shop and direct/android through the
android shop, each verified with its own Password2, both crediting one direct wallet exactly
once; the E7 report breaks payments down by channel; the split stays contour-safe (the legacy shop
still works until the cabinet cutover). B4 (fiscalization) verified when the owner's ИП/ОФД/СНО are
live.
Notes/risks. Edge routes fall through if not in the Caddyfile @gateway matcher — add a CI
probe (field note). The prod rolling deploy skips caddy on config-only changes — force-recreate on a
Caddyfile change. Money-live: expand-contract only, image rollback DB-safe. Confirm the gateway
emits direct/android for the native build before relying on subtype routing.
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.