# Жизненный цикл счёта

> Все статусы счёта и переходы между ними, какое событие вебхука приходит в какой момент, какие статусы считаются открытыми и оплаченными, и как обработать поздно пришедшую оплату.

## Коротко

Счёт проходит такой путь:

```
new ──▶ pending ──┬──▶ paid ──┬──▶ refunded
                  │           └──▶ partially_refunded
                  ├──▶ cancelled
                  └──▶ expired
```

В коде различайте две группы: **открытые** счета — `new` и `pending`; считающиеся **оплаченными** — `paid`, `refunded`, `partially_refunded`. Возвращённый счёт остаётся оплаченным: деньги приходили, потом были возвращены.

Самый важный пограничный случай — **поздняя оплата**: деньги могут прийти уже по закрытому счёту.

## Таблица статусов

| Статус | Значение | Открыт | Считается оплаченным |
|---|---|---|---|
| `new` | Счёт создан, ещё регистрируется в Kaspi | Да | Нет |
| `pending` | Ждёт оплаты покупателем | Да | Нет |
| `paid` | Оплачен | Нет | Да |
| `cancelled` | Вы его отменили | Нет | Нет |
| `expired` | Истёк срок, оплаты не было | Нет | Нет |
| `refunded` | Возвращён полностью | Нет | Да |
| `partially_refunded` | Возвращён частично | Нет | Да |

При фильтрации списка и в отчётах опирайтесь именно на эти две группы, а не перечисляйте каждый статус по отдельности — тогда добавление нового статуса не сломает ваш код.

## Переходы

| Откуда | Куда | Что произошло |
|---|---|---|
| `new` | `pending` | Счёт зарегистрирован в Kaspi, покупатель может платить |
| `pending` | `paid` | Покупатель оплатил |
| `pending` | `cancelled` | Вы отправили `POST /invoices/{id}/cancel` |
| `pending` | `expired` | Прошёл `expiresAt`, никто не оплатил |
| `paid` | `refunded` | Возвращена вся сумма |
| `paid` | `partially_refunded` | Возвращена часть суммы |
| `cancelled` / `expired` | `paid` | **Поздняя оплата.** Деньги пришли с опозданием |

Обратных переходов нет: оплаченный счёт не станет снова `pending`, а истёкший сам собой не оживёт.

## Какое событие приходит когда

| Событие | Когда | Особенность тела |
|---|---|---|
| `invoice.created` | При создании счёта | `id`, `externalOrderId`, `amount` |
| `invoice.pending` | Когда счёт готов к оплате | — |
| `invoice.paid` | Когда покупатель оплатил | `paidAt`, `receiptUrl` |
| `invoice.cancelled` | При отмене | — |
| `invoice.expired` | По истечении срока | — |
| `invoice.failed` | Когда счёт не прошёл | — |
| `invoice.refunded` | При полном возврате | — |
| `invoice.partially_refunded` | При частичном возврате | — |
| `invoice.status` | Общее событие на любую смену статуса | Если нужен один канал на все переходы |
| `invoice.lost` | Если счёт потерян на стороне Kaspi | — |

Список событий выбирается в разделе **Интеграции** кабинета. Включать все не нужно: чаще всего достаточно `invoice.paid` и `invoice.refunded`. Настройка: [Настройка вебхуков](/kb/ru/webhook-setup).

## Тайминги

| Что | Сколько |
|---|---|
| Окно сканирования QR | Примерно 3 минуты, точное время в поле `expiresAt` |
| Цикл проверки оплаты | Каждые 3 секунды |
| Счёт моложе 3 минут | Проверяется на каждом круге |
| Счёт до 30 минут | Каждые 20 секунд |
| Более старый счёт | Каждые 90 секунд |
| От оплаты покупателем до вебхука | Обычно в пределах 5 секунд |

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

**Kaspi не передаёт время оплаты:** в данных нет точного момента платежа, поэтому измерить интервал «от подтверждения покупателем до того, как мы узнали» по данным Kaspi невозможно.

Подробнее про сроки: [Срок жизни счёта](/kb/ru/invoice-expiry).

## Поздняя оплата: главный пограничный случай

По счёту, который уже закрылся как `cancelled` или `expired`, деньги могут прийти с опозданием. В этом случае событие `invoice.paid` придёт позже с пометкой **`late: true`**.

Это не ошибка, и случается это редко, но случается. Игнорировать такое событие нельзя — деньги действительно поступили.

Что делать:

1. Примите событие и верните 200.
2. Найдите заказ и посмотрите его текущее состояние.
3. Выберите одно из двух: **оказать услугу** (товар есть, заказ не аннулирован) или **вернуть деньги** ([API возвратов](/kb/ru/refunds-api)).
4. Сообщите покупателю. Молчание — худший вариант.

```js
if (event === 'invoice.paid') {
  const order = await findOrder(invoice.externalOrderId);
  if (invoice.late && order.status === 'closed') {
    await notifyStaff(order, invoice);   // нужно решение человека
  } else {
    await fulfil(order, invoice);
  }
}
```

Подробнее: [Поздняя оплата](/kb/ru/late-payment).

## Как читать статус

Каналов два, и они не заменяют друг друга:

| Канал | Когда использовать |
|---|---|
| Вебхук | Основной канал. Приходит сам при смене статуса |
| `GET /api/v1/invoices/{id}` | Когда нужен текущий статус конкретного счёта |

В сценариях, где покупатель стоит у устройства и ждёт результата (вендинг, турникет, шлагбаум), используйте оба сразу: первые 30 секунд опрашивайте статус примерно раз в секунду и принимайте то, что придёт раньше. Поэтому обработка обязана быть идемпотентной. Подробнее: [Вебхук или опрос статуса](/kb/ru/polling-vs-webhook).

В ответе `GET /api/v1/invoices/{id}` приходят также события и возвраты по счёту — удобно как журнал.

## Идемпотентная обработка

Одно и то же событие по счёту может прийти несколько раз: оборвалась сеть, вы не успели ответить 200, мы повторили. Поэтому выполняйте обработку ровно один раз по паре **`(invoice.id, status)`**.

На практике это выглядит так: записываете эту пару в свою базу с уникальным индексом, и если запись не прошла (значит, уже было) — ничего не делаете. Подробнее: [Идемпотентность](/kb/ru/idempotency).

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

**Считается ли `refunded` оплаченным?** Да. Деньги приходили, потом были возвращены. Не исключайте такие счета из группы оплаченных — возврат это отдельный показатель.

**Счёт долго висит в `pending`?** Это нормально: покупатель ещё не оплатил. После `expiresAt` счёт станет `expired`. Подробнее: [Счёт завис в статусе pending](/kb/ru/invoice-stuck-pending).

**Я не вижу статуса `new`, сразу приходит `pending`.** Это обычное дело: счёт регистрируется в Kaspi быстро, поэтому `new` часто проскакивает незаметно.

**Можно ли снова открыть отменённый счёт?** Нет. Создайте новый.

**А если я пропустил смену статуса?** Журнал вебхуков хранится в разделе **Интеграции** кабинета, а текущий статус счёта всегда можно прочитать запросом `GET /api/v1/invoices/{id}`.
