Files
scrabble-game/docs/PAYMENTS.md
T
Ilia Denisov 1507ceb793
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m0s
feat(payments): live payment-availability kill switch + per-account override
Let an operator disable purchases live from the admin — a whole rail/channel or
one account — and show the user a localized reason on their next attempt, so a
provider outage or a misconfig is explained instead of a silent dead button.

- rail kill switch (payments.rail_status, per rail direct:web / direct:android /
  vk / telegram): enabled + a per-language message, edited on the catalog page.
  Fail-open — a rail with no row stays enabled, so payments are never accidentally
  killed. The intake gate (CanPurchase in handleWalletOrder, before the order)
  returns payment_unavailable + the localized message, orthogonal to the security
  gates.
- per-account override (payments.account_payment_override, a row only for
  non-default): allow / deny / default, edited on the user card. "allow" bypasses
  ONLY the ops rail switch, never the security gates (trusted platform, the email
  anchor, the VK-iOS freeze, the min client version).
- wire: an additive ExecuteResponse.message envelope field (frozen-contract-safe);
  the gateway forwards a backend domain-error message; the client shows it on a
  payment_unavailable buy attempt.
- admin: rail toggles on the catalog page, the override control on the user card.
- tests: the pure gate (unit, TDD), the store + gate + override end-to-end
  (integration, migration 00016), the client (svelte-check / vitest).
- docs: PAYMENTS (+ru), the decisions log (D45/D46). Fiscalization stays
  cabinet-side (owner decision) — no itemized-receipt code.

Contour-safe: additive migration (two new tables, no wipe), the wire add is
additive, and fail-open so nothing is disabled until an operator acts.
2026-07-14 16:29:41 +02:00

24 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), 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.

Multi-shop direct rail (D42). The direct rail routes to one Robokassa merchant shop per channelweb and android (RuStore), ios later — chosen by the trusted X-Platform subtype; every shop credits the one direct wallet (no per-channel wallet). This is merchant-account separation for accounting / receipts only: the order records its shop (shown in the admin report), and each shop's Result callback is verified by its own Password2 at /pay/robokassa/result/<channel>. Standalone apps (Android/iOS) sign in by email only, so a direct purchase always has the D36 email anchor (D43). Fiscalization (54-ФЗ via the Robokassa cabinet under one ИП) is a single source regardless of the number of shops (D41).

Payment availability kill switch (D45/D46). An operator can disable purchases on a rail/channel (direct:web / direct:android / vk / telegram) or for one account, live from /_gm, and the user sees a localized reason on their next attempt (payment_unavailable, carried on the additive ExecuteResponse.message). Fail-open: a rail with no status row is enabled. A per-account allow override bypasses only this operational switch, never the security gates (D46).

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) vk (purchase-frozen)
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 a PURCHASE freeze, not a spend freeze. Apple's ToS forbids only buying in-app values there (for any currency) — so a purchase (money → chips) is refused. Spending VK-wallet chips (earned via rewarded ads or bought on the same VK account elsewhere, e.g. VK Android) and earning them are legal and stay allowed on VK iOS; a bought benefit also applies there. Only the money-in step is blocked.

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. Refunds are admin-triggered (the E7 console), since no rail pushes an unsolicited refund: Robokassa refunds run through its refund API / merchant cabinet (auto-polling a rail's refund status is a deferred worker, not worth it at low chargeback volume), VK refunds are handled by support, and Telegram Stars refunds are issued with refundStarPayment. All of them converge on one engine — the Refund method (internal/payments): it matches the paid order, appends a refund ledger row (idempotent on (provider, provider_refund_id) — the refund id is distinct from the fund's payment id, so the two rows coexist under the same partial-unique index), and best-effort revokes the funded chips floored at 0 (never negative — D27, balances_chips_chk). When the chips were already spent, the unrecoverable remainder is recorded as a per-account loss + abuse flag (payments.account_risk, read by the E7 report). The refund ledger row's chip delta is what was actually reclaimed, so the ledger stays reconcilable against the balance; the full reversal (money, original chips, loss) rides in the row's snapshot. The order stays paid — the refund lives in the ledger + a refunded payment event, not in the order status. A duplicate refund reverses nothing. The ledger is export-ready for future tax reporting and reconciliation (the reconciler 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. VK Mini App ads expose only a client-side watch result (VKWebAppShowNativeAdsdata.result) — there is no server-to-server verify — so a rewarded credit is client-attested (D29 amended: server verify is not possible on VK). The anti-abuse (and economic) guard is a server-side daily and hourly cap (config reward_daily_cap / reward_hourly_cap, default 50 / 10), which bounds a forger who skips the ad and calls the credit endpoint directly and, as importantly, limits free rewarded chips so a player who wants more buys. The credit is idempotent on a client nonce and order-less; the payout is config (rewarded_payout_chips, default 0 = rewarded off until set). Rewarded is VK-only and is not suppressed by no-ads (D9). A future network that offers a server verify slots in behind the ads abstraction. On the test contour a build flag (VITE_ADS_STUB) swaps a toast for the real ad; prod always shows real ads (a failed real ad must not credit).

Interstitial (fullscreen after a confirmed play), VK-only, configurable server-side values. The gate is client-mirrored: the profile carries the cooldowns and a suppressed flag (the same no-ads / no_banner gate as the banner, resolved server-side in adsFor), and the client self-gates on a single shared last-shown time in localStorage — the kind only picks the required gap, so a hint ad and a move ad never fire within a cooldown of each other (one shared timer, not one per kind) — no per-move server round-trip. The contour VITE_ADS_STUB swaps the same "ad fired" toast for the real ad. 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 an interstitial independently of the main cooldown, with its own 1-min cooldown.
  • Fires only after a confirmed play or a hint — never after a pass, exchange or resign (an ad for a non-scoring action would only annoy, so those are never "rewarded" with one).
  • 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). It grants either raw atoms or a defined value product (a reward bundle, which may be archived — hidden from the store but grantable); both refuse a chips or tournament atom. 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 (the by-product grant records the source product_id + snapshot) — 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).