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 @@ 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).
|
||||
|
||||
@@ -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
@@ -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