21fb14facf
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
The last money-intake slice: reverse a paid order best-effort, exactly once. All refunds are admin-triggered (E7) — no rail pushes an unsolicited refund (Robokassa via its refund API / cabinet, VK via support, Telegram via refundStarPayment), so this ships the engine they all converge on, not a webhook. The Refund method matches the paid order, appends a refund ledger row (idempotent on (provider, provider_refund_id) — distinct from the fund's payment id, so both coexist), and revokes the funded chips floored at 0 (never negative — D27, balances_chips_chk). When the chips were already spent, the unrecoverable remainder is recorded 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 the snapshot; the order stays paid. Additive migration (a new table only) -> rollback-safe, no contour wipe. Robokassa refund-status polling is deferred (a worker not worth it at low chargeback volume); failed events are not wired (no rail signals a hard post-charge server decline). Tests: integration (full revoke; revoke-after-spend = floor-0 + loss + abuse; duplicate idempotent; unpaid-order guard). Docs: PAYMENTS(+ru) §9, PLAN (E5 -> DONE).
762 lines
47 KiB
Markdown
762 lines
47 KiB
Markdown
# PLAN — monetization implementation
|
||
|
||
Technical, step-by-step implementation of the monetization domain. Business mechanics and
|
||
the rationale for every rule live in [`docs/PAYMENTS.md`](docs/PAYMENTS.md) (RU mirror
|
||
[`docs/PAYMENTS_ru.md`](docs/PAYMENTS_ru.md)); the frozen owner agreements they both derive
|
||
from (the `D1`–`D41` decisions log) live in
|
||
[`docs/PAYMENTS_DECISIONS_ru.md`](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.md` updated in
|
||
step.
|
||
- **Status marks:** each stage header carries `Status: TODO | WIP | DONE`. When a stage
|
||
lands, flip it to DONE and note the PR. The **Progress** table is the at-a-glance index.
|
||
- **Per-change discipline** (repo `CLAUDE.md`): update tests at the layers `docs/TESTING.md`
|
||
calls out, bake doc updates into the same PR, run local full verification before pushing,
|
||
feature branch → PR into `development`.
|
||
|
||
## Progress
|
||
|
||
| Stage | Title | Release | Status |
|
||
|-------|-------|---------|--------|
|
||
| E0 | Payments data foundation | 1 | DONE |
|
||
| E1 | Trusted platform signal | 1 | 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 | TODO |
|
||
| E7 | Admin & reports | 2 | TODO |
|
||
| E8 | Guest limits | — | TODO |
|
||
| E9 | Tournament fee | future | TODO |
|
||
|
||
**Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3).
|
||
**Release 2** = money (E4→E5→E6→E7). E8 is standalone (game-behaviour change, can run in
|
||
parallel). E9 is future.
|
||
|
||
---
|
||
|
||
## Architecture baseline (applies to all stages)
|
||
|
||
Read once; individual stages assume it.
|
||
|
||
### Schema & isolation
|
||
|
||
- The payments domain owns its **own Postgres schema `payments`** in the shared instance
|
||
(`scrabble` DB). All payments tables are `payments.*`. `backend` schema is untouched
|
||
except for the deprecation of two legacy columns (E2).
|
||
- **DB role.** A dedicated **NOLOGIN** role holds ALL privileges on `payments.*` and
|
||
**nothing** on `backend` — grant-based confinement, asserted by a `SET ROLE` isolation
|
||
test. The application still connects on its single (superuser) pool, so these grants are a
|
||
stepping-stone to a real separate login/process, not the runtime wall. The **runtime wall
|
||
is code-level**: only the `internal/payments` package imports the payments jet code and
|
||
issues `payments.*` SQL (an **import-boundary test** enforces it); every other domain
|
||
reaches payments through the narrow Go interface.
|
||
- **No cross-schema link at all.** There is **no** foreign key from `payments.*` to
|
||
`backend.accounts`: `account_id` is a plain `uuid`, kept referentially honest in code and
|
||
joined to the tombstoned account / `retained_identities` dossier by the stable id. This
|
||
keeps the spend transaction (ledger INSERT + balance UPDATE + benefit UPDATE) fully
|
||
**within `payments`** and the domain extractable into its own database later. Benefits move
|
||
**into** `payments` (they no longer live on `accounts` — see E2); game code reads them
|
||
through the payments **Go interface**, never via SQL.
|
||
- **Money.** Monetary amounts are a single **`bigint` in the currency's minor units** (RUB
|
||
kopecks; Votes/Stars/chips are whole units, scale 1) carried in Go by the `payments.Money`
|
||
value type — the sole constructor/formatter/arithmetic, so **no `float64` ever touches an
|
||
amount** and a whole-unit currency structurally cannot hold a fraction. `chip_rate` is not a
|
||
table: a chip pack is a product, so its per-method rate **is** `product_price`.
|
||
|
||
### Domain boundary
|
||
|
||
- New package `backend/internal/payments/` — `payments.go` (types + `Service`),
|
||
`store.go` (`type Store struct{ db *sql.DB }`, go-jet against `internal/postgres/jet/
|
||
payments`), following the `ads` domain shape (`backend/internal/ads/{ads,service,store}.go`).
|
||
- Wired in `backend/cmd/backend/main.go` `run()` (construct after `account`, before
|
||
`server.New`), handed into `server.Deps` (`server.go`), routes registered in
|
||
`registerRoutes` gated `if s.payments != nil`, admin section in `registerConsole` gated
|
||
`if s.payments != nil`.
|
||
- Game/other domains depend on payments only through a **narrow interface** (e.g.
|
||
`AdFree(ctx, account, platform, present) bool`, `SpendHint(ctx, account, platform, present)
|
||
error`, `Balances(ctx, account, platform, present) …`), where `present` is the set of
|
||
identity sources the account currently holds (`vk`→vk, `telegram`→telegram, `email`→direct;
|
||
`robot` ignored), computed by the caller from `account.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`/`Balances` and the gate) are served
|
||
from an in-process, account-keyed, write-through cache inside the payments package
|
||
(mirroring `account/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 the `payments` schema. The D39 materialized
|
||
`balances`/`benefits` tables 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/`, sequential
|
||
`0000N_*.sql`, `-- +goose Up/Down`, schema-qualified, **expand-contract mandatory**
|
||
(image rollback must stay DB-safe). First payments migration does `CREATE SCHEMA IF NOT
|
||
EXISTS payments`, creates the role, grants.
|
||
- **jetgen** (`backend/cmd/jetgen/main.go`) is currently hardwired to `schema=backend`.
|
||
Extend it to also generate `schema=payments` into `internal/postgres/jet/payments/
|
||
{model,table}` (committed). Regenerate only after a payments migration; revert unrelated
|
||
`backend`-schema churn (jetgen may reorder untouched tables — commit only intended diffs).
|
||
- Transactions: reuse the local `withTx(ctx, db, fn)` idiom. Balance/benefit mutations are
|
||
guarded single-row atomic UPDATEs (mirror `account.SpendHint`); the ledger INSERT +
|
||
materialized-cache UPDATE go in one `withTx`. Default Read-Committed is sufficient given
|
||
the guarded updates + unique idempotency keys; do not reach for Serializable without a
|
||
demonstrated need.
|
||
|
||
### Transport
|
||
|
||
- client ↔ gateway: Connect-RPC + FlatBuffers (h2c). gateway ↔ backend: REST/JSON +
|
||
`X-User-ID` via `gateway/internal/backendclient`. Any user-facing payments call needs the
|
||
full chain: backend REST handler (`u := s.user` group) → `backendclient` method →
|
||
Connect-RPC method + transcode (`gateway/internal/transcode/`) → FlatBuffers schema
|
||
(`pkg/fbs/…`, regen with `make -C pkg fbs` + `pnpm -C ui codegen`).
|
||
- Provider webhooks (Robokassa/VK) do NOT use the Connect path — model on the existing
|
||
public HMAC-signed route `s.public.GET("/dl/:id/:kind", …)` (`handlers.go`): a public
|
||
route with signature verification, proxied by the gateway/Caddy `@gateway` matcher
|
||
(`deploy/caddy/Caddyfile` — a new edge route MUST be added there or it falls to the
|
||
landing catch-all).
|
||
|
||
### Testing layers (`docs/TESTING.md`)
|
||
|
||
- **unit** (Go `_test.go`, TS vitest): gate-by-context, no-ads stacking, priority draw,
|
||
rate math, idempotency keys, catalog snapshotting. UI logic must be extracted out of
|
||
`.svelte` into `ui/src/lib/*` to be unit-testable.
|
||
- **integration** (`backend/internal/inttest/`, `//go:build integration`, Postgres-backed):
|
||
atomic chip↔benefit spend, callback idempotency, order-flow, TG outbox delivery, merge/
|
||
unlink of segments.
|
||
- **UI** (Playwright mock e2e, Chromium+WebKit): Wallet screen, warnings, guest-hidden, GP
|
||
stub. Mock e2e bypasses codec — wire bugs need codec unit tests.
|
||
- Local full verification before push: integration (`-tags=integration` + DAWG sibling +
|
||
Ryuk-off), UI check/test/build, codegen (`make -C pkg proto fbs`, `pnpm -C ui codegen`).
|
||
|
||
---
|
||
|
||
## E0 — Payments data foundation
|
||
|
||
**Status:** DONE · **Release 1** · depends on: none · mechanics: PAYMENTS §14, §7, §11.
|
||
|
||
**Goal.** Stand up the `payments` schema, its confinement role, the jetgen target, the domain
|
||
package skeleton and all core tables — nothing wired to real money yet. This is the substrate
|
||
every later stage builds on.
|
||
|
||
**Migration `00010_payments_foundation.sql` (`payments` schema).**
|
||
|
||
- `CREATE SCHEMA payments`; an idempotent `DO $$` block creates a **NOLOGIN** `payments` role;
|
||
`GRANT USAGE` + `GRANT ALL ON ALL TABLES` (+ `ALTER DEFAULT PRIVILEGES … GRANT ALL`) confine it
|
||
to `payments.*` with nothing on `backend`. No `REFERENCES` grant (there is no cross-schema FK).
|
||
- `payments.ledger` — append-only. `ledger_id` (uuid PK, app-generated v7), `account_id` (plain
|
||
`uuid`, **no FK**), `kind` (`fund|spend|admin_grant|refund`), `source`/`origin` (nullable,
|
||
`vk|telegram|direct`), `chips_delta` (signed int, 0 for a grant), `product_id` (nullable
|
||
FK→product), `order_id` (nullable FK→orders), `provider` + `provider_payment_id` (nullable),
|
||
`snapshot` (jsonb, written from E2), `created_at`. Immutability is a **`BEFORE UPDATE OR DELETE`
|
||
trigger** (a superuser bypasses a privilege REVOKE). Unique **partial** index on `(provider,
|
||
provider_payment_id) WHERE provider_payment_id IS NOT NULL` — the idempotency key.
|
||
- `payments.balances` — `(account_id, source)` PK, `chips int CHECK (>=0)`, `updated_at`. Created
|
||
**lazily** on first fund (up to three rows/account, absent = zero).
|
||
- `payments.benefits` — `(account_id, origin)` PK, `ads_paid_until timestamptz` null,
|
||
`ads_forever bool`, `hints int CHECK (>=0)`, `updated_at`. Created lazily.
|
||
- `payments.catalog_atom` — `atom_type` PK (`chips|hints|noads_days|tournament`); the four atoms
|
||
are **seeded** here (the `tournament` atom is provisioned for E9).
|
||
- `payments.product` (`product_id` PK, `title`, `active bool` soft-delete, timestamps);
|
||
`payments.product_item` (`(product_id, atom_type)` PK → `quantity`); `payments.product_price`
|
||
(`(product_id, COALESCE(method,''), currency)` unique → `amount bigint` minor units): a chip
|
||
pack carries a money price per `method` (`vk|telegram|direct`), a value carries one
|
||
`currency='CHIP', method=NULL` row. `currency ∈ {RUB, VOTE, XTR, CHIP}`; `amount CHECK (>=0)`.
|
||
- `payments.config` — a single typed row (`only_row bool PK CHECK`): `rewarded_payout_chips`,
|
||
`cooldown_global_seconds` (300), `cooldown_vs_ai_seconds` (1800), `cooldown_hint_seconds` (60),
|
||
`order_ttl_seconds` (1800). No `chip_rate` table — the pack rate is `product_price`.
|
||
- `payments.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` — the `Money` value type + `Currency`
|
||
(bigint minor units, exact, no float); `Store{db *sql.DB}` with a `Ping` health read via jet;
|
||
`Service` over the store. (`withTx` arrives with E2's first transaction.)
|
||
- `cmd/jetgen` generates the `payments` schema into `internal/postgres/jet/payments/` (a second
|
||
`GenerateDB` call); committed. Constructed in `cmd/backend/main.go` and passed to `server.Deps`
|
||
(`Payments`) with a boot-time reachability check; its routes are registered from E2.
|
||
|
||
**Legacy deprecation (expand phase only).** A `--` header note marks `accounts.hint_balance` and
|
||
`accounts.paid_account` deprecated; they are **not** dropped (the DROP is the contract phase after
|
||
E2 flips reads and after Release 2). Reads still use the old columns until E2.
|
||
|
||
**Tests.**
|
||
|
||
- integration (`inttest`): schema + role + seeds present; `SET ROLE payments` cannot read
|
||
`backend.accounts` (grant confinement); ledger rejects UPDATE/DELETE (trigger); idempotency
|
||
partial index holds (NULL-keyed rows repeat); balance/benefit/amount/enum CHECKs bite; migration
|
||
applies forward **and backward** on a throwaway PG.
|
||
- unit: `Money` — exact round-trip, per-currency scale, no-float parse rejecting a fraction for a
|
||
whole-unit currency, arithmetic, formatting. Import-boundary test: only `internal/payments`
|
||
imports the payments jet code.
|
||
|
||
**Done-criteria (met).** Migrations apply forward+backward on a throwaway PG 17; `go build
|
||
./backend/...` + `go vet` + `gofmt -l .` clean; committed `jet/payments/`; all tests green; no
|
||
behaviour change for users.
|
||
|
||
**Notes.** jetgen regenerates the whole `backend` schema; its committed jet had drifted from the
|
||
migrations (`robot_blocks`, `robot_friend_requests`, the `feedback_messages` `app_version` /
|
||
`browser_tz` columns, and `UseSchema` gaps), so this change also **regenerates and commits
|
||
`jet/backend`** to bring it back in sync (additive only — all suites stay green). The `payments`
|
||
role creation is idempotent (`DO $$` / `IF NOT EXISTS`) for fresh volumes.
|
||
|
||
---
|
||
|
||
## E1 — Trusted platform signal
|
||
|
||
**Status:** 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.sessions` gains nullable `platform_kind` + `platform_subtype` columns (migration
|
||
`00011`, CHECK-constrained, jet regenerated). A `session.Platform` value type + `session.Create`
|
||
captures it; `Session` carries it through the store and the warm cache.
|
||
- `handleResolveSession` → `resolveResponse` (`dto.go`) returns the platform; the establish
|
||
handlers set `kind` from their own endpoint (`/sessions/telegram|vk|guest|email/*`) and
|
||
`subtype` from 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-injected `X-Platform` into the request context
|
||
on the `s.user` group; `platform(c)` exposes it. Absent ⇒ untrusted (fail-closed).
|
||
|
||
**Gateway.**
|
||
|
||
- `backendclient.WithPlatform(ctx, "<kind>/<subtype>")` + `do`/`getRaw` inject `X-Platform` on
|
||
every authenticated backend call; `connectsrv.Execute` enriches 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): `vkauth` exposes the signed `vk_platform` and maps iPhone/iPad → ios (the frozen
|
||
case); the `X-Platform` middleware round-trips + reports untrusted when absent; the backend
|
||
client injects `X-Platform` from 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 `00011`
|
||
applies forward **and backward** on a throwaway PG.
|
||
- UI: subtype normalization (vitest) + the new `subtype` field 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 a `spend` ledger row (+ a catalog **snapshot**
|
||
of atoms+price) + decrement balance + apply benefit (extend `ads_paid_until[origin]` from
|
||
`max(now,end)` / set `ads_forever` / add hints) **in one tx**, stamping `origin` = platform
|
||
context. Refuse if untrusted (fail-closed) or funds insufficient.
|
||
- `Grant(ctx, account, origin, atoms)` → a **0-price sale of a value**: an `admin_grant`
|
||
ledger 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` / `Unlink` hooks: extend `accountmerge` to 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 from `present`. 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 to `payments.benefits`. `SpendHint` (`game/service.go` ~:1147) and
|
||
`ads.Eligible` (`ads/ads.go` :107) call the payments interface instead of the account
|
||
columns. Legacy values were never set in prod → zero them; the DROP is the contract phase
|
||
(guarded, after Release 2 — keep columns until then for rollback safety).
|
||
- `vs_ai` hints stay free/unlimited (unchanged 30-min gate); only online-game hint spend
|
||
routes through the segmented, context-aware path.
|
||
|
||
**API (user-facing, Connect chain).** Read-only wallet view + spend:
|
||
`GET /api/v1/user/wallet` (balances+benefits for the context), `POST /api/v1/user/wallet/
|
||
buy` (spend chips on a product). Add the backend handlers (`u := s.user`), `backendclient`
|
||
methods, Connect methods + transcode + FBS. Admin grant is internal/admin (E7 UI).
|
||
|
||
**Tests.**
|
||
|
||
- unit (Go): gate-by-context matrix (every row of PAYMENTS §4, 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** (no `chips` atom) carries its CHIP
|
||
price and shows everywhere; a **chip pack** (`chips` atom) 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 by `walletGate`) →
|
||
`catalogDTO`; gateway op `wallet.catalog` (`transcode.go` + `encodeCatalog`), `backendclient.
|
||
Catalog`; FBS `Catalog`/`CatalogProduct`/`CatalogAtom` (`pkg/fbs/scrabble.fbs`), client
|
||
`decodeCatalog`.
|
||
|
||
**UI (`ui/`).**
|
||
|
||
- New `'wallet'` tab in `ui/src/screens/SettingsHub.svelte` — `SettingsTab` union, tab button
|
||
**between Friends and About** (guest-hidden like Friends, offline-disabled like Profile/Friends),
|
||
body branch, `'wallet'` route in `ui/src/lib/routeparse.ts` + `ui/src/App.svelte`.
|
||
- `Wallet.svelte` screen: 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 with `wallet.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` (not `showPopup`) warns the benefit will be web/native-only. The context is inferred
|
||
client-side (`executionContext` — VK/Telegram/direct); the server enforces the real gate on
|
||
`wallet.buy` (fail-closed), so no wallet-DTO change was needed.
|
||
- Logic extracted to `ui/src/lib/{wallet,distribution}.ts` (unit-testable); `.svelte` stays 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): `projectCatalog` context matrix (value everywhere; pack per context method; misconfig
|
||
omitted). unit (vitest): money/price formatting, spendable-segment selection, warning trigger,
|
||
the GP flag; `decodeCatalog` codec round-trip (mock e2e bypasses the codec).
|
||
- integration (`inttest`): `/wallet/catalog` returns 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
|
||
(`?guest` seam); GP stub (`?gp`); warning modal on a vk-drawing web spend, no warning on a
|
||
direct-covered spend. Mock overlay stays instant under `MODE==='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 in `deploy/README.md`.
|
||
- **Archiving ships gated OFF** (`PGBACKREST_ARCHIVE_MODE` on the DB, `pitr_enabled` for 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 overlay `image:` + `prod-deploy.yaml` build/push list).
|
||
- Repository config + `archive_mode` via `PGBACKREST_*` env on the prod-overlay postgres
|
||
service; secrets/vars rendered by `deploy/write-prod-env.sh` from the `PROD_PGBACKREST_*`
|
||
Gitea set; **parity added to `prod-rollback.yaml`** (a rollback must not disarm archiving).
|
||
- Daily base-backup systemd timer (Ansible `main` role, gated by `pitr_enabled`).
|
||
- Two Grafana alerts on the exporter's `pg_stat_archiver` metrics (failing / stalled),
|
||
absent/NaN-safe on the contour.
|
||
- Belt-and-braces `pg_dump` fixed to dump the **whole DB** (`deploy/prod-deploy.sh` was
|
||
`-n backend`, silently excluding `payments`); 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_mode` restarts 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` → create `order(pending)` (account/platform/product/
|
||
expected amount/origin), return the provider-specific launch payload with `order_id`
|
||
threaded in (Robokassa `InvId` / TG `invoice_payload` / VK `item`). 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`
|
||
`@gateway` matcher — or they fall to the landing catch-all), signature/HMAC verified,
|
||
proxied into a payments intake handler. Match by `order_id`, verify amount, credit
|
||
(ledger `fund` + balance UPDATE in one tx), mark order `paid`, emit `payment_event`.
|
||
Idempotent by `(provider, provider_payment_id)`.
|
||
- **Pending sweep:** background reaper expires pending orders after the configured timeout
|
||
(~30 min). Expiry is cosmetic — a later valid callback still credits (revive/honour).
|
||
|
||
**TG bot outbox (`platform/telegram/`).**
|
||
|
||
- The bot receives `successful_payment` (and `pre_checkout_query`) via Bot API. A **SQLite**
|
||
store on the bot's disk (`internal/outbox`): on receipt, persist → forward over the reverse
|
||
mTLS bot-link to the **gateway** (a `ForwardPayment` unary; the bot cannot dial the backend
|
||
directly) → the gateway proxies to the backend payments-intake REST → on a durable response,
|
||
mark `forwarded`. Re-drive undelivered on startup and on a 30 s tick. Backend intake dedups by
|
||
`telegram_payment_charge_id`.
|
||
- Invoice creation (Stars) is minted by the bot (`createInvoiceLink`, XTR) on the gateway's
|
||
`CreateInvoice` bot-link command, returned to the Mini App as the `openInvoice` link.
|
||
|
||
**Events & notifications.**
|
||
|
||
- `payment_events` dispatcher: `succeeded`/`failed`/`refunded` → live gRPC stream if the
|
||
user is connected, else `botlink` push / email relay (existing). "failed" = an active
|
||
provider decline (not an abandoned pending) surfaced to the user; "succeeded" hook
|
||
(email/bot message).
|
||
|
||
**Receipts (§12).** Robokassa self-employed НПД receipt on payment (provider config); VK
|
||
handles Votes tax itself; TG Stars — no receipt.
|
||
|
||
**Refunds (§9).** ToS non-refundable. **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:** TODO · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) ·
|
||
mechanics: PAYMENTS §10.
|
||
|
||
**Goal.** VK video ads: the post-move interstitial (frequency-gated) and the rewarded video
|
||
(credits chips via server verify), plus extending the existing banner suppression to
|
||
per-origin.
|
||
|
||
**Work.**
|
||
|
||
- **Provider abstraction:** a small ads-network interface (VK impl now) so a future network
|
||
for other platforms slots in without rework. VK integration via `ui/src/lib/vk.ts`.
|
||
- **Rewarded:** voluntary view → the network's **server verify callback** → payments intake
|
||
credits chips to the `vk` segment (client not believed, like a payment). On-launch
|
||
anti-fraud = provider verify only (no own daily cap; abstraction allows later).
|
||
- **Interstitial** (post-move fullscreen), configurable server values (from `payments`
|
||
config): global per-user cooldown across all games (default 5 min); `vs_ai` 30 min; a hint
|
||
application triggers a post-move interstitial independently with its own 1-min cooldown;
|
||
offline banner-only; respect VK's own frequency caps. Cooldown state tracked server-side
|
||
(per user) or client-mirrored from a server value — pick and document at implementation.
|
||
- **Banner suppression:** extend `ads.Eligible` (`backend/internal/ads/ads.go` :107) to gate
|
||
on the **origin benefit applicable in the current context** (E2 interface) instead of the
|
||
single legacy flag. No-ads suppresses banner + interstitial; rewarded never suppressed.
|
||
|
||
**Tests.**
|
||
|
||
- unit: cooldown logic (global 5m / vs_ai 30m / hint-trigger 1m independence); rewarded
|
||
credit path; banner eligibility per context.
|
||
- integration: rewarded verify → credit exactly once (idempotent like a payment).
|
||
- UI: interstitial shows/respects cooldown (mock); rewarded button; no-ads hides banner +
|
||
interstitial; rewarded still available under no-ads.
|
||
|
||
**Done-criteria.** VK rewarded credits chips once per verified view; interstitial respects
|
||
all cooldowns incl. the hint trigger; no-ads suppresses banner+interstitial but not
|
||
rewarded; offline shows banner only.
|
||
|
||
**Notes/risks.** Crypto-payout networks stay rejected. Rewarded-without-payout is pointless —
|
||
only enable where the network pays (VK). Keep the interstitial frequency as server config so
|
||
it tunes without a store release.
|
||
|
||
---
|
||
|
||
## E7 — Admin & reports
|
||
|
||
**Status:** TODO · **Release 2** · depends on: E2 (ledger/grant), E5 (payments/refunds) ·
|
||
mechanics: PAYMENTS §11.
|
||
|
||
**Goal.** The admin console financial surface: per-user report, admin grant UI, manual
|
||
refund UI, ledger export.
|
||
|
||
**Work (`/_gm`, `backend/internal/server/handlers_admin_console.go` + `adminconsole/`).**
|
||
|
||
- **Per-user financial panel** on the existing user card (`consoleUserDetail` :343,
|
||
`UserDetailView`): segment balances, payments, spends, grants, refunds, full history —
|
||
read from the append-only ledger + materialized cache.
|
||
- **Admin grant** action (mirror the existing `POST /_gm/users/:id/grant-hints`): grant
|
||
concrete values (no-ads days / hints), **origin picker**, **never chips**; writes an
|
||
`admin_grant` ledger row (E2 `Grant`).
|
||
- **Manual refund** action: admin-initiated refund of a specific order (ties to the refund
|
||
path); records a `refund` ledger row; best-effort benefit revoke.
|
||
- **Ledger export**: CSV/JSON export of the ledger for tax reporting + future Robokassa
|
||
reconciliation (export-ready schema; reconciliation itself not built).
|
||
- Auth unchanged: gateway Basic-Auth in front of `/_gm` + backend same-origin CSRF on POSTs.
|
||
|
||
**Tests.**
|
||
|
||
- unit: report view assembly; grant refuses chips; export shape.
|
||
- integration: grant/refund write correct ledger rows; report reflects balances+history.
|
||
|
||
**Done-criteria.** An operator can see a user's full financial picture, grant concrete
|
||
values (origin-picked, never chips), issue a manual refund, and export the ledger.
|
||
|
||
**Notes/risks.** `/_gm` per-user detail already surfaces `PaidAccount`/`HintBalance` — replace
|
||
those with the segmented view as the legacy columns retire.
|
||
|
||
---
|
||
|
||
## E8 — Guest limits
|
||
|
||
**Status:** TODO · **standalone** (game-behaviour change; can run in parallel) · depends on:
|
||
none · mechanics: PAYMENTS §6.
|
||
|
||
**Goal.** A registration funnel: cap what a guest can do, enforced **server-side** (today the
|
||
gating is UI-only).
|
||
|
||
**Finding (verified).** The friend/invitation paths do **not** check `is_guest` on the
|
||
server — only the UI hides them: `social/friends.go:50` `SendFriendRequest`,
|
||
`robotfriends.go:41` `RequestInGame`, `friendcodes.go:67` `RedeemFriendCode`,
|
||
`lobby/invitations.go:208` `CreateInvitation`. A guest with a valid `X-User-ID` can call them
|
||
in the UI's stead.
|
||
|
||
**Work.**
|
||
|
||
- **Server guest gate** on friend-request / redeem-code / invitation-create: refuse when the
|
||
caller `is_guest` (add an `ErrGuestForbidden`-style guard, as `feedback` already has).
|
||
- **Active-game limits** for guests: at most **1 random-opponent game + 1 vs_ai** concurrently
|
||
(enforce on game/invitation creation in `lobby`/`game`; today no such limit exists).
|
||
- Keep the existing UI gating; this adds the server as the source of truth.
|
||
|
||
**Tests.**
|
||
|
||
- integration: guest is refused friend/redeem/invitation server-side; guest blocked from a
|
||
2nd random / 2nd vs_ai; durable account unaffected.
|
||
- UI: guest sees the funnel (existing hides + any new messaging).
|
||
|
||
**Done-criteria.** Guests are server-limited to 1 random + 1 vs_ai and cannot friend/invite;
|
||
durable accounts unchanged; regression that this does not break existing durable flows.
|
||
|
||
**Notes/risks.** This changes existing game behaviour — its own tests, its own PR, no
|
||
mixed-in payments changes. Decide whether a guest may finish an already-started game (limit
|
||
on creation, not mid-game) at implementation and record it here.
|
||
|
||
---
|
||
|
||
## E9 — Tournament fee (future)
|
||
|
||
**Status:** TODO · **future** · depends on: E0 (atom provisioned), tournament feature ·
|
||
mechanics: PAYMENTS §5, §7.
|
||
|
||
**Goal.** Charge a chip entry fee for tournaments. The `tournament` atom + a catalog product
|
||
are provisioned in E0/E7; the spend mechanic reuses E2 (`Spend`). The actual tournament
|
||
feature and its coupling to entry are out of scope until the tournament feature exists.
|
||
|
||
**Done-criteria.** Deferred — revisit when tournaments are built.
|
||
|
||
---
|
||
|
||
## Verification & CI (all stages)
|
||
|
||
- Per-stage tests at the layers above; **compliance-gate regression is mandatory** (a
|
||
`direct` benefit must never activate inside VK/TG) and runs from E2 onward.
|
||
- Local full verification before every push: `go build/vet ./backend/... && gofmt -l .`,
|
||
integration (`-tags=integration` + DAWG sibling + Ryuk-off), `pnpm -C ui check/test:unit/
|
||
build`, Playwright mock e2e, codegen (`make -C pkg proto fbs`, `pnpm -C ui codegen`).
|
||
- Contour: each PR into `development` auto-deploys the test contour; owner does visual sign-
|
||
off there (not local). Keep a multi-PR batch a linear stack (one shared contour,
|
||
last-deploy-wins).
|
||
- Prod (Release 2): expand-contract migrations only (image rollback DB-safe); PITR armed +
|
||
the E4 cost/perf assessment reviewed **before** the first money; new edge routes added to
|
||
the Caddyfile with a CI probe; caddy force-recreated on config-only changes.
|
||
- Release framing: **shipped docs/code/commits/PRs carry no stage ids** — finalize copy for
|
||
the release, not the plan.
|