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
+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 — «слоями»)
Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка