12e616ceae
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m28s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
The merchant accepts payments as a sole proprietor on НПД, which is outside 54-ФЗ: there is no online cash register, YooKassa does not serve receipts for that regime at all (checked with their support), and each operation is reported by the merchant to «Мой налог», which issues the чек. So no `receipt` is sent with a payment or a refund. This also removes a failure class rather than just code: a malformed receipt was an API error at payment creation, which broke the purchase outright. The fiscal code is kept dormant rather than deleted. All of it now sits behind one switch, `BACKEND_YOOKASSA_VAT_CODE`: unset — the default — builds and sends nothing; a 54-ФЗ rate code turns «Чеки от ЮKassa» back on unchanged. The return is foreseeable, which is why the switch exists: НПД carries an annual income ceiling, and losing the regime puts 54-ФЗ back in force, at which point this is a deploy-variable edit instead of writing the integration again. The D36 email anchor still gates a direct purchase. It had two justifications — a recovery anchor and the receipt address — and only the second is gone; without an email a paying customer who loses the account loses the chips with it. What was missing is that the rule was enforced but never communicated: the wallet showed the packs to a player signed in through VK or Telegram in a browser, and tapping Buy produced a bare "something went wrong". It now says "add an email in your profile" in the buy tab instead, linking to the profile; spending already-earned chips is untouched. The predicate is a pure function so it is covered by the node-env unit tests rather than needing a browser. Tests: the receipt-off default is pinned by an integration test asserting a purchase carries no receipt, and the dormant path by one that switches a VAT code on and checks the receipt reappears with the right fiscal attributes; plus unit coverage for the enable predicate and the wallet's email rule. A note for whoever runs the numbers next: the shared (svelte + i18n) chunk is now 40 bytes under its 31 KB gzip budget. Decision D51 revised.
484 lines
30 KiB
Markdown
484 lines
30 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 YooKassa) ─┐
|
|
├─► Фишки (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` | YooKassa 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; YooKassa-funded (`direct`) chips only outside
|
|
the stores. See §4.
|
|
|
|
**Multi-shop direct rail (D42).** The `direct` rail routes to one YooKassa **merchant shop per
|
|
channel** — `web` 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).
|
|
All shops share one notification endpoint, `/pay/yookassa/notify`; an incoming notification is
|
|
attributed to a shop by the shop id the payment reports (`recipient.account_id`), falling back to the
|
|
channel recorded on the order, and that shop's credentials perform the confirming read (§9).
|
|
Standalone apps (Android/iOS) sign in by email only, so a direct purchase always has the D36 email
|
|
anchor (D43), which is also where the fiscal receipt is delivered. Fiscalization (54-ФЗ under one ИП)
|
|
is a single source regardless of the number of shops (D41).
|
|
|
|
**Robokassa is retired, not deleted (D47).** It settled the `direct` rail before YooKassa. Its code,
|
|
tests and wiring stay in the tree and the rail falls back to it when no YooKassa shop is configured,
|
|
so reviving it is a credentials change rather than a code change; no deployment sets those
|
|
credentials today. `backend/internal/robokassa/README.md` records the retired variables, the cabinet
|
|
configuration and the revival steps. Ledger rows written while it was live keep
|
|
`provider = 'robokassa'` — that literal is load-bearing for the idempotency index and is never
|
|
renamed or reused.
|
|
|
|
**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.
|
|
|
|
**Titles reject HTML-special characters.** The admin editor refuses a product title containing
|
|
`& < > " '` (`validateProduct`). VK HTML-escapes such characters in its `order_status_change`
|
|
payment-callback echo (e.g. `"` → `"`) but computes the notification signature over a different
|
|
form, so our callback signature check no longer matches and the purchase silently fails to confirm —
|
|
while the `get_item` phase (whose request carries no title) still passes. Use « » guillemets, not
|
|
straight quotes.
|
|
|
|
## 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** provider callback —
|
|
YooKassa notification / TG `successful_payment` / VK callback. A client "I paid" is ignored. What
|
|
"verified" means differs by rail: VK and Telegram sign their callbacks, and **YooKassa does not**
|
|
(D48) — see the confirming read below.
|
|
|
|
**Single writer.** One payments domain is the only writer of the ledger. Public webhooks
|
|
(YooKassa/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 (YooKassa `metadata.order_id` /
|
|
TG `invoice_payload` / VK `item`). 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)`.
|
|
|
|
**Direct rail (YooKassa).** Opening a purchase is an outbound API call: the server creates the
|
|
payment (`POST /v3/payments`, `capture: true`, redirect confirmation, the order id as both the
|
|
`Idempotence-Key` and `metadata.order_id`, plus the fiscal receipt of §12) and sends the customer to
|
|
the returned `confirmation_url`. The provider's payment id is recorded on the order straight away,
|
|
which is what later lets the order be re-checked and refunded. The browser return page
|
|
(`/pay/yookassa/return`) is cosmetic: the credit never rides a redirect.
|
|
|
|
**The notification is a hint, never evidence (D48).** YooKassa does not sign notifications, so
|
|
nothing in the body may be acted on. It only names a payment, which the server then re-reads with
|
|
`GET /v3/payments/{id}`; only that answer is trusted. Two guards ride on it: the payment's metadata
|
|
must name the order being credited, and its `test` flag must match the shop's, so a test-shop payment
|
|
can never credit real chips (nor a live payment be credited against test credentials). As defence in
|
|
depth — and to stop a forger turning each fabricated notification into an outbound call of ours — the
|
|
sender address is first checked against YooKassa's published ranges. The reply tells the provider
|
|
whether to redeliver: 200 for anything durably decided, including a duplicate and a permanent
|
|
rejection, and 5xx only for a transient failure (YooKassa redelivers for 24 hours).
|
|
|
|
**Expiry-time reconciliation (D49).** No continuous polling. But a notification that is lost for good
|
|
would leave the money taken and the chips unowed, silently, so the existing pending-order reaper asks
|
|
the provider what became of each order that reached its expiry age carrying a payment id, and credits
|
|
the ones that were in fact paid. One request per order over its whole life.
|
|
|
|
**A declined payment is surfaced.** A YooKassa `payment.canceled` records a `failed` payment event,
|
|
so the customer is told the attempt did not go through instead of watching a balance that never
|
|
moves. An order simply abandoned records nothing — it just expires, invisibly.
|
|
|
|
**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). On the direct rail the console does the whole job in one click
|
|
(D50): it calls YooKassa's refund API first (`POST /v3/refunds`, the order id as the
|
|
`Idempotence-Key`, carrying the refund receipt of §12) and records the reversal only once the money
|
|
has actually moved — a failed call records **nothing**, so the ledger can never claim a refund that
|
|
did not happen. A refund is recorded **only in status `succeeded`**: one still `pending` can yet be
|
|
canceled, and the ledger is append-only, so recording early could revoke a customer's chips for money
|
|
that stayed with us. Pressing again is the way out — the idempotency key returns the same refund
|
|
rather than paying twice. The recorded refund id is the provider's own, which keeps the ledger
|
|
reconcilable against YooKassa's records.
|
|
|
|
**The cabinet is a second entry point (D52).** Unlike the earlier rails, YooKassa lets an operator
|
|
refund from the merchant cabinet, and such a refund never passes through our API — so the money would
|
|
go back while the chips stayed credited. The `refund.succeeded` notification closes that: the refund
|
|
is re-read from the API (the body is no more evidence than a payment notification's), bound back to
|
|
the order through the payment id we recorded, and reversed through the same engine — idempotent on
|
|
`(provider, refund id)`, so the notification for a refund the console already recorded reverses
|
|
nothing twice. The engine is **full-refund-only** by design (it revokes exactly what the pack funded
|
|
and rejects any other amount), so a **partial** refund is recorded as nothing at all and logged
|
|
loudly for an operator: there is no non-arbitrary way to decide how many chips a part-refund costs.
|
|
|
|
VK refunds are still handled by support and Telegram Stars refunds issued with `refundStarPayment`,
|
|
both recorded by hand afterwards. 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** (`VKWebAppShowNativeAds` → `data.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:
|
|
|
|
- **YooKassa** (direct) — **no receipt is sent, by design (D51 rev)**. The merchant operates on
|
|
**НПД**, which is outside 54-ФЗ: there is no online cash register and the provider registers
|
|
nothing. Each operation is reported by the merchant to **«Мой налог»**, which issues the чек.
|
|
YooKassa does not serve receipts for this regime at all.
|
|
|
|
The fiscal code is **kept dormant**, not deleted: «Чеки от ЮKassa» (YooKassa owning the cash
|
|
register, the fiscal drive and the OFD contract) registers a receipt **only if the request carries
|
|
it**, and that request-building lives behind one switch — `BACKEND_YOOKASSA_VAT_CODE`. Unset, no
|
|
`receipt` is built or sent; set to a 54-ФЗ rate code (tag 1199) it goes back to sending one line
|
|
(pack title, quantity 1, amount) with the settlement subject `service` (tag 1212) and method
|
|
`full_payment` (tag 1214), delivered by email to the D36 anchor. That switch exists because the
|
|
return is foreseeable: НПД carries an annual income ceiling, and losing the regime puts 54-ФЗ back
|
|
in force.
|
|
|
|
Everything an automated «Мой налог» submission needs is already recorded — the order id, the
|
|
provider payment id, the amount and currency, the credit time, the sold-pack snapshot, and refunds
|
|
as their own ledger rows, all exportable as CSV. The one thing it will additionally need is a
|
|
per-operation "already reported" marker for its own idempotency; that belongs to the submission
|
|
work, not here.
|
|
- **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** — an external payment gate is 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 (YooKassa / VK Votes / TG Stars).
|