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.
18 KiB
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 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.
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:
- Fund chips — money/ad → chip
sourcesegment, keyed by the execution context. - 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. - 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_aihints stay free/unlimited (30-min idle gate, not counted). A hint spent in a game is drawn from anoriginapplicable 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/ TGinitData(validator already exists atplatform/telegram/internal/initdata). VK/TG wrappers resend the signature on every open, so this is not a one-time login. directis 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
platformto the backend (alongside the existingX-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
directcontext. - 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-adspaid_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).