feat(payments): reverse refunds issued outside the console
CI / changes (pull_request) Successful in 11s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 29s
CI / ui (pull_request) Successful in 1m27s
CI / conformance (pull_request) Successful in 19s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m52s
CI / changes (pull_request) Successful in 11s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 29s
CI / ui (pull_request) Successful in 1m27s
CI / conformance (pull_request) Successful in 19s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m52s
Two holes on the refund path, both found by asking what happens when a refund does not come from our own `/_gm` button. The merchant cabinet is a second entry point. An operator can refund there, and such a refund never passes through our API — so the money went back while the chips stayed credited, silently. Handle `refund.succeeded`: the refund is re-read from the API (the notification body is no more evidence here than it is for a payment), bound back to its order through the payment id recorded when the payment was minted, and reversed through the same engine. It is idempotent on (provider, refund id), so the event for a refund the console already recorded reverses nothing twice. The reversal engine is full-refund-only by design — it revokes exactly what the pack funded and rejects any other amount — so a partial refund is recorded as nothing at all and logged loudly for an operator. There is no non-arbitrary way to decide how many chips a part-refund costs, and guessing would be worse than asking a human. Second hole: a refund can still be canceled while pending, and the ledger is append-only. Recording on any non-empty refund id therefore risked revoking a customer's chips for money that stayed with us, with no way to take the row back. The console now records only a `succeeded` refund and tells the operator to press again otherwise — the idempotency key returns the same refund rather than paying twice. Tests: unit (GetRefund, the refund notification envelope, a non-final status surfaced to the caller); integration (a cabinet refund is reversed once and a redelivery is a no-op, the event after a console refund changes nothing, a partial refund records nothing, an unconfirmed refund reverses nothing, a pending refund records nothing until it settles and then does). The suite shares one database and the ledger dedupes refunds globally, so the fake provider now mints a refund id per payment — a constant id made one test's refund look like another's duplicate. Decisions D50 (amended) and D52; the notification subscription list in the deploy docs gains refund.succeeded.
This commit is contained in:
+22
-8
@@ -303,14 +303,28 @@ 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. 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 —
|
||||
**admin-triggered** (the E7 console). 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. A refund is recorded **only in status `succeeded`**: one still `pending` can yet be
|
||||
canceled, and the ledger is append-only, so recording early could revoke a customer's chips for money
|
||||
that stayed with us. Pressing again is the way out — the idempotency key returns the same refund
|
||||
rather than paying twice. The recorded refund id is the provider's own, which keeps the ledger
|
||||
reconcilable against YooKassa's records.
|
||||
|
||||
**The cabinet is a second entry point (D52).** Unlike the earlier rails, YooKassa lets an operator
|
||||
refund from the merchant cabinet, and such a refund never passes through our API — so the money would
|
||||
go back while the chips stayed credited. The `refund.succeeded` notification closes that: the refund
|
||||
is re-read from the API (the body is no more evidence than a payment notification's), bound back to
|
||||
the order through the payment id we recorded, and reversed through the same engine — idempotent on
|
||||
`(provider, refund id)`, so the notification for a refund the console already recorded reverses
|
||||
nothing twice. The engine is **full-refund-only** by design (it revokes exactly what the pack funded
|
||||
and rejects any other amount), so a **partial** refund is recorded as nothing at all and logged
|
||||
loudly for an operator: there is no non-arbitrary way to decide how many chips a part-refund costs.
|
||||
|
||||
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
|
||||
|
||||
@@ -309,8 +309,20 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
||||
- **D50. Возвраты на direct-рельсе — через API ЮKassa, одним действием.** Кнопка возврата в `/_gm`
|
||||
сперва двигает деньги (`POST /v3/refunds`, `Idempotence-Key` = идентификатор заказа, с чеком
|
||||
возврата) и только потом пишет реверс в журнал; неудачный вызов не пишет **ничего** — журнал не
|
||||
может заявить о возврате, которого не было. Записывается собственный refund-id провайдера (сверка с
|
||||
может заявить о возврате, которого не было. Реверс пишется **только при статусе `succeeded`**:
|
||||
возврат в `pending` ещё может отмениться, а журнал только на добавление, поэтому ранняя запись
|
||||
отобрала бы у покупателя Фишки за деньги, оставшиеся у нас; выход — нажать ещё раз, ключ
|
||||
идемпотентности вернёт тот же возврат. Записывается собственный refund-id провайдера (сверка с
|
||||
данными ЮKassa). VK и TG Stars — как раньше: деньги руками, запись фактом.
|
||||
- **D52. Обрабатываем `refund.succeeded`: кабинет — вторая точка входа для возврата.** В отличие от
|
||||
прежних рельсов, у ЮKassa возврат можно оформить прямо в кабинете магазина, минуя наш API, — тогда
|
||||
деньги ушли бы назад, а Фишки остались бы начисленными, и молча. Поэтому подписываемся на событие и
|
||||
проводим возврат тем же движком: возврат перечитывается из API (тело уведомления — не доказательство,
|
||||
как и у платежа), привязывается к заказу через записанный идентификатор платежа, идемпотентность по
|
||||
`(провайдер, refund-id)` делает уведомление о возврате, уже записанном консолью, пустой операцией.
|
||||
**Частичный возврат не записываем вовсе** — движок по устройству работает только с полной суммой
|
||||
заказа, а непроизвольного способа решить, скольких Фишек стоит часть возврата, нет; вместо записи —
|
||||
громкий лог для оператора.
|
||||
- **D51. Фискализация — «Чеки от ЮKassa», чек отправляем из кода.** Ревизия D41: у ЮKassa нет
|
||||
кабинетного «обобщённого чека», как у Robokassa, — чек регистрируется **только если запрос его
|
||||
несёт**, поэтому itemized-код, от которого отказались в D41, возвращается в объём. Каждый платёж и
|
||||
@@ -336,7 +348,7 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
||||
ведёт к пропускам. Текст — только для фиксации решённого и пояснений. Усилить
|
||||
feedback-память `prefer-interview-mode` после plan mode.
|
||||
|
||||
## Все развилки закрыты (D1-D51)
|
||||
## Все развилки закрыты (D1-D52)
|
||||
|
||||
Интервью завершено. Дальше — оформление документов и реализация по релизам.
|
||||
|
||||
@@ -355,6 +367,10 @@ D47-D51 — консервация Robokassa с откатом по кредам
|
||||
у ЮKassa **есть** тестовый режим (отдельный тестовый магазин со своими креды и тестовыми картами),
|
||||
поэтому весь путь проверяется на тестовом контуре без реальных денег.
|
||||
|
||||
**Уточнение по возвратам (владелец, 2026-07-28).** D50 дополнена требованием статуса `succeeded`;
|
||||
добавлена D52 — обработка `refund.succeeded`, потому что кабинет ЮKassa позволяет вернуть деньги
|
||||
мимо нашего API. Обе правки вошли в тот же PR, что и переход на рельс.
|
||||
|
||||
## План внедрения (черновик PLAN.md — «слоями»)
|
||||
|
||||
Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка
|
||||
|
||||
+22
-7
@@ -298,13 +298,28 @@ at-least-once + идемпотентный приём (дедуп по `telegram
|
||||
бота).
|
||||
|
||||
**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Возвраты **инициирует
|
||||
админ** (консоль E7): ни один рельс не шлёт непрошеный возврат. На direct-рельсе консоль делает
|
||||
всю работу одним нажатием (D50): сперва вызывает refund-API ЮKassa (`POST /v3/refunds`,
|
||||
`Idempotence-Key` — идентификатор заказа, с чеком возврата из §12) и записывает реверс только после
|
||||
того, как деньги действительно ушли, — неудачный вызов не пишет **ничего**, поэтому журнал не может
|
||||
заявить о возврате, которого не было. Записывается собственный refund-id провайдера: по нему журнал
|
||||
сверяется с данными ЮKassa. Возвраты VK по-прежнему через поддержку, TG Stars — вызовом
|
||||
`refundStarPayment`, оба фиксируются руками после факта. Все сходятся на одном движке — метод `Refund`
|
||||
админ** (консоль E7). На direct-рельсе консоль делает всю работу одним нажатием (D50): сперва
|
||||
вызывает refund-API ЮKassa (`POST /v3/refunds`, `Idempotence-Key` — идентификатор заказа, с чеком
|
||||
возврата из §12) и записывает реверс только после того, как деньги действительно ушли, — неудачный
|
||||
вызов не пишет **ничего**, поэтому журнал не может заявить о возврате, которого не было. Возврат
|
||||
записывается **только в статусе `succeeded`**: ещё не завершённый (`pending`) может отмениться, а
|
||||
журнал только на добавление, поэтому ранняя запись отобрала бы у покупателя Фишки за деньги, оставшиеся у
|
||||
нас. Выход — нажать ещё раз: ключ идемпотентности вернёт тот же возврат, а не заплатит дважды.
|
||||
Записывается собственный refund-id провайдера: по нему журнал сверяется с данными ЮKassa.
|
||||
|
||||
**Кабинет — вторая точка входа (D52).** В отличие от прежних рельсов, ЮKassa позволяет оформить
|
||||
возврат прямо в кабинете магазина, и такой возврат не проходит через наш API — деньги ушли бы назад,
|
||||
а Фишки остались бы начисленными. Это закрывает уведомление `refund.succeeded`: возврат
|
||||
перечитывается из API (тело уведомления — такое же не-доказательство, как и у платежа), привязывается
|
||||
к заказу через идентификатор платежа, который мы записали, и проводится тем же движком —
|
||||
идемпотентно по `(провайдер, refund-id)`, поэтому уведомление о возврате, уже записанном консолью,
|
||||
ничего не отзывает повторно. Движок по устройству работает **только с полным возвратом** (отзывает
|
||||
ровно то, что профондировал пакет, и отвергает любую другую сумму), поэтому **частичный** возврат не
|
||||
записывается вовсе и громко логируется для оператора: непроизвольного способа решить, скольких Фишек
|
||||
стоит часть возврата, нет.
|
||||
|
||||
Возвраты VK по-прежнему через поддержку, TG Stars — вызовом `refundStarPayment`, оба фиксируются
|
||||
руками после факта. Все сходятся на одном движке — метод `Refund`
|
||||
(`internal/payments`): матчит оплаченный заказ, пишет **refund**-строку журнала (идемпотентно по
|
||||
`(provider, provider_refund_id)` — refund-id отличается от payment-id fund'а, поэтому строки
|
||||
сосуществуют под тем же partial-unique индексом) и **по возможности отзывает начисленные Фишки с
|
||||
|
||||
Reference in New Issue
Block a user