Files
scrabble-game/docs/PAYMENTS.md
T
Ilia Denisov 4c7bc7baa9
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
docs(payments): add monetization spec and implementation plan
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.
2026-07-07 23:11:49 +02:00

334 lines
18 KiB
Markdown

# 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).