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 @@
Валюта **двухуровневая**:
```
деньги (Голоса 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).