docs(payments): add monetization spec and implementation plan
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s

Add docs/PAYMENTS.md (+ docs/PAYMENTS_ru.md mirror) — the monetization
mechanics specification: two-tier «Фишка» currency, per-source wallet
segments, per-origin benefits with the one-directional store-compliance
gate, order-flow payment intake, VK ads, configurable catalog,
append-only ledger, taxes, and native-Android distribution.

Add PLAN.md — the staged technical implementation plan with per-step
touch-points, tests, and done-criteria.
This commit is contained in:
Ilia Denisov
2026-07-07 23:11:49 +02:00
parent ca48b3e18e
commit 4c7bc7baa9
3 changed files with 1269 additions and 0 deletions
+333
View File
@@ -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).
+338
View File
@@ -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).