diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..113a619 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,598 @@ +# 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)); 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 | TODO | +| E1 | Trusted platform signal | 1 | TODO | +| E2 | Currency + benefit core | 1 | TODO | +| E3 | Wallet UI | 1 | TODO | +| E4 | Durability (PITR) | 2 | TODO | +| E5 | Payment intake | 2 | TODO | +| E6 | Ads | 2 | TODO | +| E7 | Admin & reports | 2 | TODO | +| E8 | Guest limits | — | TODO | +| E9 | Tournament fee | future | TODO | + +**Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3). +**Release 2** = money (E4→E5→E6→E7). E8 is standalone (game-behaviour change, can run in +parallel). E9 is future. + +--- + +## Architecture baseline (applies to all stages) + +Read once; individual stages assume it. + +### Schema & isolation + +- The payments domain owns its **own Postgres schema `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 role owns `payments` (ALL on `payments.*`, plus `REFERENCES` on + `backend.accounts(account_id)` for the cross-schema FK — nothing else on `backend`). The + game/backend role keeps no rights on `payments`. Isolation is one-directional: payments + may reference accounts; the rest of the backend never touches `payments` SQL. +- **No cross-schema writes.** Benefits move **into** `payments` (they no longer live on + `accounts` — see E2), so the spend transaction (ledger INSERT + balance UPDATE + benefit + UPDATE) is atomic **within `payments`**. The only cross-schema link is the FK + `payments.*.account_id → backend.accounts.account_id` (referential integrity, not a + write). Game code reads benefits through the payments **Go interface**, never via SQL. + +### 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) bool`, `SpendHint(ctx, account, platform) error`, + `Balances(ctx, account, platform) …`). Keep the surface small; this is the seam that lets + payments become a separate process later without touching callers. + +### Migrations & codegen + +- Migrations: same goose dir `backend/internal/postgres/migrations/`, 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:** TODO · **Release 1** · depends on: none · mechanics: PAYMENTS §14, §7, §11. + +**Goal.** Stand up the `payments` schema, DB role, jetgen target, domain package skeleton, +and all core tables — with nothing wired to real money yet. This is the substrate every +later stage builds on. + +**Migrations (`payments` schema).** + +- `CREATE SCHEMA payments`; create the payments DB role + GRANTs (ALL on `payments`, + `REFERENCES` on `backend.accounts(account_id)`). +- `payments.ledger` — append-only. Columns: `ledger_id` (UUIDv7 PK), `account_id` (FK → + `backend.accounts`), `kind` (`fund|spend|admin_grant|refund`), `source` / `origin` + (nullable per kind), `chips_delta` (signed; 0 for admin_grant), `product_id` (nullable, + FK → catalog), `order_id` (nullable, FK → orders), `provider` + `provider_payment_id` + (nullable), `snapshot` (JSONB of sold atoms+price for spend/grant), `created_at`. **No + UPDATE/DELETE ever.** Unique partial index on `(provider, provider_payment_id)` where not + null — the idempotency key. +- `payments.balances` — materialized `(account_id, source)` PK, `chips int CHECK (chips >= + 0)`, `updated_at`. +- `payments.benefits` — materialized `(account_id, origin)` PK, `ads_paid_until timestamptz` + null, `ads_forever bool`, `hints int CHECK (hints >= 0)`, `updated_at`. +- `payments.catalog_atom` — atom types (`chips|hints|noads_days|tournament`). +- `payments.product` — `product_id`, `title`, `active bool` (soft-delete), created/updated; + `payments.product_item` (product → atom + quantity); `payments.product_price` (product → + method `vk|telegram|direct` + amount + currency — multi-currency chip packs; value + products carry a single chips price). +- `payments.chip_rate` / rewarded payout config — either dedicated rows or a small + `payments.config` KV (frequency cooldowns, rewarded payout, pending-expiry). Pick one and + document it here at implementation. +- `payments.order` — `order_id` (UUIDv7 PK), `account_id`, `platform`, `product_id`, + `expected_amount` + currency, `origin`, `status` (`pending|paid|expired`), `provider`, + `provider_payment_id` (nullable), timestamps. Index for the pending-expiry sweep. +- `payments.payment_event` — `event_id`, `account_id`, `order_id` (nullable), `type` + (`succeeded|failed|refunded`), `payload`, `created_at`, `dispatched_at` (nullable). + +**Backend.** + +- `backend/internal/payments/` package skeleton: `Store` + `Service` + a `withTx` helper. + No handlers wired yet beyond a health-style no-op — this stage is schema + package. +- Extend `cmd/jetgen` to generate `payments` schema → `internal/postgres/jet/payments/`; + commit generated code. + +**Legacy deprecation (expand phase only).** Add a migration comment/marker that +`accounts.hint_balance` and `accounts.paid_account` are deprecated; do NOT drop yet (drop is +the contract phase, after E2 reads/writes the new tables and after Release 2). Reads still +use the old columns until E2 flips them. + +**Tests.** + +- integration: schema/role created; role cannot read `backend`-only tables and vice-versa; + ledger rejects UPDATE/DELETE (trigger or role privilege); idempotency unique index holds; + FK to `backend.accounts` enforced; balance/benefit CHECKs hold. +- unit: none material yet (logic arrives in E2). + +**Done-criteria.** Migrations apply cleanly forward+backward on a throwaway PG (jetgen path +proves this); `go build ./backend/...` + `go vet` + `gofmt -l .` clean; committed jet code +for `payments`; integration tests green; no behaviour change for users. + +**Notes/risks.** jetgen may reorder untouched `backend` tables — revert that churn, commit +only the `payments` additions. Keep the `payments` role creation idempotent (`IF NOT +EXISTS` / `DO $$`), since migrations re-run on fresh volumes. + +--- + +## E1 — Trusted platform signal + +**Status:** TODO · **Release 1** · depends on: none (parallel to E0) · mechanics: PAYMENTS §8. + +**Goal.** Make the server know the execution platform from a trusted, unforgeable source, +carried on the session and re-confirmed each cold start. This is the foundation the gate +(E2) stands on; without it the gate is meaningless. + +**Model.** `platform = {kind: vk|telegram|direct, subtype: ios|android|web}` becomes a +property of the **session**. On cold start the client submits the fresh wrapper signature +(VK launch-params `sign` / TG `initData`); the gateway (or backend session-resolve) +validates it and records/refreshes the session's platform. `direct` needs no signature — it +is implied by a web/native session's creation path. + +**Backend.** + +- Session store (`backend/internal/session/`): add `platform` (kind+subtype) to the session + row + `Session` struct; set it at creation and refresh it on a validated cold-start call. +- Session resolve (`handlers_auth.go handleResolveSession` → `resolveResponse` DTO + `dto.go`): return `platform` so the gateway can carry it. +- Validation: TG `initData` validator already exists (`platform/telegram/internal/initdata`) + — reuse. Add VK launch-params `sign` verification (HMAC over sorted params with the VK app + secret) — new small verifier, unit-tested against known VK vectors. +- Gateway (`gateway/internal/backendclient`): inject a trusted `X-Platform` header (mirror + `X-User-ID` injection in `client.go do()`), sourced from the resolved session — never from + the client request body. +- Backend middleware: read `X-Platform` into context alongside `X-User-ID` + (`middleware.go`); expose a typed accessor `platform(c)`. +- **Fail-closed default:** absent/invalid/stale platform ⇒ context = untrusted; the payments + interface treats untrusted as "view only" (enforced in E2's gate, but the signal plumbing + lands here). + +**Client (`ui/`).** On cold start, submit the wrapper signature to the session-establish/ +refresh call: VK via `ui/src/lib/vk.ts` (launch params), TG via `ui/src/lib/telegram.ts` +(`initData`). `direct` submits nothing (web/native). Compose the platform from +`insideVK()` + `insideTelegram()` + `clientChannel()` + `vkPlatform()` (no single +discriminator exists today — see `ui/src/lib/channel.ts`, note VK reads as `web` there). + +**Tests.** + +- unit (Go): VK sign verifier accepts valid / rejects tampered params; TG initData path + (existing) covered; platform kind+subtype derivation. +- integration: session carries platform; resolve returns it; stale/absent → untrusted. +- UI: cold start submits the right signature per wrapper (mock). + +**Done-criteria.** A VK/TG session resolves to a trusted `{kind,subtype}`; a forged body +cannot change it; `direct` sessions resolve to `direct`; untrusted path is reachable and +observable. No user-visible change. + +**Notes/risks.** VK iOS must surface as `subtype=ios` (drives the frozen state in E2/E3). +Old sessions predating this stage have no platform → untrusted → fail-closed (acceptable; +re-cold-start fixes it). Keep the VK app secret in config/secret store, not code. + +--- + +## E2 — Currency + benefit core + +**Status:** TODO · **Release 1** · depends on: E0, E1 · mechanics: PAYMENTS §2–§6, §11. + +**Goal.** The full internal money mechanic, exercisable end-to-end **without real money** +via `admin_grant`: chip balances, spend→benefit atomically, the compliance gate by context, +benefit application (no-ads stacking, hints per-origin), the legacy migration, and the +`SpendHint` rewrite. This is the heart. + +**Payments service (Go).** + +- `Balances(ctx, account, platform)` → the segments spendable in the platform's context + (VK→vk; TG→tg; direct→direct+vk+tg; VK-iOS→frozen/view; untrusted→none). +- `Spend(ctx, account, platform, productID)` → gate-checked purchase of a value with chips: + resolve spendable segments by context, draw by priority (direct→vk→tg on web), write a + `spend` ledger row + 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)` → `admin_grant` ledger row (price 0, concrete values + only, never chips), same benefit application; origin chosen by the admin (E7 UI; here the + service method + an internal/admin entrypoint). +- `AdFree(ctx, account, platform)` / `HintsAvailable(ctx, account, platform)` / + `SpendHint(ctx, account, platform)` → apply the one-directional origin rule: in context P, + usable origins = {P} plus, when P is web/native, {vk,tg} (relaxation outward); in VK only + vk; in TG only tg. +- `Merge` / `Unlink` hooks: extend `accountmerge` to merge segments+benefits by origin + (same-origin add, different coexist), and make segment availability follow identity + presence (unlink → sleep, re-link → wake). Warn-before-unlink is a UI concern (surface the + balance via the interface). + +**Backend wiring & migration.** + +- Deprecate-and-flip `accounts.hint_balance` / `accounts.paid_account`: E0 added the tables; + here, move reads/writes 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); priority draw; no-ads + stacking (`+=` from max(now,end)) + forever override; per-origin hint applicability; + merge/unlink segment math. +- integration: atomic spend (ledger+balance+benefit in one tx; failure rolls all back); + admin_grant credits values not chips; legacy migration zeroes and flips reads; merge/ + unlink over Postgres. +- UI: none new (E3 builds the screen); wallet DTO covered by codec unit tests. + +**Done-criteria.** With chips seeded via `admin_grant`, a user can buy no-ads/hints; the gate +blocks cross-context spend and cross-origin application; ads gate reads the new benefit; +`SpendHint` uses segments; all layers green; **compliance regression** (a `direct` benefit +never activates inside VK/TG) is a named test. + +**Notes/risks.** This is the high-blast-radius core (money semantics + a live path +`SpendHint`/`ads.Eligible`). Minimize surface, keep the interface narrow, no mixed-in +refactors. The legacy flip is expand-contract: reads move first, DROP much later. + +--- + +## E3 — Wallet UI + +**Status:** TODO · **Release 1** · depends on: E2 · mechanics: PAYMENTS §1, §7, §6, §13, §4. + +**Goal.** The user-facing "Кошелёк" (Wallet) section: balances + active benefits + the +catalog storefront, honouring guest-hidden, GP-stub, and the web-spend warning. + +**UI (`ui/`).** + +- New `'wallet'` tab in `ui/src/screens/SettingsHub.svelte` — `SettingsTab` union, tab + button **between Friends (:65) and About (:66)**, body branch in the `:42-51` switch, + `'wallet'` route in `ui/src/lib/routeparse.ts` + `ui/src/App.svelte`. Reuse the hub's + guest/offline gating. +- `Wallet.svelte` screen: **minimal** — context-available chip balances + active benefits + (no-ads until date, hints count). **No history feed** (PAYMENTS §11 — noise). Storefront: + products from the configurable catalog, prices in chips (values) / per-method (chip packs). +- **Guest:** section hidden entirely (durable-only). +- **GP build:** purchase CTA replaced by a stub ("install the RuStore build to make + purchases"); rewarded + spending earned chips still work. Detect the GP native build. +- **Web-spend warning:** before spending vk/tg chips in a web/native (direct) context, show + an own `Modal` (not `showPopup` — that eats the user-activation gesture) warning the value + will be web/native-only. +- Extract all logic into `ui/src/lib/*` (unit-testable); keep `.svelte` thin. + +**Tests.** + +- unit (vitest): storefront/price formatting, context-available-segment selection, warning + trigger condition. +- UI (Playwright mock, Chromium+WebKit): Wallet renders between Friends/About; guest hides + it; GP stub; warning modal on web vk/tg spend. Mock overlay must stay instant under + `MODE==='mock'` (or it intercepts taps). + +**Done-criteria.** Owner can review the Wallet on the deployed test contour (visual sign-off +is on the contour, not local); balances/benefits/storefront correct per context; guest/GP/ +warning paths verified. Release 1 is now demonstrable end-to-end via `admin_grant`. + +**Notes/risks.** No global `.btn`/`.ghost` in `ui/` — style per-component with scoped CSS + +tokens (mirror NewGame `.invite` for a CTA). Svelte whitespace/`$state` naming gotchas +apply. iOS WebView download/gesture caveats are irrelevant here (no file delivery). + +--- + +## E4 — Durability (PITR) + +**Status:** TODO · **Release 2** · depends on: E0 (schema exists) · mechanics: PAYMENTS §14. + +**Goal.** Continuous WAL archiving with point-in-time recovery, armed **before the first +real money** is accepted (E5 prod). Protects both money and game data. + +**Work.** + +- Add WAL archiving to the prod Postgres (pgBackRest or WAL-G): base backups + continuous + WAL to a second host or object storage. Wire into `deploy/` (compose/prod overlay + + ansible `deploy/ansible/`), config + secrets under the `PROD_` prefix. +- Restore runbook in `deploy/README.md`: base + WAL replay to a timestamp; test the restore + on a scratch target. +- Keep the existing migration-time `pg_dump` (`deploy/prod-deploy.sh`) as belt-and-braces. + +**Mandatory pre-prod assessment (owner-required, gates the Release 2 prod rollout).** +Before the first money goes live, produce and record here: + +1. **Disk-storage cost estimate** for the WAL archive + base backups (retention window × + WAL volume; account for both hosts / object-storage pricing). This can change hosting + sizing. +2. **PG performance-degradation assessment** from continuous archiving (write amplification, + archive_command latency, backup I/O contention) on the prod-sized instance. + +Neither blocks building the plan, but both **must** be completed and reviewed before the +prod release — surface the numbers to the owner (they may change host settings). + +**Tests.** Restore drill on a scratch instance (base + WAL → target timestamp) documented +and passing. No app-level tests. + +**Done-criteria.** WAL archiving live on prod PG; a timed restore verified; cost + perf +assessment recorded and owner-reviewed; runbook in `deploy/README.md`. + +**Notes/risks.** The tg host is weak (1 vCPU) — if the archive lands there, watch +contention. Migrations stay expand-contract so image rollback remains DB-safe alongside PITR. + +--- + +## E5 — Payment intake + +**Status:** TODO · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12. + +**Goal.** Accept real money on all three rails into the payments domain: order-flow, +verified provider callbacks, idempotency, the TG bot SQLite outbox, the event dispatcher, +receipts, and refunds. + +**Order-flow & intake (single writer).** + +- `POST /api/v1/user/wallet/order` → 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`). +- 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. Add a + **SQLite** store on the bot's disk: on receipt, persist → ack the Telegram update → forward + to a backend payments-intake endpoint (internal, authenticated over the existing reverse + mTLS bot-link) → on backend ack, mark `forwarded`. Retries with backoff; re-drive + undelivered on restart. Backend intake dedups by `telegram_payment_charge_id`. +- Provide the invoice creation path (Stars) from the Mini App via the bot as needed. + +**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; admin manual refund (ties to `accountdelete`); external +`refunded` events honoured — best-effort benefit revoke (never negative; record loss + abuse +flag if spent), ledger `refund` row. Ledger export-ready (reconciliation not built). + +**Tests.** + +- unit: signature/HMAC verifiers per rail; order-id matching; idempotency-key dedup. +- integration: full order→callback→credit per rail; duplicate callback credits once; expired + order still honoured on late callback; TG outbox store→forward→ack→cleanup + restart + re-drive; refund revoke best-effort/never-negative. +- UI: purchase flow reaches the provider launch (mock); success/failure surfaced. + +**Done-criteria.** A real (sandbox) payment on each rail credits chips exactly once; a +replayed callback does not double-credit; the TG outbox survives a bot restart mid-flight; +failed/succeeded events reach the user; refund path exercised. **PITR (E4) armed and the +cost/perf assessment reviewed before this goes to prod.** + +**Notes/risks.** Manual `docker run -p` boot tests fail from the shell — verify the runnable +artifact via the testcontainers integration suite. Distroless services run UID 65532; +bind-mounted secrets must be 0644. Edge routes silently fall through if not added to the +Caddyfile — add a CI probe. The prod rolling deploy skips caddy on config-only changes — +force-recreate when the Caddyfile changes. + +--- + +## E6 — Ads + +**Status:** TODO · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) · +mechanics: PAYMENTS §10. + +**Goal.** VK video ads: the post-move interstitial (frequency-gated) and the rewarded video +(credits chips via server verify), plus extending the existing banner suppression to +per-origin. + +**Work.** + +- **Provider abstraction:** a small ads-network interface (VK impl now) so a future network + for other platforms slots in without rework. VK integration via `ui/src/lib/vk.ts`. +- **Rewarded:** voluntary view → the network's **server verify callback** → payments intake + credits chips to the `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. diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md new file mode 100644 index 0000000..94b9239 --- /dev/null +++ b/docs/PAYMENTS.md @@ -0,0 +1,333 @@ +# PAYMENTS — monetization mechanics + +Authoritative specification of the monetization domain: the in-game currency, wallets, +benefits, store-compliance rules, payment intake, ads, catalog, ledger, and reporting. +English is authoritative; [`PAYMENTS_ru.md`](PAYMENTS_ru.md) mirrors it in plain Russian +(mirror every point edit in the same PR, like `FUNCTIONAL.md`/`FUNCTIONAL_ru.md`). + +Read this before changing any payments behaviour. The technical implementation plan lives +in [`../PLAN.md`](../PLAN.md). + +> Status: specification approved; implementation staged (see `PLAN.md`). Nothing here is +> live yet. + +## 1. Overview + +The game earns through two orthogonal channels: + +- **Purchases** of the in-game currency **Фишка** (chips) with real money, then spending + chips on **values** (benefits). +- **Ads** — a rewarded video tops chips up; an interstitial and a house banner monetize by + impression. + +The currency is **two-tier**: + +``` +money (VK Votes / TG Stars / RUB via Robokassa) ─┐ + ├─► Фишки (chips) ──► values +rewarded ad view ─┘ (no-ads, hints, + tournament fee) +``` + +Chips are the single storefront unit. Money and rewarded views **fund** chips; chips +**buy** values. There is no path that spends money directly on a value — always through +chips. + +## 2. Currency model + +**One chip is one chip everywhere** — the unit is uniform. The difference between payment +methods lives entirely in the **purchase rate**: a chip pack costs *X* Votes / *Y* Stars / +*Z* RUB, and the rate absorbs each store's commission. Value prices are fixed **in chips** +and identical across methods. + +The chip balance is **segmented by `source`** — the platform where the chips were funded: + +| `source` | Funded by | +|------------|----------------------------------| +| `vk` | VK Votes purchase, VK rewarded ad | +| `telegram` | TG Stars purchase | +| `direct` | Robokassa purchase (web / native)| + +A single account holds all three segments simultaneously: `balance = (account_id, source)`, +exactly three rows. Segmentation is **not** per-identity — see §6. + +Why segmented, not one pooled balance: store rules forbid activating value that was paid +for outside the store's own cash desk. Chips funded inside VK (Votes) may only be spent in +the VK context; Stars only inside Telegram; Robokassa-funded (`direct`) chips only outside +the stores. See §4. + +## 3. Three operations, kept distinct + +Do not conflate these — they use different keys: + +1. **Fund chips** — money/ad → chip `source` segment, keyed by the **execution context**. +2. **Spend chips = buy a value** — segment → benefit, keyed by context with the gate (§4). + This is where a benefit is **born** and stamped with its `origin`. +3. **Apply a benefit** over time (e.g. "no-ads until *T*") — governed by the origin rule + (§5). + +`source` (where chips were funded) and `origin` (where a value was bought) share the same +value set `{vk, telegram, direct}` but mean different things. On the web they can diverge: +web spending may draw `vk` chips (`source=vk`) into a `direct` purchase (`origin=direct`). + +## 4. Store-compliance gate + +The compliance wall is **one-directional**. The dangerous direction — activating externally +paid value *inside* a store wrapper — is blocked; the safe direction (a store benefit +leaking out to the open web) is allowed. + +**Spend context → which segments/benefits are usable:** + +| Execution context | Spendable chip segments | Spend priority | +|---------------------|-------------------------|--------------------| +| Inside VK (Android) | `vk` | — | +| Inside VK (iOS) | none — frozen (view only)| — | +| Inside Telegram | `telegram` | — | +| Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg | + +- Inside VK/TG only the same-named segment is usable; everything else (chiefly `direct`) is + invisible-as-spendable there. +- On the web the store has no jurisdiction, so all attached segments are spendable, drained + by priority direct → vk → tg. +- **VK iOS** is frozen for spending (Apple forbids spending virtual currency on digital + goods outside IAP inside VK on iOS): the balance is shown as a number, but no purchase or + spend is possible. A previously bought benefit still *applies* there. + +The account is **single** (identities merge, one profile/friends/stats). The gate is +**logical**: in a VK/TG context the server activates only the same-named segment. It rests +on a **trusted platform signal** (§8) — the client is never believed. When the platform +cannot be trusted, the gate is **fail-closed**: spends/purchases are denied, view only. + +### One-directional benefit application + +A benefit carries `origin` = the **purchase context** (not "what it was paid with"). Buying +on the web with `vk` chips still yields `origin=direct`. + +| `origin` | Where the benefit applies | +|------------------|----------------------------------------------------| +| `vk` / `telegram`| **Everywhere** — inside its store *and* out on web/native | +| `direct` | **Only** web/native — never inside VK/TG (= store ban) | + +Before a web spend draws `vk`/`telegram` chips, the UI **warns** the user that the value +will be available here (web/native) only, because of VK/TG restrictions. + +## 5. Benefits + +Three distinct entities, do not merge: + +- **Chips** — currency. Segment by `source`. +- **Hints** — consumable, bought with chips. Segment by `origin`. Spent one-per-hint in + online games; `vs_ai` hints stay free/unlimited (30-min idle gate, not counted). A hint + spent in a game is drawn from an `origin` applicable to the current context (same + one-directional relaxation as above). +- **No-ads** — a duration benefit, bought with chips. Segment by `origin`. + +**No-ads stacking.** Buying a term extends `paid_until[origin] += term` from +`max(now, current end)` — the remainder is never lost ("terms add up"). **Forever** is a +separate perpetual flag that overrides terms. "Ads off in context *P*" ⇔ some `origin` +applicable in *P* has `paid_until > now` (on the web take the max over direct/vk/tg; in VK +only vk; in TG only tg). + +**What no-ads suppresses:** the top banner **and** the post-move fullscreen interstitial. +The voluntary **rewarded** view (for chips) is never suppressed — it is the user's choice. + +**Tournament fee** — a future value type; the atom is provisioned but the mechanic ships +later. + +## 6. Wallet lifecycle + +**Segment availability rule.** A segment is spendable ⇔ the account has an identity of that +`source` (for `direct`: a durable identity/email). This makes unlink/merge fall out +naturally. + +**Unlink (vk/tg).** Allowed even with a non-zero balance/active benefit. The segment is not +burned — it **sleeps** (no identity ⇒ unavailable in VK *and*, lacking the attachment, +unavailable as an attached web segment); re-linking wakes it. The user is warned before +unlinking ("N chips will be unavailable until you re-link"). The last identity cannot be +unlinked (existing `ErrLastIdentity`). + +**Merge.** Segments and benefits merge **by origin**: same-origin segments add (chips sum, +benefit terms extend per origin), different origins coexist. This extends the current +account-merge (`hint_balance +=`, `paid_account OR=`) so origin is preserved and nothing +leaks across platforms. + +**Guest.** A guest account has **no wallet at all** — the Wallet section is hidden, no +purchases, no balance, by design. Balances belong only to durable accounts. The guest +reaper therefore deletes guests freely (no money can exist there). In `direct`, email is +required **before the first purchase** as a recovery anchor (in VK/TG the vk/tg identity is +already the anchor); the existing email flow (request code → confirm → clear guest → +durable) is reused. + +## 7. Catalog and pricing + +The catalog is **configurable** — products, prices, purchase rates, and rewarded payout +live in the database and are edited in the admin console, no release required. + +- **Base values (atoms):** chips, hints, no-ads days, tournament entry. +- **Product = a set of atoms + a price.** Sold singly or as a combo (e.g. "250 hints + 30 + no-ads days"). +- **Chip pack** (funds chips) — priced **per method** (multi-currency Votes/Stars/RUB) as a + single product. +- **Value** (bought with chips) — priced **in chips** (uniform across methods). + +**Deactivation, not deletion.** Products are soft-deleted (deactivated). A completed +purchase stores a **snapshot** of what was sold (atom composition + price at the time) into +an archive, so history/receipts/tax are independent of later catalog edits. + +## 8. Trusted platform signal + +The gate (§4) needs a **trusted, unforgeable** platform context on the server. The client is +never the source of truth. + +- The platform is a **property of the session**, re-confirmed by a fresh signature on **every + cold start** — VK launch-params `sign` / TG `initData` (validator already exists at + `platform/telegram/internal/initdata`). VK/TG wrappers resend the signature on every open, + so this is not a one-time login. +- `direct` is established by the fact of a web/native session's creation (no external + signer, and none needed — reaching vk/tg segments in a direct context still requires a + real attachment, §6). +- The platform carries **kind** (`vk`/`telegram`/`direct`) **plus subtype** + (`ios`/`android`/`web`) — the subtype is mandatory (VK iOS is frozen). +- The gateway resolves the session and passes `platform` to the backend (alongside the + existing `X-User-ID`). +- **Fail-closed:** an untrusted/unconfirmed platform (a VK/TG session without a valid + cold-start signature; an old session with no recorded platform) denies spends/purchases + and applying any foreign origin — view only. + +## 9. Payment intake + +**Server callback only.** Chips are credited solely on a **verified** (signature/HMAC) +provider callback — Robokassa webhook / TG `successful_payment` / VK callback. A client "I +paid" is ignored. + +**Single writer.** One payments domain is the only writer of the ledger. Public webhooks +(Robokassa/VK) terminate at the edge (Caddy/gateway) and proxy into payments; TG +`successful_payment` reaches the bot, which forwards into payments. One place credits and +dedupes. + +**Order-flow.** The server pre-creates an `order(pending)` with account / platform / pack / +expected amount / origin. The `order_id` is threaded to the provider (Robokassa `InvId` / TG +`invoice_payload` / VK `item` — confirm the exact VK field at integration). The callback +matches by `order_id` (never by amount, so equal-amount collisions cannot happen), verifies +the amount, credits, marks `paid`. **Idempotency:** dedup by `(provider, provider_payment_id)`. + +**Pending is invisible** to the user; it auto-expires on a timeout (~30 min, DB hygiene). A +valid callback is **always** honoured, even on an expired order (`expired` ≠ cancellation — +the money is real, the chips are owed). The user sees only successful purchases. + +**TG bot outbox.** `successful_payment` reaches the bot only (Bot API, not the Mini App), and +the bot host is weak and can lose connectivity, so the bot is a durable link. Store-and- +forward on **SQLite** on the bot's disk: receive → store → ack the Telegram update → forward +to payments (idempotent, dedup by `telegram_payment_charge_id`) → ack → mark `forwarded`. +Retries with backoff; re-drives undelivered on restart. At-least-once delivery + idempotent +intake = credited exactly once. + +**Events.** The payments domain writes `payment_events` (succeeded / failed / refunded); a +dispatcher fans out over channels — the live gRPC stream if the user is in-app, else the +existing `botlink` push / email relay. "Payment failed" (an **active** provider decline, not +an abandoned pending) is surfaced to the user; "payment succeeded" is a hook (email / bot +message). + +**Refunds.** ToS is **non-refundable** — we do not offer refunds to the user. An admin may +issue a **manual** refund (edge case: a user demands one shortly after paying / closes their +account — tie into `accountdelete`, which already preserves messages). **External** refunds +(chargeback / store decision / TG / VK) are honoured: the system takes the `refunded` event, +**best-effort** revokes the benefit (never going negative; if chips were already spent, it +records the loss + an abuse flag), and writes to the ledger. The ledger is **export-ready** +for future tax reporting and Robokassa reconciliation (reconciliation itself is not built +yet; the schema stays compatible). + +## 10. Ads + +**Launch scope: VK only** for video (rewarded payout in RUB, ОРД automatic, API ready). +web/native/TG keep the existing house **text banner** only; video is deferred until a +ruble-paying in-app network exists. The ad provider is behind an **abstraction** so a future +network for other platforms slots in without rework. Crypto-payout networks (AdsGram/AdMob) +are rejected — no legal ruble income for a self-employed (НПД) developer. + +**Rewarded** (voluntary video for chips) credits chips through payments on the network's +**server verify callback** (client not believed, like a payment). On-launch anti-fraud is +the provider's verify only (no own daily cap yet; the abstraction allows adding caps later). + +**Interstitial** (post-move fullscreen), configurable server-side values: + +- Global cooldown **per user, across all games**, default **5 min**. +- **`vs_ai` — 30 min** (aligned with the hint cooldown, so it does not scare casual players). +- Applying a **hint** triggers a post-move interstitial **independently** of the main + cooldown, with its own **1-min** cooldown. +- Offline — banner only. +- Respect VK's own frequency caps. + +**No ads offline** beyond the house banner. The `no-ads` benefit suppresses the banner via +the existing `ads.Eligible` (`backend/internal/ads/ads.go`), which is extended to gate on +the **origin benefit applicable in the current context** rather than one global flag. + +## 11. Admin, audit, reporting + +**Append-only ledger + materialized balance.** The operations ledger is **INSERT-only** +(never UPDATE/DELETE — full audit). Segment balances `(account, source)` and benefits +`(account, origin)` are a fast **materialized** cache, updated **in the same transaction** as +the ledger write, and recomputable from the ledger for reconciliation. + +**Admin rewards.** An admin grants **concrete values only** (no-ads / hints) — **never +chips** (a gifted currency balance = a store cash-desk bypass). The admin **picks the +origin** at grant time (compliance is on them: `origin=vk` point-wise/low-volume = low risk, +`origin=direct` = safe). A grant is a ledger transaction of type `admin_grant`, price 0 +chips — full audit of rewards. + +**Per-user financial report** in the admin console `/_gm` — segment balances, payments, +spends, grants, refunds, full history — as an extension of the existing user card +(`UserDetailView`, `handlers_admin_console.go`). Plus a ledger export. + +## 12. Taxes and compliance + +Receipts are automatic **through the provider**, and differ by rail: + +- **Robokassa** (direct) — self-employed НПД receipt on payment. +- **VK** — VK processes Votes through the tax authority itself; nothing to do. +- **TG Stars** — no tax side (for a RU self-employed, Stars are not legally withdrawable = not + НПД income; accepted, no receipt issued). + +**ОРД** (ad marking) for VK ads is handled on VK's side. (Not legal advice — the owner +confirms the exact НПД scheme with a tax advisor.) + +## 13. Distribution (native Android) + +- **RuStore** — Robokassa/external gate allowed (0%); native = clean `direct` context. +- **Google Play** — direct purchases are **hidden**; the Wallet shows a stub ("install the + RuStore build to make purchases"). Rewarded ads and spending already-earned chips still + work. Confirm Google's current in-app-currency rules before the GP release. + +## 14. Data model (schema `payments`) + +The payments domain lives in its **own schema `payments`** in the shared Postgres instance, +with its own DB role (rights limited to `payments`) and a domain package behind a hard +interface. Cross-schema FKs to `backend.accounts` keep the chip↔benefit spend atomic in one +transaction. Durability is **PITR** (continuous WAL archive), independent of DB topology. + +Core tables (final names/columns fixed in `PLAN.md`): + +- **ledger** — append-only operations: fund / spend / `admin_grant` / refund; `(provider, + provider_payment_id)` unique for idempotency; export-ready. +- **balances** — materialized `(account_id, source) → chips`. +- **benefits** — materialized `(account_id, origin)` → no-ads `paid_until`/`forever` + + hints count. +- **catalog** — atoms + products (soft-deletable), per-method prices, chip-purchase rates, + rewarded payout. +- **orders** — pending purchases, `order_id`, expected amount, origin, status + (pending/paid/expired). +- **payment_events** — succeeded/failed/refunded for the dispatcher. + +Legacy `accounts.hint_balance` and `accounts.paid_account` are **deprecated** and dropped +(expand-contract) in favour of the segmented model; neither was ever set in prod (no purchase +flow existed), so legacy values are zeroed. + +## 15. Glossary + +- **Фишка / chip** — the in-game currency; uniform unit, segmented by `source`. +- **source** — where chips were funded (`vk`/`telegram`/`direct`). +- **origin** — where a value was bought; governs where the benefit applies. +- **value / benefit** — what chips buy (no-ads, hints, tournament fee). +- **gate** — the one-directional store-compliance rule (§4). +- **ledger** — the append-only record of all money/value operations. +- **rail** — a payment provider (Robokassa / VK Votes / TG Stars). diff --git a/docs/PAYMENTS_ru.md b/docs/PAYMENTS_ru.md new file mode 100644 index 0000000..4a6a90f --- /dev/null +++ b/docs/PAYMENTS_ru.md @@ -0,0 +1,338 @@ +# PAYMENTS — механики монетизации + +Русское зеркало [`PAYMENTS.md`](PAYMENTS.md) (английская версия — основная). Описывает +домен монетизации: игровую валюту, кошельки, ценности, правила комплаенса сторов, приём +платежей, рекламу, каталог, журнал операций и отчёты. Каждую правку `PAYMENTS.md` зеркалим +сюда в том же PR (как `FUNCTIONAL.md`/`FUNCTIONAL_ru.md`). + +Читать перед любым изменением платёжного поведения. Технический план внедрения — +[`../PLAN.md`](../PLAN.md). + +> Статус: спецификация утверждена; внедрение поэтапно (см. `PLAN.md`). В проде пока ничего +> из этого нет. + +## 1. Обзор + +Игра зарабатывает двумя независимыми каналами: + +- **Покупки** игровой валюты **«Фишка»** за реальные деньги, затем трата Фишек на + **ценности** (бенефиты). +- **Реклама** — ролик за награду пополняет Фишки; полноэкранный ролик и наш баннер + зарабатывают показами. + +Валюта **двухуровневая**: + +``` +деньги (Голоса VK / Stars TG / рубли через Robokassa) ─┐ + ├─► Фишки ──► ценности +просмотр ролика за награду ─┘ (без рекламы, + подсказки, + турнирный взнос) +``` + +Фишки — единая единица витрины. Деньги и просмотры **пополняют** Фишки; Фишки **покупают** +ценности. Прямой оплаты ценности деньгами нет — всегда через Фишки. + +## 2. Модель валюты + +**Одна Фишка равна одной Фишке везде** — единица единая. Разница между методами оплаты +целиком в **курсе покупки**: пакет Фишек стоит *X* Голосов / *Y* Stars / *Z* рублей, и курс +учитывает комиссию каждого стора. Цены ценностей фиксированы **в Фишках** и одинаковы для +всех методов. + +Баланс Фишек **сегментирован по «источнику» (`source`)** — платформе, где Фишки пополнены: + +| `source` | Чем пополняется | +|------------|------------------------------------| +| `vk` | Покупка за Голоса VK, ролик за награду в VK | +| `telegram` | Покупка за Stars TG | +| `direct` | Покупка через Robokassa (web / native) | + +Один аккаунт держит все три сегмента одновременно: `баланс = (account_id, source)`, ровно +три записи. Сегментация **не** по привязке (identity) — см. §6. + +Почему сегментировано, а не один общий баланс: правила сторов запрещают активировать +ценность, оплаченную мимо их кассы. Фишки, пополненные внутри VK (Голоса), тратятся только +в контексте VK; Stars — только внутри Telegram; Фишки от Robokassa (`direct`) — только вне +сторов. См. §4. + +## 3. Три операции, которые нельзя путать + +Не смешивать — у них разные ключи: + +1. **Пополнить Фишки** — деньги/реклама → сегмент `source` Фишек, по **контексту + исполнения**. +2. **Потратить Фишки = купить ценность** — сегмент → бенефит, по контексту с гейтом (§4). + Здесь бенефит **рождается** и помечается своим `origin`. +3. **Применить бенефит** во времени (напр. «без рекламы до *T*») — по правилу `origin` + (§5). + +`source` (где пополнены Фишки) и `origin` (где куплена ценность) используют одно множество +значений `{vk, telegram, direct}`, но значат разное. В вебе они расходятся: покупка в вебе +может списать `vk`-Фишки (`source=vk`) в `direct`-покупку (`origin=direct`). + +## 4. Гейт комплаенса сторов + +Стена комплаенса **односторонняя**. Опасное направление — активация оплаченной снаружи +ценности *внутри* обёртки стора — заблокировано; безопасное (бенефит стора действует +наружу, в открытом вебе) — разрешено. + +**Контекст траты → какие сегменты/бенефиты доступны:** + +| Контекст исполнения | Тратимые сегменты Фишек | Приоритет траты | +|----------------------|---------------------------|--------------------| +| Внутри VK (Android) | `vk` | — | +| Внутри VK (iOS) | нет — заморожено (только просмотр) | — | +| Внутри Telegram | `telegram` | — | +| Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg | + +- Внутри VK/TG доступен только одноимённый сегмент; всё остальное (в первую очередь + `direct`) там невидимо как тратимое. +- В вебе у стора нет юрисдикции, поэтому доступны все привязанные сегменты, списываются по + приоритету direct → vk → tg. +- **VK iOS** заморожен для траты (Apple запрещает тратить виртуальную валюту на цифровые + товары мимо IAP внутри VK на iOS): баланс показывается числом, но покупка/трата + невозможны. Ранее купленный бенефит там всё равно *действует*. + +Аккаунт **единый** (привязки сливаются, один профиль/друзья/статистика). Гейт +**логический**: в контексте VK/TG сервер активирует только одноимённый сегмент. Держится на +**доверенном сигнале платформы** (§8) — клиенту не верим. Когда платформу нельзя доверенно +установить, гейт **fail-closed**: траты/покупки запрещены, только просмотр. + +### Одностороннее применение бенефита + +Бенефит несёт `origin` = **контекст покупки** (не «чем оплачено»). Покупка в вебе за +`vk`-Фишки всё равно даёт `origin=direct`. + +| `origin` | Где действует бенефит | +|------------------|----------------------------------------------------| +| `vk` / `telegram`| **Везде** — внутри своего стора *и* наружу в web/native | +| `direct` | **Только** web/native — никогда внутри VK/TG (= бан) | + +Перед тратой в вебе `vk`/`telegram`-Фишек интерфейс **предупреждает**, что ценность будет +доступна только здесь (web/native) из-за ограничений VK/TG. + +## 5. Ценности + +Три разные сущности, не смешивать: + +- **Фишки** — валюта. Сегмент по `source`. +- **Подсказки** — расходник, покупается за Фишки. Сегмент по `origin`. Тратятся по одной в + онлайн-играх; в `vs_ai` подсказки бесплатны/безлимитны (30-мин кулдаун, не в счёт). + Подсказка в игре списывается из `origin`, применимого в текущем контексте (то же + одностороннее послабление). +- **Без рекламы** — срок-бенефит, покупается за Фишки. Сегмент по `origin`. + +**Складывание «без рекламы».** Покупка срока продлевает `paid_until[origin] += срок` от +`max(сейчас, текущий конец)` — остаток не теряется («сроки плюсуются»). **Навсегда** — +отдельный вечный флаг, перекрывает сроки. «Реклама выключена в контексте *P*» ⇔ есть +применимый в *P* `origin` с `paid_until > сейчас` (в вебе берём максимум по direct/vk/tg; в +VK только vk; в TG только tg). + +**Что гасит «без рекламы»:** верхний баннер **и** полноэкранный ролик после хода. +Добровольный **ролик за награду** (за Фишки) не гасится — это выбор пользователя. + +**Турнирный взнос** — будущий тип ценности; атом заложен, механика позже. + +## 6. Жизненный цикл кошелька + +**Правило доступности сегмента.** Сегмент тратим ⇔ на аккаунте есть привязка этого +`source` (для `direct` — устойчивая привязка/email). Отсюда unlink/мерж выводятся +естественно. + +**Отвязка (vk/tg).** Разрешена даже при ненулевом балансе/активном бенефите. Сегмент не +сжигается — он **засыпает** (нет привязки ⇒ недоступен в VK *и*, без привязки, недоступен +как подтянутый веб-сегмент); повторная привязка будит. Перед отвязкой предупреждение +(«N Фишек станут недоступны до повторной привязки»). Последнюю привязку отвязать нельзя +(существующий `ErrLastIdentity`). + +**Мерж.** Сегменты и бенефиты сливаются **по origin**: одноимённые складываются (Фишки +суммируются, сроки бенефита продлеваются по origin), разные сосуществуют. Это расширяет +текущий мерж аккаунтов (`hint_balance +=`, `paid_account OR=`), так что origin сохраняется и +ничего не протекает между платформами. + +**Гость.** У гостевого аккаунта **вообще нет кошелька** — раздел «Кошелёк» скрыт, покупок +нет, баланса нет, by design. Балансы — только у durable-аккаунтов. Поэтому чистильщик +гостей удаляет их свободно (денег там быть не может). В `direct` email обязателен **перед +первой покупкой** как якорь восстановления (в VK/TG якорь — сама vk/tg-привязка); +переиспользуем существующий флоу email (запрос кода → подтверждение → снятие флага гостя → +durable). + +## 7. Каталог и цены + +Каталог **конфигурируемый** — продукты, цены, курсы покупки и награда за ролик живут в базе +и правятся в админке, без релиза. + +- **Базовые ценности (атомы):** Фишки, подсказки, дни без рекламы, участие в турнире. +- **Продукт = набор атомов + цена.** Продаётся по одной или комбо (напр. «250 подсказок + + 30 дней без рекламы»). +- **Пакет Фишек** (пополняет Фишки) — цена **per-метод** (мультивалютная Голоса/Stars/руб) + одним продуктом. +- **Ценность** (за Фишки) — цена **в Фишках** (единая для всех методов). + +**Деактивация, не удаление.** Продукты деактивируются (soft-delete). Состоявшаяся покупка +хранит **снимок** проданного (состав атомов + цена на момент) в архиве, чтобы +история/чеки/налоги не зависели от последующих правок каталога. + +## 8. Доверенный сигнал платформы + +Гейту (§4) нужен **доверенный, неподделываемый** контекст платформы на сервере. Клиент — +никогда не источник правды. + +- Платформа — **свойство сессии**, переподтверждается свежей подписью на **каждом холодном + старте** — VK launch-params `sign` / TG `initData` (валидатор уже есть в + `platform/telegram/internal/initdata`). Обёртки VK/TG шлют подпись при каждом открытии, + так что это не одноразовый вход. +- `direct` устанавливается самим фактом создания веб/native-сессии (внешней подписи нет и + не нужно — доступ к vk/tg-сегментам в direct-контексте всё равно требует реальной + привязки, §6). +- Платформа несёт **kind** (`vk`/`telegram`/`direct`) **плюс подтип** + (`ios`/`android`/`web`) — подтип обязателен (VK iOS заморожен). +- Гейтвей резолвит сессию и передаёт `platform` в бэкенд (рядом с существующим + `X-User-ID`). +- **Fail-closed:** недоверенная/неподтверждённая платформа (VK/TG-сессия без валидной + подписи на холодном старте; старая сессия без записанной платформы) запрещает + траты/покупки и применение любого чужого origin — только просмотр. + +## 9. Приём платежей + +**Только серверный колбэк провайдера.** Фишки начисляются лишь по **проверенному** +(подпись/HMAC) серверному колбэку — Robokassa webhook / TG `successful_payment` / VK +callback. Клиентское «я оплатил» игнорируется. + +**Единственный писатель.** Один платёжный домен — единственный, кто пишет в журнал операций. +Публичные вебхуки (Robokassa/VK) терминируются на краю (Caddy/gateway) и проксируются в +платёжный домен; TG `successful_payment` приходит боту, тот форвардит в платёжный домен. +Одно место начисляет и защищает от повторов. + +**Флоу заказа.** Сервер заранее создаёт `order(pending)` с account / платформой / пакетом / +ожидаемой суммой / origin. `order_id` прокидывается провайдеру (Robokassa `InvId` / TG +`invoice_payload` / VK `item` — точную форму VK уточнить при интеграции). Колбэк матчится по +`order_id` (никогда по сумме, поэтому коллизии одинаковых сумм невозможны), сверяет сумму, +начисляет, помечает `paid`. **Защита от повторов:** дедуп по `(провайдер, +provider_payment_id)`. + +**Pending невидим** пользователю; авто-истекает по таймауту (~30 мин, гигиена базы). +Валидный колбэк исполняется **всегда**, даже на истёкшем заказе (`expired` ≠ отмена — +деньги реальны, Фишки должны быть выданы). Пользователь видит только успешные покупки. + +**Outbox TG-бота.** `successful_payment` приходит только боту (Bot API, не Mini App), а +хост бота слабый и может терять связь, поэтому бот — durable-звено. Store-and-forward на +**SQLite** на диске бота: получил → сохранил → подтвердил апдейт Telegram → форвардит в +платёжный домен (идемпотентно, дедуп по `telegram_payment_charge_id`) → ack → пометил +`forwarded`. Ретраи с backoff; дореталивает недоставленное при рестарте. Доставка +at-least-once + идемпотентный приём = начисление ровно один раз. + +**События.** Платёжный домен пишет `payment_events` (succeeded / failed / refunded); +диспетчер рассылает по каналам — live gRPC-стрим, если пользователь в аппе, иначе +существующий пуш `botlink` / email. «Оплата не прошла» (**активный** отказ провайдера, не +брошенный pending) доводится до пользователя; «оплата прошла» — хук (письмо / сообщение в +бота). + +**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Админ может сделать +**ручной** возврат (крайний случай: пользователь требует вскоре после оплаты / закрывает +аккаунт — связано с `accountdelete`, где уже сохраняются сообщения). **Внешние** возвраты +(чарджбек / решение стора / TG / VK) обрабатываются: система принимает событие `refunded`, +**по возможности** отзывает бенефит (в минус не уходим; если Фишки уже потрачены — +фиксирует убыток + флаг защиты от злоупотреблений), пишет в журнал. Журнал операций +**спроектирован экспортопригодным** для будущей налоговой отчётности и сверки с Robokassa +(саму сверку пока не строим; схема остаётся совместимой). + +## 10. Реклама + +**Охват на старте: только VK** для видео (награда в рублях, ОРД автоматом, API готов). +web/native/TG держат только существующий наш **текст-баннер**; видео отложено до появления +рублёвой in-app сети. Рекламный провайдер за **абстракцией**, чтобы будущая сеть для других +платформ встроилась без переделки. Крипто-сети (AdsGram/AdMob) отвергнуты — нет легального +рублёвого дохода самозанятому (НПД). + +**Ролик за награду** (добровольное видео за Фишки) начисляет Фишки через платёжный домен по +**серверному verify-колбэку** сети (клиенту не верим, как платежу). Антифрод на старте — только +verify провайдера (своего дневного потолка пока нет; абстракция позволит добавить лимиты +позже). + +**Полноэкранный ролик** (после хода), конфигурируемые серверные значения: + +- Глобальный кулдаун **на пользователя, сквозь все партии**, дефолт **5 мин**. +- **`vs_ai` — 30 мин** (соосно кулдауну подсказок, чтобы не отпугивать казуалов). +- Применение **подсказки** триггерит ролик после хода **независимо** от основного кулдауна, + со своим кулдауном **1 мин**. +- Оффлайн — только баннер. +- Уважать собственные лимиты частоты VK. + +**Оффлайн без рекламы**, кроме нашего баннера. Бенефит `без рекламы` гасит баннер через +существующий `ads.Eligible` (`backend/internal/ads/ads.go`), который расширяется до гейта по +**origin-бенефиту, применимому в текущем контексте**, а не по одному глобальному флагу. + +## 11. Админ, аудит, отчётность + +**Неизменяемый журнал операций + материализованный баланс.** Журнал операций **только на +INSERT** (никогда UPDATE/DELETE — полный аудит). Балансы сегментов `(account, source)` и +бенефиты `(account, origin)` — быстрый **материализованный** кэш, обновляется **в той же +транзакции**, что и запись журнала, и пересчитывается из журнала для сверки. + +**Награждение админом.** Админ начисляет **только конкретные ценности** (без рекламы / +подсказки) — **никогда не Фишки** (подаренный баланс валюты = обход кассы стора). Админ +**выбирает origin** при выдаче (ответственность за комплаенс на нём: `origin=vk` +точечно/малый объём = низкий риск, `origin=direct` = безопасно). Грант — транзакция журнала +типа `admin_grant`, цена 0 Фишек — полный аудит наград. + +**Финансовый отчёт по пользователю** в админке `/_gm` — балансы сегментов, платежи, траты, +гранты, возвраты, полная история — расширение существующей карточки (`UserDetailView`, +`handlers_admin_console.go`). Плюс экспорт журнала. + +## 12. Налоги и комплаенс + +Чеки формируются автоматически **на стороне провайдера** и отличаются по каналу: + +- **Robokassa** (direct) — чек НПД самозанятого при оплате. +- **VK** — VK сам процессит Голоса через налоговую; делать нечего. +- **TG Stars** — налоговой стороны нет (для РФ-самозанятого Stars легально невыводимы = не + доход НПД; принимаем, чек не формируем). + +**ОРД** (маркировка рекламы) по VK-рекламе — на стороне VK. (Не юридическая консультация — +владелец сверяет точную схему НПД с налоговым консультантом.) + +## 13. Дистрибуция (native Android) + +- **RuStore** — Robokassa/внешний гейт разрешён (0%); native = чистый контекст `direct`. +- **Google Play** — direct-покупки **скрыты**; «Кошелёк» показывает заглушку («установите + версию из RuStore для покупок»). Ролик за награду и трата уже накопленных Фишек работают. + Перед GP-релизом свериться с актуальными правилами Google по внутренней валюте. + +## 14. Модель данных (схема `payments`) + +Платёжный домен живёт в **своей схеме `payments`** в общем инстансе Postgres, со своим +DB-ролём (права только на `payments`) и доменным пакетом за жёстким интерфейсом. +Cross-schema внешние ключи к `backend.accounts` держат трату «Фишки↔бенефит» атомарной в +одной транзакции. Сохранность — **PITR** (непрерывный WAL-архив), независимо от топологии +базы. + +Основные таблицы (финальные имена/колонки — в `PLAN.md`): + +- **журнал операций (ledger)** — append-only операции: пополнение / трата / `admin_grant` / + возврат; `(провайдер, provider_payment_id)` уникален для защиты от повторов; + экспортопригоден. +- **балансы** — материализованные `(account_id, source) → Фишки`. +- **бенефиты** — материализованные `(account_id, origin)` → «без рекламы» + `paid_until`/`forever` + счётчик подсказок. +- **каталог** — атомы + продукты (деактивируемые), цены per-метод, курсы покупки Фишек, + награда за ролик. +- **заказы (orders)** — pending-покупки, `order_id`, ожидаемая сумма, origin, статус + (pending/paid/expired). +- **payment_events** — succeeded/failed/refunded для диспетчера. + +Legacy `accounts.hint_balance` и `accounts.paid_account` **устаревают** и удаляются +(expand-contract) в пользу сегментированной модели; ни то, ни другое в проде никогда не +выставлялось (потока покупки не было), поэтому legacy-значения обнуляются. + +## 15. Словарь + +- **Фишка** — игровая валюта; единая единица, сегментирована по `source`. +- **source (источник)** — где пополнены Фишки (`vk`/`telegram`/`direct`). +- **origin (происхождение)** — где куплена ценность; определяет, где действует бенефит. +- **ценность / бенефит** — то, что покупается за Фишки (без рекламы, подсказки, турнирный + взнос). +- **гейт** — одностороннее правило комплаенса сторов (§4). +- **журнал операций (ledger)** — неизменяемая запись всех операций с деньгами/ценностями. +- **платёжный канал / рельса** — платёжный провайдер (Robokassa / Голоса VK / Stars TG).