feat(payments): refund engine (best-effort revoke, never negative)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s

The last money-intake slice: reverse a paid order best-effort, exactly once. All
refunds are admin-triggered (E7) — no rail pushes an unsolicited refund (Robokassa
via its refund API / cabinet, VK via support, Telegram via refundStarPayment), so
this ships the engine they all converge on, not a webhook.

The Refund method matches the paid order, appends a refund ledger row (idempotent on
(provider, provider_refund_id) — distinct from the fund's payment id, so both
coexist), and revokes the funded chips floored at 0 (never negative — D27,
balances_chips_chk). When the chips were already spent, the unrecoverable remainder
is recorded as a per-account loss + abuse flag in the new additive
payments.account_risk table (read by the E7 report). The refund ledger row's chip
delta is what was actually reclaimed (the ledger stays reconcilable); the full
reversal rides in the snapshot; the order stays paid.

Additive migration (a new table only) -> rollback-safe, no contour wipe. Robokassa
refund-status polling is deferred (a worker not worth it at low chargeback volume);
failed events are not wired (no rail signals a hard post-charge server decline).

Tests: integration (full revoke; revoke-after-spend = floor-0 + loss + abuse;
duplicate idempotent; unpaid-order guard). Docs: PAYMENTS(+ru) §9, PLAN (E5 -> DONE).
This commit is contained in:
Ilia Denisov
2026-07-09 23:21:10 +02:00
parent ee9924c313
commit 21fb14facf
10 changed files with 461 additions and 23 deletions
+16 -8
View File
@@ -241,14 +241,22 @@ existing `botlink` push / email relay. "Payment failed" (an **active** provider
an abandoned pending) is surfaced to the user; "payment succeeded" is a hook (email / bot
message).
**Refunds.** ToS is **non-refundable** — we do not offer refunds to the user. An admin may
issue a **manual** refund (edge case: a user demands one shortly after paying / closes their
account — tie into `accountdelete`, which already preserves messages). **External** refunds
(chargeback / store decision / TG / VK) are honoured: the system takes the `refunded` event,
**best-effort** revokes the benefit (never going negative; if chips were already spent, it
records the loss + an abuse flag), and writes to the ledger. The ledger is **export-ready**
for future tax reporting and Robokassa reconciliation (reconciliation itself is not built
yet; the schema stays compatible).
**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 —
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
the funded chips floored at 0** (never negative — D27, `balances_chips_chk`). When the chips were
already spent, the unrecoverable remainder is recorded as a per-account **loss + abuse flag**
(`payments.account_risk`, read by the E7 report). The refund ledger row's chip delta is what was
actually reclaimed, so the ledger stays reconcilable against the balance; the **full** reversal
(money, original chips, loss) rides in the row's snapshot. The order stays `paid` — the refund lives
in the ledger + a `refunded` payment event, not in the order status. A duplicate refund reverses
nothing. The ledger is **export-ready** for future tax reporting and reconciliation (the reconciler
itself is not built yet; the schema stays compatible).
## 10. Ads
+14 -8
View File
@@ -243,14 +243,20 @@ at-least-once + идемпотентный приём (дедуп по `telegram
брошенный pending) доводится до пользователя; «оплата прошла» — хук (письмо / сообщение в
бота).
**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Админ может сделать
**ручной** возврат (крайний случай: пользователь требует вскоре после оплаты / закрывает
аккаунт — связано с `accountdelete`, где уже сохраняются сообщения). **Внешние** возвраты
(чарджбек / решение стора / TG / VK) обрабатываются: система принимает событие `refunded`,
**по возможности** отзывает бенефит (в минус не уходим; если Фишки уже потрачены —
фиксирует убыток + флаг защиты от злоупотреблений), пишет в журнал. Журнал операций
**спроектирован экспортопригодным** для будущей налоговой отчётности и сверки с Robokassa
(саму сверку пока не строим; схема остаётся совместимой).
**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Возвраты **инициирует
админ** (консоль E7): ни один рельс не шлёт непрошеный возврат — Robokassa через refund-API / ЛК
(авто-опрос статуса — отложенный воркер, при низком объёме чарджбеков не оправдан), VK — через
поддержку, TG Stars — вызовом `refundStarPayment`. Все сходятся на одном движке — метод `Refund`
(`internal/payments`): матчит оплаченный заказ, пишет **refund**-строку журнала (идемпотентно по
`(provider, provider_refund_id)` — refund-id отличается от payment-id fund'а, поэтому строки
сосуществуют под тем же partial-unique индексом) и **по возможности отзывает начисленные Фишки с
полом 0** (в минус не уходим — D27, `balances_chips_chk`). Если Фишки уже потрачены, невозвратный
остаток фиксируется как **убыток + флаг злоупотребления** per-account (`payments.account_risk`, читает
отчёт E7). Дельта Фишек в refund-строке — то, что реально отозвано, поэтому журнал остаётся сверяемым
с балансом; **полный** реверс (деньги, исходные Фишки, убыток) лежит в snapshot строки. Заказ остаётся
`paid` — возврат живёт в журнале + событии `refunded`, не в статусе заказа. Повторный возврат не
отзывает ничего. Журнал операций **спроектирован экспортопригодным** для будущей налоговой отчётности
и сверки (саму сверку пока не строим; схема остаётся совместимой).
## 10. Реклама