# Как не вернуть деньги дважды

> При ответах refund_unknown и refund_pending_unknown повтор запроса — кратчайший путь к двойному возврату. Вместо этого нужно прочитать статус счёта. Идемпотентность, учёт частичных возвратов и журнал.

## Коротко

Если запрос на возврат вернул `refund_unknown` (502) или `refund_pending_unknown` (409), это не значит «возврат не прошёл» — это значит **результат пока неизвестен**. Если в этот момент повторить запрос, а первый возврат на самом деле прошёл, покупатель получит деньги дважды. Правильное действие одно: **не повторяйте, прочитайте статус счёта через `GET /api/v1/invoices/{id}` и узнайте результат оттуда.**

## Почему вообще бывает ответ «неизвестно»

Возврат проходит через несколько систем. Запрос может дойти до Kaspi, тот может его выполнить, а ответ до нас не дойти. В такой момент мы отвечаем вам честно: результат неизвестен.

| Код | HTTP | Что это значит |
|---|---|---|
| `refund_unknown` | 502 | Kaspi не ответил, прошёл возврат или нет — неизвестно |
| `refund_pending_unknown` | 409 | Судьба предыдущего возврата ещё не ясна, новый не принимаем |
| `refund_failed` | 502 | Kaspi возврат не выполнил. Это как раз «не прошёл» |
| `refund_state_conflict` | 409 | Состояние возврата отличается от ожидаемого, перечитайте статус |

Разница между `refund_failed` и `refund_unknown` — суть всей этой статьи. Первое — определённая неудача, её можно повторить. Второе — неопределённость, и повторять её **нельзя**.

`refund_pending_unknown` — это наша защита: пока судьба предыдущего возврата не выяснена, новый мы не пропускаем. Увидев его, считайте, что система прямо сейчас уберегла вас от ошибки.

## Правильный порядок действий

1. **Не повторяйте запрос.** Если у вас есть автоматический ретрай, уберите из него возвраты.
2. **Подождите** несколько секунд.
3. **Отправьте `GET /api/v1/invoices/{id}`.** В ответе будет статус счёта и список возвратов по нему.
4. **Решайте по статусу:**

| Статус счёта | Что это значит | Что делать |
|---|---|---|
| `refunded` | Возвращён полностью | Всё, готово. Не повторяйте |
| `partially_refunded` | Часть возвращена | Посчитайте возвращённую сумму и досылайте только разницу, если её не хватает |
| `paid` | Возврат не прошёл | Только теперь можно повторить |

5. **Запишите результат в свой журнал**, чтобы в следующий раз не выяснять заново.

Если даже после нескольких проверок статус не проясняется, подождите и напишите в поддержку. Мысль «попробую повторить всего один разок» именно здесь самая опасная.

## Узнать результат можно и через вебхук

Результат операции приходит и событиями: `refund.done`, `refund.failed`, `refund.unknown`, а при смене статуса счёта — `invoice.refunded` или `invoice.partially_refunded`.

То есть после `refund_unknown` у вас два пути: спросить статус самому или дождаться события. Надёжнее вести оба канала, но **результат обоих обрабатывать в одном месте и идемпотентно.**

## Идемпотентность

Главная защита от двойного возврата — гарантия неповторяемости на вашей стороне.

- **Одна «задача на возврат» на один счёт.** Храните её записью в своей базе: `id счёта`, `сумма`, `состояние` (отправлено / подтверждено / неизвестно / не прошло).
- **Блокируйте запись.** Вторая задача на возврат по тому же счёту создаваться не должна. Два параллельных процесса не должны одновременно отправлять возврат по одному счёту.
- **Если задачи из очереди повторяются автоматически, пометьте состояние «неизвестно» как неповторяемое.** Такая задача должна уходить на проверку, а не на повтор.
- **Обработка вебхуков тоже должна быть идемпотентной.** Если пара `(id счёта, статус)` уже обработана, не делайте ничего. При ответе, отличном от 2xx, доставка повторяется **до 11 раз**, так что одно событие вполне может прийти к вам несколько раз.
- **Ручные возвраты тоже фиксируйте в журнале.** Если возврат сделали руками из кабинета, а потом ваша система отправила свой, итог будет двойным.

## Учёт частичных возвратов

В частичных возвратах сбиться со счёта легче всего. Правило простое: **сумма всех возвратов не может превышать оплаченную сумму.** Если отправите больше, получите `invalid_refund_amount` (422) — это хорошо, но полагаться на это не стоит, считайте сами.

Пример: счёт на 10 000 ₸ оплачен. Вы вернули 3 000 ₸ — счёт становится `partially_refunded`, остаток 7 000 ₸. Если отправить ещё 3 000 ₸, это будет **не повтор первого возврата, а новый возврат**: всего вернётся 6 000 ₸. Поэтому мысль «просто отправлю ещё раз» в частичных возвратах опаснее всего.

Считайте всегда по списку возвратов из ответа `GET /api/v1/invoices/{id}`, а не по своим предположениям.

## Что должен содержать журнал

Именно он спасает в спорной ситуации.

- Время отправки запроса на возврат, id счёта, сумма
- HTTP-статус ответа и код в поле `error`
- Последующий ответ `GET /invoices/{id}`: статус и список возвратов
- Пришедшие события вебхука вместе с заголовком `X-Webhook-Delivery`
- Кто инициировал возврат: система или человек вручную

**Сам ключ в журнал не пишите.** При логировании заголовков вырезайте значение `X-API-Key`.

## Вопросы и ответы

**Пришёл `refund_unknown`, а статус счёта `paid`. Можно повторять?** Да. Если статус `paid`, возврат не прошёл, повторять можно.

**Сколько держится `refund_pending_unknown`?** Пока не прояснится судьба предыдущего возврата. Подождите несколько секунд и перечитайте статус счёта.

**Если я всё-таки вернул дважды, можно отменить?** Нет, операции «отменить возврат» не существует. Придётся связаться с покупателем и выставить новый счёт на излишек.

**Есть ли ключ идемпотентности для возвратов?** При создании счёта есть заголовок `Idempotency-Key`. В возвратах защита устроена иначе: пока результат предыдущего возврата неизвестен, новый не принимается (`refund_pending_unknown`). Собственный журнал и блокировка на вашей стороне всё равно обязательны.

**Откуда берутся деньги на возврат?** С вашего счёта Kaspi. У нас деньги не хранятся: [Когда и куда приходят деньги](/kb/ru/money-arrival).
