# API возвратов — полный и частичный возврат

> Справочник по методу POST /invoices/{id}/refund: поля запроса, полный и частичный возврат, ограничение суммы, все коды ошибок, что делать при refund_unknown и какие события приходят после возврата.

## Коротко

Возврат денег — один метод:

```http
POST /api/v1/invoices/{id}/refund
X-API-Key: qp_live_…
Content-Type: application/json

{ "amount": 1500, "reason": "Товар возвращён" }
```

Без поля `amount` счёт возвращается **полностью**. С `amount` возвращается только указанная сумма, а счёт переходит в статус `partially_refunded`.

Два правила, которые стоит запомнить сразу: ключу нужно право **`refunds:write`**, а при ошибках `refund_unknown` и `refund_pending_unknown` **запрос повторять нельзя** — вместо повтора вы читаете статус счёта.

## Поля запроса

| Поле | Тип | Обязательное | Описание |
|---|---|---|---|
| `amount` | number | нет | Сумма возврата в тенге. Без неё — полный возврат |
| `reason` | string | нет | Причина. Видна в отчёте и в кабинете |

В пути `{id}` — идентификатор счёта (`inv_…`). Возврата по `externalOrderId` нет: сначала найдите счёт через `GET /api/v1/invoices?externalOrderId=…`, затем используйте его `id`.

## По какому счёту возможен возврат

| Статус счёта | Возврат |
|---|---|
| `new`, `pending` | Нет. Деньги ещё не пришли — счёт нужно **отменить**: `POST /invoices/{id}/cancel` |
| `paid` | Да, полный и частичный |
| `partially_refunded` | Да, в пределах остатка |
| `refunded` | Нет, возвращено всё |
| `cancelled`, `expired` | Нет |

Деньги уходят **не с нашего счёта, а с вашего счёта Kaspi**. Мы деньги никогда не держим, поэтому сам возврат выполняет Kaspi. Если остатка не хватает, операция не пройдёт.

## Полный и частичный возврат

**Полный возврат** — запрос с пустым телом или только с `reason`:

```bash
curl -X POST https://api.qut.kz/api/v1/invoices/inv_7Kd2/refund \
  -H "X-API-Key: $QUTPAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"reason":"Клиент отказался"}'
```

**Частичный возврат** — запрос с `amount`. Частичных возвратов может быть несколько, но правило жёсткое:

> Сумма всех возвратов не может превышать оплаченную сумму.

Например, по счёту на 10 000 ₸ вы вернули 3 000 и 4 000 — третий раз можно вернуть не больше 3 000. Если запросить больше, придёт `invalid_refund_amount`. Когда вернёте последний остаток, счёт перейдёт в статус `refunded`.

Перед очередным возвратом полезно прочитать текущий остаток через `GET /api/v1/invoices/{id}`: в ответе есть список уже сделанных возвратов по этому счёту.

## Коды ошибок

| Код | HTTP | Что случилось | Что делать |
|---|---|---|---|
| `invoice_not_refundable` | 409 | По этому счёту возврат невозможен | Прочитайте статус: оплачен ли, не истёк ли срок |
| `invalid_refund_amount` | 422 | Неверная сумма | Положительное число, не больше оплаченной суммы |
| `refund_failed` | 502 | Kaspi не выполнил возврат | Проверьте остаток на счёте Kaspi, повтор допустим |
| `refund_unknown` | 502 | Kaspi не ответил, результат неизвестен | **Не повторять.** Прочитайте статус счёта |
| `refund_pending_unknown` | 409 | Результат предыдущего возврата ещё не ясен | **Не повторять.** Подождите |
| `refund_state_conflict` | 409 | Состояние возврата не то, которого ждали | Перечитайте статус и решайте по нему |
| `insufficient_scope` | 403 | У ключа нет права `refunds:write` | Добавьте право в кабинете |
| `invoice_not_found` | 404 | Счёта нет или он не виден ключу | Проверьте идентификатор и кассира, к которому привязан ключ |
| `kaspi_session_expired` | 409 | Привязка кассира оборвалась | Переподключите кассира |

Обрабатывайте ошибки по коду `error`, а не по тексту `message`: текст может измениться, код — нет.

## Когда результат неизвестен

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

В обоих случаях порядок одинаковый:

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

И не говорите покупателю «деньги вернули», пока не увидели это в статусе счёта.

```js
async function refundOnce(id, amount) {
  const r = await fetch(`${API}/invoices/${id}/refund`, {
    method: 'POST',
    headers: { 'X-API-Key': KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({ amount, reason: 'Возврат' }),
  });
  if (r.ok) return 'done';

  const { error } = await r.json();
  if (error === 'refund_unknown' || error === 'refund_pending_unknown') {
    // не повторяем — читаем статус и решаем по нему
    return 'check_status';
  }
  if (error === 'refund_failed') return 'retry_later';
  throw new Error(error);
}
```

## События

После возврата приходит вебхук:

| Событие | Когда |
|---|---|
| `invoice.refunded` | Счёт возвращён полностью |
| `invoice.partially_refunded` | Возвращена часть суммы |
| `refund.done` | Отдельная операция возврата выполнена |
| `refund.failed` | Возврат не выполнен |
| `refund.unknown` | Результат остался неизвестным |

Если ведёте автоматический учёт, опираться на эти события удобнее, чем опрашивать статус. Обработчик вебхуков делайте идемпотентным: два уведомления об одном возврате не должны превратиться в две проводки в бухгалтерии.

## Права и кассир

- Ключу нужно право **`refunds:write`**, иначе придёт `insufficient_scope`. Подробнее: [Права доступа (scopes)](/kb/ru/scopes).
- Возврат тоже идёт через роль «Кассир» в Kaspi, поэтому привязка кассира должна быть активной. Если она оборвалась, не пройдёт ни возврат, ни создание счёта.
- Если ключ привязан к конкретному кассиру, чужие счета он не видит — на попытку возврата по такому счёту придёт `invoice_not_found`.

## Срок

Возврат доступен не бессрочно: спустя некоторое время Kaspi перестаёт разрешать возврат по старой операции. Срок задаёт **Kaspi**, продлить его мы не можем. По просроченному счёту приходит `invoice_not_refundable`.

В песочнице весь цикл можно безопасно прогнать: создайте счёт, симулируйте оплату, затем отправьте возврат.

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

**Можно ли отменить возврат?** Нет. Прошедший возврат обратно не откатывается — придётся просить покупателя оплатить заново.

**Возвращается ли комиссия?** Свою комиссию Kaspi определяет сам, уточняйте в поддержке Kaspi. Мы процент с транзакции не берём, поэтому с нашей стороны возвращать нечего.

**Когда деньги дойдут до покупателя?** Это определяет Kaspi, на сроки мы повлиять не можем.

**Можно ли сделать возврат из кабинета?** Да, в разделе «Счета» откройте счёт и нажмите кнопку возврата. API и кабинет выполняют одну и ту же операцию.

**Где полный список кодов ошибок?** В статье [Каталог ошибок](/kb/ru/error-catalog). Разбор ситуации, когда возврат не проходит: [Возврат не проходит](/kb/ru/refund-not-working).
