feat(payments): settle the direct rail through YooKassa
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s

Replace Robokassa with YooKassa as the RUB direct-rail provider. The wallet
model is untouched: one `direct` segment, the same spend wall, the same
per-channel merchant shops (D42) and `shop` on the order (D44).

The two providers are not shaped alike, and that drives the change:

- Opening a purchase is now an outbound API call (`POST /v3/payments`,
  single-stage capture, redirect confirmation). The order id is both the
  `Idempotence-Key` and `metadata.order_id`, so a retried create cannot mint a
  second payment and a notification always resolves to its order.
- YooKassa does NOT sign notifications, so the body is never evidence: it only
  names a payment, which is re-read with `GET /v3/payments/{id}`, and only that
  answer is acted on. Two guards ride on it — the payment's metadata must name
  the order, and its `test` flag must match the shop's, so a test-shop payment
  can never credit real chips. The sender address is checked against YooKassa's
  published ranges first, which stops a forger turning each fabricated
  notification into an outbound call of ours.
- A notification lost for good would leave the money taken and the chips unowed,
  silently. The existing pending-order reaper now asks the provider about each
  order that reached its expiry age carrying a payment id, and credits the ones
  really paid — one request per order over its whole life, not polling.
- `payment.canceled` records a `failed` event, so a declined payment is finally
  surfaced to the customer as PAYMENTS.md §9 already specified.
- The admin refund moves the money through `POST /v3/refunds` before recording
  anything; a failed call records nothing, so the ledger cannot claim a refund
  that did not happen, and the recorded id is the provider's own.
- YooKassa has no cabinet-side generic receipt: «Чеки от ЮKassa» registers one
  only if the request carries it, so every payment and refund now sends an
  itemized `receipt` to the D36 confirmed email. The VAT rate code is a deploy
  variable; the settlement subject and method are constants.

Robokassa is retired, not deleted: the direct rail falls back to it when no
YooKassa shop is configured and no deployment sets its credentials, so reviving
it is a credentials change rather than a code change. Its variables are removed
from compose, .env.example, write-prod-env.sh and the three workflows, and
recorded in backend/internal/robokassa/README.md together with the cabinet
configuration and the revival steps. Ledger rows keep `provider = 'robokassa'`;
that literal is load-bearing for the idempotency index.

No migration and no wire change: `orders.provider_payment_id` already existed,
and the client is rail-agnostic.

Decisions D47-D51 (revising D41) and stage E12 are baked into the docs.
This commit is contained in:
Ilia Denisov
2026-07-28 08:51:31 +02:00
parent 985ed40639
commit 92ba527575
37 changed files with 2638 additions and 171 deletions
+72 -23
View File
@@ -23,7 +23,7 @@ The game earns through two orthogonal channels:
The currency is **two-tier**:
```
money (VK Votes / TG Stars / RUB via Robokassa) ─┐
money (VK Votes / TG Stars / RUB via YooKassa) ─┐
├─► Фишки (chips) ──► values
rewarded ad view ─┘ (no-ads, hints,
tournament fee)
@@ -46,7 +46,7 @@ The chip balance is **segmented by `source`** — the platform where the chips w
|------------|----------------------------------|
| `vk` | VK Votes purchase, VK rewarded ad |
| `telegram` | TG Stars purchase |
| `direct` | Robokassa purchase (web / native)|
| `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
@@ -54,17 +54,27 @@ 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 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 Robokassa **merchant shop per
**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),
and each shop's Result callback is verified by its own Password2 at `/pay/robokassa/result/<channel>`.
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). Fiscalization (54-ФЗ via the Robokassa cabinet under one ИП) is a single source
regardless of the number of shops (D41).
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
@@ -224,20 +234,47 @@ never the source of truth.
## 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.
**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
(Robokassa/VK) terminate at the edge (Caddy/gateway) and proxy into payments; TG
(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 (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)`.
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 —
@@ -266,10 +303,14 @@ an abandoned pending) is surfaced to the user; "payment succeeded" is a hook (em
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 —
**admin-triggered** (the E7 console), since no rail pushes an unsolicited refund. 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. The recorded refund id is the provider's
own, which keeps the ledger reconcilable against YooKassa's records. 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
@@ -356,7 +397,15 @@ spends, grants, refunds, full history — as an extension of the existing user c
Receipts are automatic **through the provider**, and differ by rail:
- **Robokassa** (direct) — self-employed НПД receipt on payment.
- **YooKassa** (direct) — a 54-ФЗ fiscal receipt through **«Чеки от ЮKassa»**: YooKassa owns the
cash register, the fiscal drive and the OFD contract, and files with the tax authority. Unlike the
retired rail's cabinet-side receipts, it registers one **only if the request carries it** (D51), so
every payment and every refund sends a `receipt`: one line (the pack title, quantity 1, the amount)
with the VAT rate code (54-ФЗ tag 1199, a deploy variable — `1` = «Без НДС» for УСН/ПСН), the
settlement subject `service` (tag 1212) and the settlement method `full_payment` (tag 1214).
Delivery is by email only, to the D36 confirmed anchor. `tax_system_code` is not sent — YooKassa
ignores it for this solution. A malformed receipt is an API error at payment creation, so it blocks
the purchase loudly rather than silently skipping the fiscal document.
- **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).
@@ -366,7 +415,7 @@ confirms the exact НПД scheme with a tax advisor.)
## 13. Distribution (native Android)
- **RuStore** — Robokassa/external gate allowed (0%); native = clean `direct` context.
- **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.
@@ -407,4 +456,4 @@ prod (no purchase flow existed), so legacy values are zeroed.
- **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).
- **rail** — a payment provider (YooKassa / VK Votes / TG Stars).