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
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.
404 lines
24 KiB
Markdown
404 lines
24 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.
|
|
|
|
**Multi-shop direct rail (D42).** The `direct` rail routes to one Robokassa **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),
|
|
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** (`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:
|
|
|
|
- **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).
|