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).
+49 -1
View File
@@ -281,6 +281,46 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
(нет строки = default; снять = удалить строку). **`allow` обходит ТОЛЬКО ops-рубильник, НЕ
security-гейты** (D36 email-якорь, VK-iOS-фриз, trusted-платформа, min-client-version) — те
проверяются отдельно в CreateOrder.
- **D47. Direct-рельс переезжает с Robokassa на ЮKassa; Robokassa консервируется, а не удаляется.**
Меняется merchant-отношение, не модель кошельков: сегмент `direct`, стенка трат, origin бенефитов,
мультимагазинность по каналу (D42) и `shop` в заказе (D44) — без изменений. Код Robokassa, её тесты
и проводка остаются в дереве, а direct-рельс **откатывается на неё**, если ни один магазин ЮKassa не
настроен, — значит возврат к Robokassa это смена кредов, а не правка кода. Переменные Robokassa
убраны из CI/compose/деплоя и записаны в `backend/internal/robokassa/README.md` вместе с
настройками кабинета и порядком возврата. Строки журнала с `provider = 'robokassa'` не трогаем:
литерал несущий для индекса идемпотентности. Telegram **не трогаем** — там реальными деньгами за
цифровые товары платить нельзя, рельс остаётся на Stars; префиксы переменных заводим на магазин
сразу, чтобы будущий магазин (например, android/RuStore) добавлялся аддитивно.
- **D48. Уведомления ЮKassa не подписаны → подтверждающее чтение обязательно.** В отличие от
Robokassa (подпись Password2) и VK (подпись), ЮKassa **не подписывает** вебхуки. Поэтому тело
уведомления — только подсказка: оно называет платёж, который сервер перечитывает запросом
`GET /v3/payments/{id}`, и действует **исключительно** по ответу API. Дополнительно: метаданные
платежа должны называть тот самый заказ; флаг `test` платежа должен совпадать с флагом магазина
(платёж тестового магазина не начислит настоящие Фишки, и наоборот); адрес отправителя сверяется с
опубликованными диапазонами ЮKassa — эшелонированная защита, которая не даёт превратить каждое
фальшивое уведомление в наш исходящий запрос. Ответ провайдеру: 200 на всё окончательно решённое
(включая дубль и неустранимый отказ), 5xx только на временный сбой (ЮKassa повторяет 24 часа).
- **D49. Сверка — одна проверка на истечении заказа, без постоянного опроса.** «Или» в документации
ЮKassa адресовано тем, кто не хочет вебхуки; у нас вебхуки основные. Но безвозвратно потерянное
уведомление оставило бы деньги списанными, а Фишки — не выданными, и молча. Поэтому существующий
жнец просроченных заказов перед списанием в `expired` спрашивает провайдера о судьбе каждого заказа,
дожившего до срока с идентификатором платежа, и начисляет реально оплаченные. Один запрос на заказ
за всю его жизнь; отдельный воркер не заводим.
- **D50. Возвраты на direct-рельсе — через API ЮKassa, одним действием.** Кнопка возврата в `/_gm`
сперва двигает деньги (`POST /v3/refunds`, `Idempotence-Key` = идентификатор заказа, с чеком
возврата) и только потом пишет реверс в журнал; неудачный вызов не пишет **ничего** — журнал не
может заявить о возврате, которого не было. Записывается собственный refund-id провайдера (сверка с
данными ЮKassa). VK и TG Stars — как раньше: деньги руками, запись фактом.
- **D51. Фискализация — «Чеки от ЮKassa», чек отправляем из кода.** Ревизия D41: у ЮKassa нет
кабинетного «обобщённого чека», как у Robokassa, — чек регистрируется **только если запрос его
несёт**, поэтому itemized-код, от которого отказались в D41, возвращается в объём. Каждый платёж и
возврат шлют `receipt`: одна позиция (название пакета, количество 1, сумма), код ставки НДС
(тег 1199), признак предмета расчёта `service` (тег 1212), признак способа расчёта `full_payment`
(тег 1214); доставка только на email — на подтверждённый якорь D36. Код ставки НДС — **переменная
деплоя** (дефолт `1` = «Без НДС» для УСН/ПСН): это единственный реквизит, который реально меняется
(с 1 января 2026 ставки выросли, появились коды 11/12), и менять его без релиза нужно; признаки
предмета и способа расчёта — константы в коде. `tax_system_code` не шлём: для «Чеков от ЮKassa»
провайдер его игнорирует.
## Заметки к оформлению документов
@@ -296,7 +336,7 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
ведёт к пропускам. Текст — только для фиксации решённого и пояснений. Усилить
feedback-память `prefer-interview-mode` после plan mode.
## Все развилки закрыты (D1-D46)
## Все развилки закрыты (D1-D51)
Интервью завершено. Дальше — оформление документов и реализация по релизам.
@@ -307,6 +347,14 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
рубильник платежей per-rail + локализованное сообщение + per-user override (этап E11). Фискализация
(B4) — кабинетная на стороне Robokassa, itemized-код не делаем (решение владельца).
**Дополнение 2026-07-28 (интервью с владельцем).** Direct-рельс переезжает на **ЮKassa**; добавлены
D47-D51 — консервация Robokassa с откатом по кредам, подтверждающее чтение вместо подписи уведомления,
сверка на истечении заказа, возвраты через API и фискализация через «Чеки от ЮKassa» с отправкой
`receipt` из кода (ревизия D41, этап E12 в `PLAN.md`). Telegram из объёма исключён: реальными
деньгами за цифровые товары в Mini App платить нельзя, рельс остаётся на Stars. Отдельно уточнено:
у ЮKassa **есть** тестовый режим (отдельный тестовый магазин со своими креды и тестовыми картами),
поэтому весь путь проверяется на тестовом контуре без реальных денег.
## План внедрения (черновик PLAN.md — «слоями»)
Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка
+72 -23
View File
@@ -23,7 +23,7 @@
Валюта **двухуровневая**:
```
деньги (Голоса VK / Stars TG / рубли через Robokassa) ─┐
деньги (Голоса VK / Stars TG / рубли через ЮKassa) ─┐
├─► Фишки ──► ценности
просмотр ролика за награду ─┘ (без рекламы,
подсказки,
@@ -46,7 +46,7 @@
|------------|------------------------------------|
| `vk` | Покупка за Голоса VK, ролик за награду в VK |
| `telegram` | Покупка за Stars TG |
| `direct` | Покупка через Robokassa (web / native) |
| `direct` | Покупка через ЮKassa (web / native) |
Один аккаунт держит все три сегмента одновременно: `баланс = (account_id, source)`, до трёх
записей — запись материализуется лениво при первом пополнении сегмента, отсутствующий сегмент
@@ -54,17 +54,27 @@
Почему сегментировано, а не один общий баланс: правила сторов запрещают активировать
ценность, оплаченную мимо их кассы. Фишки, пополненные внутри VK (Голоса), тратятся только
в контексте VK; Stars — только внутри Telegram; Фишки от Robokassa (`direct`) — только вне
в контексте VK; Stars — только внутри Telegram; Фишки от ЮKassa (`direct`) — только вне
сторов. См. §4.
**Мультимагазинный direct-рельс (D42).** Рельс `direct` маршрутизирует в отдельный магазин
Robokassa **на канал**`web` и `android` (RuStore), позже `ios` — по трастовому подтипу
ЮKassa **на канал**`web` и `android` (RuStore), позже `ios` — по трастовому подтипу
`X-Platform`; каждый магазин зачисляет в единый кошелёк `direct` (отдельных кошельков на канал нет).
Это разделение merchant-аккаунтов только для учёта / чеков: заказ хранит свой `shop` (виден в
админ-отчёте), а Result-колбэк каждого магазина проверяется своим Password2 по
`/pay/robokassa/result/<channel>`. В standalone-приложениях (Android/iOS) вход только по email, поэтому
у direct-покупки всегда есть email-якорь D36 (D43). Фискализация (54-ФЗ через кабинет Robokassa под
одним ИП) — один источник независимо от числа магазинов (D41).
админ-отчёте). Точка приёма уведомлений у всех магазинов одна — `/pay/yookassa/notify`; входящее
уведомление привязывается к магазину по идентификатору магазина, который сообщает сам платёж
(`recipient.account_id`), с откатом на канал, записанный в заказе, и подтверждающее чтение (§9)
выполняется кредами именно этого магазина. В standalone-приложениях (Android/iOS) вход только по
email, поэтому у direct-покупки всегда есть email-якорь D36 (D43) — он же адрес доставки чека.
Фискализация (54-ФЗ под одним ИП) — один источник независимо от числа магазинов (D41).
**Robokassa законсервирована, но не удалена (D47).** До ЮKassa она обслуживала рельс `direct`. Код,
тесты и проводка остаются в дереве, и рельс откатывается на неё, если ни один магазин ЮKassa не
настроен, — поэтому возврат к ней это смена кредов, а не правка кода; сегодня эти креды не задаёт ни
один контур. `backend/internal/robokassa/README.md` хранит список выведенных переменных, настройки
кабинета и порядок возврата рельса. Строки журнала, записанные при её жизни, сохраняют
`provider = 'robokassa'` — этот литерал несущий для индекса идемпотентности, его не переименовывают и
не переиспользуют.
**Рубильник платежей (D45/D46).** Оператор выключает покупки на рельсе/канале (`direct:web` /
`direct:android` / `vk` / `telegram`) или для одного аккаунта, живьём из `/_gm`, и юзер на следующей
@@ -217,21 +227,48 @@ durable).
## 9. Приём платежей
**Только серверный колбэк провайдера.** Фишки начисляются лишь по **проверенному**
(подпись/HMAC) серверному колбэку — Robokassa webhook / TG `successful_payment` / VK
callback. Клиентское «я оплатил» игнорируется.
**Только серверный колбэк провайдера.** Фишки начисляются лишь по **проверенному** серверному
колбэку — уведомление ЮKassa / TG `successful_payment` / VK callback. Клиентское «я оплатил»
игнорируется. Что значит «проверенный», зависит от рельса: VK и Telegram подписывают свои колбэки,
а **ЮKassa — нет** (D48), см. подтверждающее чтение ниже.
**Единственный писатель.** Один платёжный домен — единственный, кто пишет в журнал операций.
Публичные вебхуки (Robokassa/VK) терминируются на краю (Caddy/gateway) и проксируются в
Публичные вебхуки (ЮKassa/VK) терминируются на краю (Caddy/gateway) и проксируются в
платёжный домен; TG `successful_payment` приходит боту, тот форвардит в платёжный домен.
Одно место начисляет и защищает от повторов.
**Флоу заказа.** Сервер заранее создаёт `order(pending)` с account / платформой / пакетом /
ожидаемой суммой / origin. `order_id` прокидывается провайдеру (Robokassa `InvId` / TG
`invoice_payload` / VK `item` — точную форму VK уточнить при интеграции). Колбэк матчится по
`order_id` (никогда по сумме, поэтому коллизии одинаковых сумм невозможны), сверяет сумму,
начисляет, помечает `paid`. **Защита от повторов:** дедуп по `(провайдер,
provider_payment_id)`.
ожидаемой суммой / origin. `order_id` прокидывается провайдеру (ЮKassa `metadata.order_id` / TG
`invoice_payload` / VK `item`). Колбэк матчится по `order_id` (никогда по сумме, поэтому коллизии
одинаковых сумм невозможны), сверяет сумму, начисляет, помечает `paid`. **Защита от повторов:**
дедуп по `(провайдер, provider_payment_id)`.
**Direct-рельс (ЮKassa).** Открытие покупки — исходящий вызов API: сервер создаёт платёж
(`POST /v3/payments`, `capture: true`, подтверждение через redirect, идентификатор заказа и как
`Idempotence-Key`, и как `metadata.order_id`, плюс фискальный чек из §12) и отправляет покупателя на
полученный `confirmation_url`. Идентификатор платежа сразу записывается в заказ — именно он позволяет
потом перепроверить заказ и оформить возврат. Страница возврата браузера (`/pay/yookassa/return`)
косметическая: начисление никогда не едет на редиректе.
**Уведомление — подсказка, а не доказательство (D48).** ЮKassa не подписывает уведомления, поэтому
действовать по содержимому тела нельзя. Оно лишь называет платёж, который сервер затем перечитывает
запросом `GET /v3/payments/{id}`; доверяем только этому ответу. На нём же держатся две проверки:
метаданные платежа должны называть тот самый заказ, а его флаг `test` — совпадать с флагом магазина,
поэтому платёж тестового магазина никогда не начислит настоящие Фишки (и наоборот). В качестве
эшелонированной защиты — и чтобы подделыватель не превращал каждое фальшивое уведомление в наш
исходящий запрос — адрес отправителя сперва сверяется с опубликованными диапазонами ЮKassa. Ответ
сообщает провайдеру, повторять ли доставку: 200 на всё, что решено окончательно, включая дубль и
неустранимый отказ, и 5xx только на временный сбой (ЮKassa повторяет доставку 24 часа).
**Сверка на истечении заказа (D49).** Постоянного опроса нет. Но безвозвратно потерянное уведомление
оставило бы деньги списанными, а Фишки — не выданными, и молча. Поэтому уже существующий жнец
просроченных заказов спрашивает у провайдера судьбу каждого заказа, который дожил до своего срока с
идентификатором платежа, и начисляет те, что на самом деле оплачены. Один запрос на заказ за всю его
жизнь.
**Об отклонённой оплате сообщаем.** `payment.canceled` от ЮKassa пишет событие `failed`, поэтому
покупатель узнаёт, что попытка не прошла, вместо разглядывания неменяющегося баланса. Просто
брошенный заказ не пишет ничего — он молча истекает.
**Pending невидим** пользователю; авто-истекает по таймауту (~30 мин, гигиена базы).
Валидный колбэк исполняется **всегда**, даже на истёкшем заказе (`expired` ≠ отмена —
@@ -261,9 +298,13 @@ at-least-once + идемпотентный приём (дедуп по `telegram
бота).
**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Возвраты **инициирует
админ** (консоль E7): ни один рельс не шлёт непрошеный возврат — Robokassa через refund-API / ЛК
(авто-опрос статуса — отложенный воркер, при низком объёме чарджбеков не оправдан), VK — через
поддержку, TG Stars — вызовом `refundStarPayment`. Все сходятся на одном движке — метод `Refund`
админ** (консоль E7): ни один рельс не шлёт непрошеный возврат. На direct-рельсе консоль делает
всю работу одним нажатием (D50): сперва вызывает refund-API ЮKassa (`POST /v3/refunds`,
`Idempotence-Key` — идентификатор заказа, с чеком возврата из §12) и записывает реверс только после
того, как деньги действительно ушли, — неудачный вызов не пишет **ничего**, поэтому журнал не может
заявить о возврате, которого не было. Записывается собственный refund-id провайдера: по нему журнал
сверяется с данными ЮKassa. Возвраты VK по-прежнему через поддержку, TG Stars — вызовом
`refundStarPayment`, оба фиксируются руками после факта. Все сходятся на одном движке — метод `Refund`
(`internal/payments`): матчит оплаченный заказ, пишет **refund**-строку журнала (идемпотентно по
`(provider, provider_refund_id)` — refund-id отличается от payment-id fund'а, поэтому строки
сосуществуют под тем же partial-unique индексом) и **по возможности отзывает начисленные Фишки с
@@ -349,7 +390,15 @@ in-process кэш сегментов и бенефитов по ключу-ак
Чеки формируются автоматически **на стороне провайдера** и отличаются по каналу:
- **Robokassa** (direct) — чек НПД самозанятого при оплате.
- **ЮKassa** (direct) — фискальный чек по 54-ФЗ через **«Чеки от ЮKassa»**: касса, фискальный
накопитель, договор с ОФД и отправка в налоговую — на стороне ЮKassa. В отличие от кабинетных чеков
прежнего рельса, чек регистрируется **только если запрос его несёт** (D51), поэтому каждый платёж и
каждый возврат отправляют `receipt`: одна позиция (название пакета, количество 1, сумма) с кодом
ставки НДС (тег 54-ФЗ 1199, переменная деплоя — `1` = «Без НДС» для УСН/ПСН), признаком предмета
расчёта `service` (тег 1212) и признаком способа расчёта `full_payment` (тег 1214). Доставка только
на email — на подтверждённый якорь D36. `tax_system_code` не передаём: для этого решения ЮKassa его
игнорирует. Некорректный чек — ошибка API при создании платежа, то есть покупка ломается громко, а
не тихо остаётся без фискального документа.
- **VK** — VK сам процессит Голоса через налоговую; делать нечего.
- **TG Stars** — налоговой стороны нет (для РФ-самозанятого Stars легально невыводимы = не
доход НПД; принимаем, чек не формируем).
@@ -359,7 +408,7 @@ in-process кэш сегментов и бенефитов по ключу-ак
## 13. Дистрибуция (native Android)
- **RuStore** — Robokassa/внешний гейт разрешён (0%); native = чистый контекст `direct`.
- **RuStore** — внешний платёжный гейт разрешён (0%); native = чистый контекст `direct`.
- **Google Play** — direct-покупки **скрыты**; «Кошелёк» показывает заглушку («установите
версию из RuStore для покупок»). Ролик за награду и трата уже накопленных Фишек работают.
Перед GP-релизом свериться с актуальными правилами Google по внутренней валюте.
@@ -403,4 +452,4 @@ legacy-значения обнуляются.
взнос).
- **гейт** — одностороннее правило комплаенса сторов (§4).
- **журнал операций (ledger)** — неизменяемая запись всех операций с деньгами/ценностями.
- **платёжный канал / рельса** — платёжный провайдер (Robokassa / Голоса VK / Stars TG).
- **платёжный канал / рельса** — платёжный провайдер (ЮKassa / Голоса VK / Stars TG).