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
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:
+72
-23
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user