12e616ceae
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m28s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
The merchant accepts payments as a sole proprietor on НПД, which is outside 54-ФЗ: there is no online cash register, YooKassa does not serve receipts for that regime at all (checked with their support), and each operation is reported by the merchant to «Мой налог», which issues the чек. So no `receipt` is sent with a payment or a refund. This also removes a failure class rather than just code: a malformed receipt was an API error at payment creation, which broke the purchase outright. The fiscal code is kept dormant rather than deleted. All of it now sits behind one switch, `BACKEND_YOOKASSA_VAT_CODE`: unset — the default — builds and sends nothing; a 54-ФЗ rate code turns «Чеки от ЮKassa» back on unchanged. The return is foreseeable, which is why the switch exists: НПД carries an annual income ceiling, and losing the regime puts 54-ФЗ back in force, at which point this is a deploy-variable edit instead of writing the integration again. The D36 email anchor still gates a direct purchase. It had two justifications — a recovery anchor and the receipt address — and only the second is gone; without an email a paying customer who loses the account loses the chips with it. What was missing is that the rule was enforced but never communicated: the wallet showed the packs to a player signed in through VK or Telegram in a browser, and tapping Buy produced a bare "something went wrong". It now says "add an email in your profile" in the buy tab instead, linking to the profile; spending already-earned chips is untouched. The predicate is a pure function so it is covered by the node-env unit tests rather than needing a browser. Tests: the receipt-off default is pinned by an integration test asserting a purchase carries no receipt, and the dormant path by one that switches a VAT code on and checks the receipt reappears with the right fiscal attributes; plus unit coverage for the enable predicate and the wallet's email rule. A note for whoever runs the numbers next: the shared (svelte + i18n) chunk is now 40 bytes under its 31 KB gzip budget. Decision D51 revised.
1090 lines
72 KiB
Markdown
1090 lines
72 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`–`D51` 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–E12) 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 | DONE |
|
||
| E7 | Admin, reports & catalog | 2 | DONE |
|
||
| E8 | Guest limits | — | DONE |
|
||
| E9 | Tournament fee | future | TODO |
|
||
| E10 | Multi-shop direct rail + ИП fiscalization | 2+ | DONE |
|
||
| E11 | Payment availability kill switch + per-account override | 2 | DONE |
|
||
| E12 | YooKassa direct rail (Robokassa retired) | 2+ | DONE |
|
||
|
||
**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 `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:** 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 `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 is **client-mirrored**
|
||
(the chosen option): the server sends the cooldowns + `suppressed` on the profile and the
|
||
client self-gates on a per-kind last-shown time in `localStorage` — 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.active` flag** (no new column, no migration):
|
||
`active=false` **is** "archived". Its three behaviours already hold — hidden from the user
|
||
storefront (`store_catalog.go` filters `active`), an **in-flight external payment still
|
||
credits** (the `fund` credit path resolves the order and never re-checks `active`; only order
|
||
*creation* / chip *spend* require `active`), and a product with any order/ledger row **cannot
|
||
be hard-deleted** (FK `orders→product` / `ledger→product` are 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_grant` ledger row; by-product records
|
||
`product_id` + the snapshot. **Both refuse any set containing `chips`** (admin never grants
|
||
currency) **or `tournament`** (no credit target until E9) — an explicit refusal, never a
|
||
silent no-op.
|
||
- **Manual refund = full order only** for now (the E5 `Refund` engine 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 `tournament` atom (archived templates for E9), but granting/spending a
|
||
`tournament` atom is refused until E9 designs the storage (recurring types, each its own
|
||
benefit + price) — see E9. Adding a `benefits.tournament` counter 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 on `payments.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 retired
|
||
`PaidAccount`/`HintBalance` fields 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 items `atom_type→quantity`, price rows
|
||
`method+currency→amount`); **Archive/Unarchive** (`active`); **Delete** (never-transacted
|
||
only). Enforce the projection's shape: a **pack** (carries `chips`) needs a money price per
|
||
method; a **value** (no `chips`) needs a single `CHIP` price. A `tournament`-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_grant` ledger
|
||
row (+ `product_id`/snapshot for by-product) via `payments.Grant`.
|
||
- **Manual refund** action: refund a specific paid order **in full** — a `refund` ledger row +
|
||
best-effort floor-0 benefit revoke (E5 `Refund`).
|
||
- **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_grant` row + benefit; refund writes
|
||
a `refund` row + 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).**
|
||
|
||
1. ~~**Per-user financial panel**~~ (#230) — read-only ledger/segments/benefits on the user card;
|
||
retired the `PaidAccount`/`HintBalance` display.
|
||
2. ~~**Catalog editor**~~ (#231) — product/atom/price CRUD + archive/unarchive + delete-if-clean +
|
||
shape validation.
|
||
3. ~~**Admin grant**~~ (#232, + the D31 hint-wallet wire cleanup that surfaced there) — raw +
|
||
by-product, refuse chips/tournament, origin-picked, `admin_grant` + snapshot.
|
||
4. ~~**Manual refund** (full order via E5 `Refund`) + **ledger CSV export**~~ — `RefundOrderFull`
|
||
(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.config`** table:
|
||
`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`, `atGameLimit` deleted; the per-tier/kind config is the single mechanism,
|
||
enforced at the **same handler gate** (`ensureUnderGameLimit(kind)` for `enqueue`
|
||
random/vs_ai) plus the durable **friends cap** inside `CreateInvitation`. 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`,
|
||
seeded `1,1,0 / 10,10,10` + jetgen); `game_kind` set on creation and projected onto `game.Game`;
|
||
the **guest gate** (`ErrGuestForbidden`, mapped to **403 `guest_forbidden`**) on friend-request /
|
||
redeem-code / befriend-in-game / invitation-create; the **active-limit enforce** — the old flat
|
||
`MaxActiveQuickGames` mechanism removed and replaced by `ensureUnderGameLimit(kind)` on `enqueue`
|
||
(random/vs_ai) plus the durable friends cap in `CreateInvitation`, keyed off
|
||
`game.Service.AtGameLimit`; the **`internal/gamelimits` config + 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
|
||
opens `GameLimitModal` instead of a game, native (Telegram `showPopup`) or the in-app `Modal`
|
||
elsewhere, 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_limit` New-Game tab-disable + notice is
|
||
removed.** The `at_game_limit` flag (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 field
|
||
`GameList.at_game_limit` stays (unused by the client) for a later cleanup.
|
||
|
||
**Tests.**
|
||
|
||
- integration (PR1, done): `game_kind` persisted 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 `tournament` atom (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:** DONE (merged — the multi-shop PR) · **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`; `ios`
|
||
later), each 4 fields (`MerchantLogin`/`Password1`/`Password2`/`IsTest`). New env
|
||
`BACKEND_ROBOKASSA_WEB_*`, `BACKEND_ROBOKASSA_ANDROID_*`.
|
||
- **Expand-contract, no flag-day:** the legacy `BACKEND_ROBOKASSA_*` seeds the `web` shop; 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).**
|
||
|
||
- `handleWalletOrder` `SourceDirect` branch: pick the shop by `cxt.Subtype` (`web`/`android`;
|
||
unknown → `web`). Build the payment request from that shop. The subtype rides the trusted
|
||
gateway-injected `X-Platform` (`<kind>/<subtype>`, `parsePlatformHeader`) — no spoofable client
|
||
field. **Verify the gateway emits `direct/android` for the native build**; if it sends a bare
|
||
`direct/`, add the android subtype (small gateway change).
|
||
- Record the chosen `shop` on 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 same `Fund` (source stays `direct`). Add the routes to the **Caddyfile
|
||
`@gateway` matcher** (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) — RESOLVED (owner 2026-07-14): cabinet-side only.** The owner keeps
|
||
Robokassa's cabinet auto-fiscalization (a generic чек is acceptable for the ИП); the optional
|
||
itemized-`Receipt` / `Email` code below is **dropped** — not needed. (Kept for context.)
|
||
|
||
- **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`, `tax`
|
||
per СНО, `payment_object`/`payment_method`) + the customer `Email` (the D36 confirmed anchor) in
|
||
the payment request; extend the signature to `MerchantLogin:OutSum:InvId:Receipt:Password1`
|
||
(URL-encode `Receipt`). 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 `shop` column on the order (default `web` / backfill from `origin`); a per-channel
|
||
breakdown in the E7 per-user report (D40). Expand-contract migration (nullable/defaulted column),
|
||
**contour-safe** (a schema touch → note the contour `DROP SCHEMA` step in `PRERELEASE.md`).
|
||
|
||
**B6 — Tests + docs.**
|
||
|
||
- unit: the `robokassa` shops registry + per-shop `VerifyResult` (right `Password2`);
|
||
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 `shop` recorded + reported.
|
||
- docs: `PAYMENTS.md` (+`_ru`) the multi-shop topology + the ИП/54-ФЗ receipt revision;
|
||
`deploy/README` + `.env.example` the new vars + the per-shop Result routes + probes;
|
||
`PRERELEASE.md` the `shop` column 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.
|
||
|
||
---
|
||
|
||
## E11 — Payment availability kill switch + per-account override
|
||
|
||
**Status:** DONE · **Release 2** · depends on: E5 (intake/order), E7 (admin console) · mechanics:
|
||
PAYMENTS §9; decisions D45, D46.
|
||
|
||
**Delivered.** An operator disables purchases live from `/_gm` — a whole rail/channel or one account
|
||
— and the user sees a localized reason on the next purchase attempt. Motivation (owner): real apps
|
||
show broken payments with no explanation; this gives ops a live switch + a clear user message.
|
||
|
||
- **Rail kill switch** — `payments.rail_status` (per rail `direct:web` / `direct:android` / `vk` /
|
||
`telegram`): `enabled` + a per-language message, edited on the catalog page. **Fail-open** (no row
|
||
⇒ enabled, so payments are never accidentally killed). The intake gate — `CanPurchase` in
|
||
`handleWalletOrder`, before the order — returns `payment_unavailable` + the localized message;
|
||
orthogonal to the security gates.
|
||
- **Per-account override** — `payments.account_payment_override` (a row only for non-default; default
|
||
= no row, cleared by delete): allow / deny / default, edited on the user card. `allow` bypasses
|
||
**only** the ops rail switch, never the security gates (D46).
|
||
- **Wire** — the message rides an additive `ExecuteResponse.message` (envelope layer,
|
||
frozen-contract-safe; the gateway forwards a backend domain-error message via `DomainMessage`); the
|
||
client reads it into `GatewayError.message` and shows it on a `payment_unavailable` buy attempt.
|
||
- **Tests** — the pure gate `PurchaseGate` (unit, TDD); the store + gate + override end-to-end
|
||
(integration, migration `00016`); the client (svelte-check / vitest). **Docs** — PAYMENTS (+`_ru`),
|
||
decisions D45 / D46.
|
||
|
||
**Contour-safe:** additive migration (two new tables, no wipe); the wire add is additive; fail-open,
|
||
so nothing is disabled until an operator acts.
|
||
|
||
---
|
||
|
||
## E12 — YooKassa direct rail (Robokassa retired)
|
||
|
||
**Status:** DONE · **Release 2+ (money-live)** · depends on: E5 (intake/`Fund`, the order flow), E7
|
||
(the admin refund + report), E10 (per-channel shops), E11 (the kill switch) · mechanics: PAYMENTS §2,
|
||
§9, §11, §12; decisions D47–D51 (revising D41).
|
||
|
||
**Delivered.** The `direct` (RUB) rail settles through **YooKassa** instead of Robokassa. The wallet
|
||
model is untouched — one `direct` segment, the same spend wall, the same per-channel shops (D42) and
|
||
`shop` on the order (D44). Robokassa is **dormant, not deleted**: the direct rail falls back to it
|
||
when no YooKassa shop is configured, so reviving it is a credentials change (D47).
|
||
|
||
- **Provider glue** — `backend/internal/yookassa`: an API client (`CreatePayment` / `GetPayment` /
|
||
`CreateRefund`, HTTP Basic, `Idempotence-Key`), a per-channel `Shops` registry with `ByShopID` for
|
||
attributing a notification, the notification envelope + the sender-IP allowlist, and the fiscal
|
||
receipt builder. No DB, no payments-domain coupling — the same shape as the `robokassa` package.
|
||
- **Order** — `handleWalletOrder` prefers a YooKassa shop and otherwise falls through to Robokassa.
|
||
It creates the payment (order id as both `Idempotence-Key` and `metadata.order_id`), records the
|
||
provider payment id on the order (`AttachProviderPayment`; no migration — the column existed) and
|
||
returns `confirmation_url`. D36's email anchor now also feeds the receipt (`ConfirmedEmail`).
|
||
- **Notification** — `/pay/yookassa/notify` (gateway: rate limit + raw-body proxy; backend: sender
|
||
allowlist, then the confirming `GetPayment`). Nothing in the body is acted on (D48). Guards: the
|
||
payment's metadata must name the order, and its `test` flag must match the shop's. `payment.canceled`
|
||
records a `failed` event. 200 = durably decided, 5xx = redeliver.
|
||
- **Reconcile** — `runOrderReaper` asks the provider about each pending order that reached its expiry
|
||
age carrying a payment id, and credits the ones really paid (D49). One request per order.
|
||
- **Refund** — the `/_gm` button calls `POST /v3/refunds` first and records only on a **succeeded**
|
||
refund, under the provider's own refund id (D50); a failure — or a still-`pending` refund — records
|
||
nothing, and pressing again is safe. `refund.succeeded` is handled too (D52), because the merchant
|
||
cabinet can issue a refund that never passes through our API; a **partial** refund records nothing
|
||
and is logged for an operator (the engine is full-refund-only).
|
||
- **Fiscalization** — **no receipt is sent** (D51 rev): the merchant is on НПД, outside 54-ФЗ, and
|
||
YooKassa does not serve receipts for that regime; reporting goes to «Мой налог» instead (its own
|
||
task — the data it needs is already recorded, bar a per-operation "reported" marker). The fiscal
|
||
code stays dormant behind `BACKEND_YOOKASSA_VAT_CODE`: unset sends nothing, a 54-ФЗ rate code turns
|
||
«Чеки от ЮKassa» back on — the path a lost НПД regime (annual income ceiling) would take.
|
||
- **Email anchor** — D36 still gates a direct purchase (the recovery anchor stands on its own now
|
||
that the receipt address is gone). The wallet now says so **before** the buy tap: a player signed in
|
||
through VK/Telegram in a browser sees "add an email in your profile" instead of a bare error.
|
||
- **Config/deploy** — `BACKEND_YOOKASSA_{WEB,ANDROID}_{SHOP_ID,SECRET_KEY,TEST}` +
|
||
`BACKEND_YOOKASSA_VAT_CODE`; every `ROBOKASSA_*` mapping removed from compose, `.env.example`,
|
||
`write-prod-env.sh` and the three workflows, and recorded in `backend/internal/robokassa/README.md`.
|
||
The CI edge probe now targets `/pay/yookassa/notify`.
|
||
- **Tests** — unit (`yookassa`: request building, receipt, idempotence key, error retryability, shop
|
||
registry, IP allowlist); integration (`payments_yookassa_test.go`: credit-once + redelivery, a
|
||
forged notification credits nothing, a foreign sender is refused, a live payment on a test shop is
|
||
refused, a decline records `failed`, the reconcile sweep credits a lost notification and leaves an
|
||
unpaid order alone, the refund moves money then records — and records nothing when it fails, the
|
||
order path mints a payment with a receipt, D36 still gates the rail, a cabinet refund is reversed
|
||
once, the event after a console refund is a no-op, a partial refund changes nothing, an unconfirmed
|
||
refund reverses nothing, and a pending refund records nothing until it settles).
|
||
|
||
**Contour-safe:** no migration, no wire change; the client is rail-agnostic (only the mock URL and an
|
||
e2e assertion name a provider).
|
||
|
||
**Verified on the contour** against a YooKassa **test shop** (test mode exists — a separate test shop
|
||
with its own credentials and test cards), then in prod with one real payment.
|
||
|
||
---
|
||
|
||
## 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.
|