# Счета Kaspi и оплата

Как лагерь берёт деньги за путёвку, как оплатить частями, заменить или отменить счёт и что
происходит с юрлицами лагеря.

## Как устроена оплата

- Платит **только плательщик и только счётом Kaspi**. Счёт уходит **с Kaspi лагеря** на телефон
  плательщика: телефон родителя из брони, а если указан `payer_phone` — на него.
- В запросе на счёт **телефона нет**. Сменить, кому уходят счета, можно только правкой брони
  (`PATCH /bookings/{id}` с `payer_phone`). Так ключ не может отправить требование заплатить на
  чужой номер от имени лагеря.
- У брони одновременно **только один неоплаченный счёт**.
- Счёт — только на **целые тенге** (Kaspi не знает тиынов) и **не больше остатка** по брони.
  Переплатить счётом нельзя.
- Не больше **5 счетов в час** на одну бронь.
- Ввести платёж через API нельзя. Если родитель заплатил лагерю другим способом (наличными в кассе,
  переводом на счёт организации), такой платёж вносит сам лагерь — вы увидите его в платежах брони с
  `source: manual`.

Нужное право — `invoices` (выставить, отменить) и `read` (смотреть).

## Состояния счёта

| `state`     | Что значит                                                          | Событие             |
| ----------- | ------------------------------------------------------------------- | ------------------- |
| `queued`    | Счёт создан и ждёт отправки в Kaspi (обычно секунды)                | `invoice.queued`    |
| `issued`    | Счёт у плательщика в приложении Kaspi                               | `invoice.issued`    |
| `paid`      | Оплачен. Рядом появляется платёж (`payment_id`)                     | `invoice.paid`      |
| `expired`   | Не оплачен в срок, Kaspi закрыл его                                 | `invoice.expired`   |
| `cancelled` | Отменён (вами, лагерем или системой при замене)                      | `invoice.cancelled` |
| `error`     | Не выставлен — причина в поле `error`                               | `invoice.error`     |
| `mismatch`  | Kaspi сообщил о сумме, отличной от счёта. Лагерь проверяет вручную  | `invoice.mismatch`  |

«Живой» (незакрытый) счёт — в состоянии `queued`, `issued` или `mismatch`. Список состояний может
пополняться: незнакомое значение не должно ломать вашу CRM.

## Выставить счёт

[`POST /bookings/{id}/invoices`](/api/счета) с `Idempotency-Key`. Что выставить — поле `kind`:

| `kind`   | Сумма счёта                                                                                    |
| -------- | ---------------------------------------------------------------------------------------------- |
| `min`    | Сколько ещё не хватает до минимального платежа. Если минимум уже внесён — отказ `409 min_already_paid` |
| `full`   | Весь остаток по брони                                                                           |
| `amount` | Своя сумма из поля `amount`: целые тенге строкой (`"50000"` или `"50000.00"`), не больше остатка |

```sh
curl https://partners.zvezdnyy-burabai.edu.kz/ext/v1/bookings/15230/invoices \
  -H "Authorization: Bearer $ZB_KEY" \
  -H "Idempotency-Key: 4c27e1a0-5d8b-4b6f-8e13-7a90d2c4f5e8" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "amount", "amount": "100000" }'
```

Ответ `201` — счёт в очереди:

```json
{
  "id": "735",
  "booking_id": "15230",
  "amount": { "amount": "100000.00", "currency": "KZT" },
  "phone": "+77010000001",
  "state": "queued",
  "cancelling": false,
  "payment_type": "prepayment",
  "purpose": "Путёвка 03-154, смена 3, 01.07–14.07, Тестов Тимур Аскарович",
  "created_at": "2027-05-20T06:15:00.311802Z",
  "issued_at": null,
  "paid_at": null,
  "payment_id": null,
  "error": null,
  "version": "2027-05-20T06:15:00.311802Z"
}
```

Ответ приходит сразу, а в Kaspi счёт уходит очередью — следите за событиями `invoice.issued` и
`invoice.paid` или перечитайте счёт: `GET /invoices/735`.

`payment_type` система ставит сама: `prepayment` — если счёт меньше остатка, `payment` — если на весь
остаток.

Частые отказы:

| Ответ                      | Что значит                                                                    |
| -------------------------- | ----------------------------------------------------------------------------- |
| `409 invoice_live`         | У брони уже есть неоплаченный счёт. Дождитесь его оплаты, отмените его или замените (`replace: true`) |
| `400 amount_over_debt`     | Сумма больше остатка по брони                                                 |
| `400 fractional_amount`    | Сумма с тиынами. Только целые тенге                                           |
| `400 nothing_to_pay`       | Остаток по брони равен нулю — платить нечего                                  |
| `409 min_already_paid`     | `kind: min`, а минимум уже внесён. Выставьте `full` или `amount`              |
| `409 booking_cancelled`    | Бронь отменена — счёт по ней не выставляется                                  |
| `409 invoice_mismatch`     | По брони разбирается расхождение суммы — новый счёт до решения лагеря не выставить |
| `400 bad_kind`, `bad_amount` | Опечатка в `kind` или сумма не по шаблону; `amount` передан без `kind: amount` |
| `429 rate_limited`         | Больше 5 счетов в час по этой брони                                           |

## Оплата частями

Родитель может платить путёвку несколькими платежами. Пример: путёвка 255 000 ₸, минимальный платёж
50 000 ₸.

| Шаг | Что происходит                                                                | `paid`    | `debt`    | `payment_state` |
| --- | ----------------------------------------------------------------------------- | --------- | --------- | --------------- |
| 1   | Бронь оформлена, ушёл счёт на 50 000 (`invoice: min`)                         | 0.00      | 255000.00 | `awaiting`      |
| 2   | Родитель оплатил 50 000                                                       | 50000.00  | 205000.00 | `secured`       |
| 3   | Родитель просит счёт на 100 000 — вы выставляете `kind: amount`, `amount: "100000"` | 50000.00 | 205000.00 | `secured` |
| 4   | Родитель оплатил 100 000                                                      | 150000.00 | 105000.00 | `secured`       |
| 5   | В день `full_payment_due` лагерь сам выставил счёт на остаток 105 000 и родитель оплатил | 255000.00 | 0.00 | `secured` |

Правила:

- Следующий счёт можно выставить, только когда предыдущий закрыт (оплачен, отменён, истёк) — или
  заменить его.
- Место закрепляется (`secured`), когда **сумма всех оплат** брони достигла минимального платежа.
  Можно набрать минимум и несколькими счетами меньше минимума.
- «Оплачено» (`paid`) и «остаток» (`debt`) в брони считаются по всем платежам, **включая ещё не
  сверенные бухгалтерией** — менеджеру не нужно ждать сверки.
- Автоматический счёт на остаток лагерь выставляет **один раз** на бронь — начиная с дня срока
  полной оплаты и до дня заезда. Если в этот момент у брони есть неоплаченный счёт, лагерь не
  дублирует его, а ждёт, пока тот закроется, и выставляет счёт на остаток после. Бронь,
  оформленная уже после срока полной оплаты, получает такой счёт, как только закроется счёт на
  минимальный платёж.

## Заменить счёт

Родитель получил счёт на 205 000, а хочет заплатить сейчас 100 000. Два живых счёта у брони быть не
может, поэтому замените старый новым — `replace: true`:

```sh
curl https://partners.zvezdnyy-burabai.edu.kz/ext/v1/bookings/15230/invoices \
  -H "Authorization: Bearer $ZB_KEY" \
  -H "Idempotency-Key: 9a3f6b20-1e4c-4d7a-b8f5-62c0e9d1a734" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "amount", "amount": "100000", "replace": true }'
```

Ответ `202` — замена принята, но ещё не завершена:

```json
{
  "state": "replacing",
  "amount": { "amount": "100000.00", "currency": "KZT" },
  "replaces": {
    "id": "733",
    "booking_id": "15230",
    "amount": { "amount": "205000.00", "currency": "KZT" },
    "phone": "+77010000001",
    "state": "issued",
    "cancelling": true,
    "payment_type": "payment",
    "purpose": "Путёвка 03-154, смена 3, 01.07–14.07, Тестов Тимур Аскарович",
    "created_at": "2027-05-18T11:00:02.557100Z",
    "issued_at": "2027-05-18T11:00:06.009412Z",
    "paid_at": null,
    "payment_id": null,
    "error": null,
    "version": "2027-05-20T06:15:00.311802Z"
  }
}
```

Что происходит дальше:

1. Старый счёт гасится в Kaspi — это занимает от секунд до минут. Придёт `invoice.cancelled`.
2. Сразу после этого лагерь выставляет новый счёт — придёт `invoice.queued`, затем `invoice.issued`.
   Номер нового счёта вы узнаете из этих событий.

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

## Отменить счёт

[`POST /invoices/{id}/cancel`](/api/счета) с `Idempotency-Key`, без тела:

```sh
curl -X POST https://partners.zvezdnyy-burabai.edu.kz/ext/v1/invoices/735/cancel \
  -H "Authorization: Bearer $ZB_KEY" \
  -H "Idempotency-Key: e5b1c7d2-08af-4e63-91c4-3f7a2d6b0e95"
```

Ответ `200` — счёт после отмены:

- счёт ещё не ушёл в Kaspi (`queued`) — отменяется сразу, `state: cancelled`;
- счёт уже у плательщика (`issued`) — гасится в Kaspi очередью: сначала `cancelling: true`, потом
  событие `invoice.cancelled`. **Пока это так, счёт ещё можно оплатить** — оплата будет принята.

Отказы: `409 invoice_paid` — счёт уже оплачен (это уже вопрос возврата, см.
[Возвраты](/partners/refunds)); `409 invoice_not_live` — счёт уже закрыт, отменять нечего;
`invoice_mismatch` — по счёту расхождение суммы, его разбирает только лагерь.

## Изменение брони и счёт

Если вы меняете цену, скидку или трансфер брони (`PATCH /bookings/{id}`), остаток меняется, и живой
счёт на старую сумму становится неправильным. Лагерь **сам** гасит его и выставляет новый на верную
сумму (события `invoice.cancelled`, затем `invoice.queued`). Делать ничего не нужно.

## Если счёт не выставился

У счёта в состоянии `error` (и иногда у живого счёта) заполнено поле `error`:

```json
"error": {
  "code": "rejected",
  "message": "Kaspi отклонил счёт. Проверьте телефон плательщика в брони: номер должен быть зарегистрирован в Kaspi"
}
```

| `error.code`                                          | Что делать                                                                 |
| ----------------------------------------------------- | -------------------------------------------------------------------------- |
| `waiting`, `waiting_status`                           | Ничего: ждём ответа Kaspi, счёт выставится или обновится сам               |
| `rejected`                                            | Телефон плательщика не зарегистрирован в Kaspi. Исправьте `payer_phone` (или телефон родителя) в брони и выставьте счёт снова |
| `rejected_in_kaspi`                                   | Плательщик сам отклонил счёт в Kaspi. Уточните у него и выставьте новый     |
| `queue_expired`                                       | Счёт не удалось выставить за сутки. Выставьте новый или обратитесь в лагерь |
| `rejected_sent`, `attempts_exhausted`, `queue_expired_sent` | **Не выставляйте новый** — счёт мог дойти до плательщика. Обратитесь в лагерь |
| `cancel_waiting`, `cancel_retry`                      | Отмена в процессе. Пока не завершена — счёт можно оплатить                  |
| `cancel_failed`, `orphan_live`                        | Отменить автоматически не удалось, лагерь отменит вручную. Предупредите плательщика не оплачивать этот счёт |
| `mismatch`                                            | Kaspi сообщил о другой сумме — лагерь проверяет оплату                      |
| `expired_in_kaspi`                                    | Счёт не оплачен в срок                                                      |
| другой код                                            | Счёт требует внимания лагеря — обратитесь в лагерь                          |

Список кодов может пополняться.

## Одно юрлицо на бронь

У лагеря **несколько юрлиц** — ТОО и ИП, — и у каждого своя касса Kaspi. Лагерь время от времени
переключает, через какую кассу выставляются новые счета (это решение лагеря, вы на него не влияете).

Главное правило: **деньги одной брони никогда не делятся между ИП и ТОО.**

- Пока по брони **нет подтверждённой оплаты**, новые счета уходят через кассу, которая сейчас
  активна у лагеря.
- Как только по брони пришла и подтвердилась **первая оплата**, **все следующие счета этой брони**
  — доплата, остаток, автоматический счёт к сроку — уходят через **кассу того же юрлица**, даже
  если лагерь уже переключил активную кассу на другое юрлицо.
- Уже выставленный счёт навсегда помнит свою кассу. Счёт ушёл от ТОО, лагерь переключился на ИП,
  родитель оплатил — деньги пришли на ТОО, и платёж записан на ТОО.
- Возврат делается **с того же юрлица**, которое приняло деньги.

Пример:

1. 14 мая активна касса ТОО. Родитель оплатил минимальный платёж 50 000 — деньги на ТОО.
2. 1 июня лагерь переключил активную кассу на ИП. Новые брони теперь оплачиваются на ИП.
3. 24 июня (срок полной оплаты) лагерь выставляет этой брони счёт на остаток — **через кассу ТОО**,
   потому что первая оплата брони пришла на ТОО.

На какое юрлицо пришли деньги, видно в каждом платеже — поле `legal_entity` (`name`, `bin` — БИН ТОО
или ИИН ИП). Вашей CRM ничего делать не нужно; правило нужно знать, чтобы правильно отвечать
родителям и сверять акты с лагерем.

## Платежи — только чтение

| Нужно                              | Запрос                                                          |
| ---------------------------------- | --------------------------------------------------------------- |
| Платежи брони                      | `GET /bookings/{id}/payments`                                    |
| Все платежи отдела                 | `GET /payments` (фильтры `paid_from`, `updated_since`)           |
| Один платёж                        | `GET /payments/{id}`                                             |
| Платежи семьи                      | `GET /families/{id}/payments`                                    |

Поля платежа:

| Поле           | Что значит                                                                               |
| -------------- | ---------------------------------------------------------------------------------------- |
| `amount`       | Сумма. Возврат — отрицательной суммой                                                     |
| `type`         | `prepayment` — предоплата, `payment` — оплата, `refund` — возврат                         |
| `source`       | `kaspi_invoice` — оплата счёта Kaspi, `manual` — платёж внёс лагерь                       |
| `method`       | Как заплатили: `kaspi_invoice`, `kaspi_transfer`, `bank_transfer`, `cash`; `null` — не указано |
| `paid_on`      | День оплаты по календарю лагеря (время Казахстана), без времени                           |
| `verified`     | `true` — бухгалтерия лагеря сверила платёж с банком                                       |
| `legal_entity` | Юрлицо лагеря, на которое пришли деньги                                                   |
| `invoice_id`   | Счёт Kaspi, оплатой которого создан платёж                                                |
