6e03ce0131
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
Accept real money via Telegram Stars (XTR) — the third intake rail alongside Robokassa (direct) and VK Votes. Only the bot reaches Telegram, so the rail funnels through the reverse mTLS bot-link: - the gateway mints the invoice on a CreateInvoice command (the bot calls createInvoiceLink, XTR; the link goes to WebApp.openInvoice); - the bot gates each pre_checkout_query via a ValidatePreCheckout unary (the order must exist, be still creditable and not already paid — the reusable-invoice double-pay guard; the decline reason is localised to the order account's language); - a completed successful_payment is queued in a durable pure-Go SQLite outbox and forwarded via a ForwardPayment unary, credited once (idempotent on telegram_payment_charge_id, honours an expired order), re-driven on restart and every 30s. The rail is wired by TELEGRAM_STARS_OUTBOX_DIR (default /data) but stays inert until a chip pack carries an XTR price, so seeding a Stars price in the admin is the go-live. Tests: backend integration (order->forward->credit once, duplicate, pre_checkout gate) + bot outbox unit (idempotent, restart re-drive) + executor createInvoice. Docs: PAYMENTS(+ru) §9, ARCHITECTURE, the platform/telegram README, PLAN.
361 lines
20 KiB
Markdown
361 lines
20 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)`,
|
|
up to three rows — a row is materialised lazily on the segment's first funding, and an absent
|
|
segment reads as zero. 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**, captured when the session is created. VK and
|
|
Telegram wrappers re-mint (and so re-validate the launch signature — VK launch-params `sign`
|
|
/ Telegram `initData`) on **every cold start**, so their platform is re-confirmed each launch;
|
|
a `direct` session captures it once, 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`). `kind` is always trusted — the server derives it from the validated
|
|
launch, never a client field. The **subtype is trusted only for VK**: it rides inside the
|
|
signed launch parameters, which is what makes the **VK iOS freeze** enforceable; for Telegram
|
|
and direct the subtype is client-reported and best-effort, so the gate never keys off it.
|
|
- The gateway resolves the session and passes `platform` to the backend (alongside the existing
|
|
`X-User-ID`), sourced from the session — never the client request body.
|
|
- **Fail-closed:** an untrusted platform — a session with no recorded platform, one predating
|
|
this feature or one the gateway could not attribute — denies spends/purchases and applying any
|
|
foreign origin (view only). A VK/TG session recovers on its next cold-start re-mint; a reused
|
|
direct/email session on re-login.
|
|
|
|
## 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 Stars.** Only the **bot** reaches Telegram, so the whole rail funnels through the reverse
|
|
mTLS **bot-link** (bot ↔ gateway; the bot cannot dial the backend). The invoice is minted by the
|
|
bot: on the order path the gateway sends a `CreateInvoice` command and the bot returns a
|
|
`createInvoiceLink` (XTR) in its Ack, which the Mini App opens with `WebApp.openInvoice`. Before any
|
|
star moves the bot answers `pre_checkout_query` via a bot→gateway `ValidatePreCheckout` unary
|
|
(backed by the intake): approve only if the order exists, is still creditable and is **not already
|
|
paid** — a Stars invoice link is reusable, so this gate is the one place a repeat payment is stopped
|
|
before the charge; the decline reason is localised to the order account's language.
|
|
|
|
`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 (`internal/outbox`): persist on receipt (idempotent on `telegram_payment_charge_id`) → forward
|
|
over the bot-link (a `ForwardPayment` unary; the gateway proxies to the intake) → on a durable
|
|
response, mark `forwarded`. Re-drives undelivered on restart and on a periodic tick. At-least-once
|
|
delivery + idempotent intake (dedup by `telegram_payment_charge_id`) = 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.
|
|
|
|
**In-process read cache.** On top of the materialized tables, the payments package keeps an
|
|
in-process, account-keyed, write-through cache of each account's segments and benefits, so the
|
|
hot read paths — the ad-eligibility check, hint availability, the wallet view, the spend gate
|
|
— issue **no** query to the `payments` schema on the steady-state path. It is invalidated on
|
|
every payments mutation (spend / grant / fund / refund / merge) and re-read from the
|
|
materialized tables on a miss (the same write-through pattern the account-suspension gate
|
|
uses). Single-instance, matching the deployment; a multi-instance backend would need a shared
|
|
cache. Identity-presence (which segments are awake, §6) is supplied by the caller, not cached
|
|
here, so unlink/re-link takes effect immediately.
|
|
|
|
**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. There is **no cross-schema foreign key** to `backend.accounts` — an account id is a
|
|
plain value here, kept consistent in code and joined to the tombstoned account /
|
|
retained-identities dossier by the stable id. That keeps the chip↔benefit spend atomic
|
|
**within `payments`** and the domain extractable into its own database. 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** in favour of the
|
|
segmented model and dropped in a later **contract-phase** migration (expand-contract, after the
|
|
currency core flips reads and Release 2 — image rollback stays DB-safe); 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).
|