# Форматы данных

Как в API записаны деньги, даты, время, телефоны и номера, и как получать длинные списки.

## Деньги

**В ответах** деньги — объект: сумма **строкой** с ровно двумя знаками после точки и валюта.

```json
{ "amount": "255000.00", "currency": "KZT" }
```

- Валюта сейчас всегда `KZT` — тенге.
- Сумма — строка, а не число: в JavaScript, PHP и многих базах дробные числа теряют точность
  (`0.1 + 0.2 = 0.30000000000000004`). Храните деньги у себя в десятичном типе (`DECIMAL(12,2)`,
  `Decimal` в Python, `BigDecimal` в Java) или в тиынах целым числом — но не в `float`.
- Отрицательная сумма бывает у возврата (`"-50000.00"`) и у остатка при переплате.

**В запросах** суммы — **строкой в тенге**, без валюты:

| Где                          | Как                                                              |
| ---------------------------- | ---------------------------------------------------------------- |
| Скидка (`discount.amount`)   | Целое или до двух знаков после точки: `"10000"`, `"10000.50"`     |
| Счёт Kaspi (`amount`)        | **Только целые тенге**: `"50000"` или `"50000.00"`. С тиынами — `fractional_amount` |

Разделитель дробной части — точка. Без пробелов и знака валюты: `"50 000 ₸"` не примется
(`bad_amount`).

## Номера записей

Все номера (`id`, `booking_id`, `shift_id`…) — **строки**: `"15230"`, а не `15230`. Храните их
у себя как текст: устройство номеров может поменяться, а строка останется строкой. Не делайте
выводов по величине номера.

## Время и даты

| Что                  | Формат                              | Пример                         | Поля                                   |
| -------------------- | ----------------------------------- | ------------------------------ | -------------------------------------- |
| Момент времени       | RFC 3339 в **UTC** (буква `Z` в конце) | `2027-05-14T09:31:07.482913Z`  | `*_at`: `created_at`, `hold_until`, `paid_at` |
| Дата без времени     | `ГГГГ-ММ-ДД`                        | `2027-07-01`                   | `*_on`, `date_*`, `*_due`, `birth_date`  |

- Время в ответах — **всегда UTC**. Лагерь живёт по времени **Казахстана: UTC+5**. Чтобы показать
  менеджеру местное время, прибавьте 5 часов: `hold_until` `2027-05-15T09:31:07Z` — это 15 мая,
  14:31 по Астане.
- Дробная часть секунд может быть, а может не быть — разбирайте время стандартной функцией разбора
  RFC 3339, а не регулярным выражением.
- Даты без времени (`date_start`, `full_payment_due`, `paid_on`) — **по календарю лагеря**
  (UTC+5). Платёж, пришедший 14 мая в 23:30 по Астане (18:30 UTC), имеет `paid_on: 2027-05-14`.
- В параметрах запроса время передавайте тоже в RFC 3339: `updated_since=2027-05-14T00:00:00Z` или
  с поясом: `2027-05-14T05:00:00+05:00`. Не тот формат — `400 bad_time`. Знак `+` в адресе нужно
  закодировать как `%2B`, поэтому проще передавать время в UTC с `Z`.

:::tip{title="updated_since — с запасом"}

Время изменения записи — это начало транзакции, которая её изменила. Запрашивая «изменённые с
момента прошлой загрузки», берите момент **на минуту раньше**: иначе можно пропустить запись,
изменённую на стыке. Повторно полученные записи просто перезапишите у себя.

:::

## Телефоны

- **В запросах** — любой казахстанской записью: `87010000001`, `8 701 000 00 01`,
  `+7 (701) 000-00-01`, `+77010000001`.
- **В ответах** — всегда `+7XXXXXXXXXX`: `+77010000001`.
- Сравнивайте телефоны у себя в том же виде `+7XXXXXXXXXX`, иначе один номер покажется двумя.

## Пустые значения

- **В ответах** поле, у которого нет значения, приходит как `null` (а не пропадает), если в
  [справочнике](/api) не сказано иное.
- **В правках** (`PATCH`) различаются три случая — например, для `external_id`:

| В теле                      | Что будет         |
| --------------------------- | ----------------- |
| поля нет                    | не менять         |
| `"external_id": "deal-1"`   | заменить          |
| `"external_id": null`       | стереть           |

Для `payer_phone` и `partner_manager` «стереть» — пустая строка `""`.

## Строгость к запросам

- Незнакомое поле в теле запроса — отказ `422 invalid_input` с именем поля в `message`. Опечатка
  (`"birthdate"` вместо `"birth_date"`) не превратится в молча потерянные данные.
- Тело — JSON в UTF-8, заголовок `Content-Type: application/json`.
- Логические параметры адреса — `true` или `false`: `?on_sale=true`.

## Списки и порции

Списки отдаются **порциями** (по-английски — pagination):

```json
{
  "items": [ { "id": "15230", "…": "…" }, { "id": "15231", "…": "…" } ],
  "next_cursor": "c15231"
}
```

1. Запросите первую порцию: `GET /bookings?limit=200`.
2. Обработайте `items`.
3. Если `next_cursor` не пустой — запросите следующую: `GET /bookings?limit=200&cursor=c15231`.
4. `next_cursor` пустой (`null`) — список закончился.

- `limit` — от 1 до 200, по умолчанию 50. У сверки (`GET /reconcile/bookings`) — до 5000, по
  умолчанию 1000.
- Курсор — непрозрачная строка: передавайте как есть, не собирайте сами. Неверный курсор —
  `400 bad_cursor`: начните список заново.
- Ответ списка — **всегда объект** `{"items": [...], …}`, а не голый массив.
- Списки идут по возрастанию номера записи, поэтому новые записи не сдвигают уже полученные порции
  (исключение — журнал доставок вебхука: от новых к старым).
- Лента событий устроена иначе — номером `after`, а не курсором (см. [События](/partners/events#опрос)).

<CodeTabs>

```js title="JavaScript"
async function* all(path, params = {}) {
  let cursor = "";
  for (;;) {
    const url = new URL(`https://partners.zvezdnyy-burabai.edu.kz/ext/v1${path}`);
    for (const [k, v] of Object.entries({ ...params, limit: 200 })) url.searchParams.set(k, v);
    if (cursor) url.searchParams.set("cursor", cursor);
    const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.ZB_KEY}` } });
    if (res.status === 429) {
      await new Promise((r) => setTimeout(r, Number(res.headers.get("Retry-After") ?? 1) * 1000));
      continue; // та же порция ещё раз
    }
    const page = await res.json();
    yield* page.items;
    if (!page.next_cursor) return;
    cursor = page.next_cursor;
  }
}

for await (const booking of all("/bookings", { status: "active" })) console.log(booking.id);
```

```python title="Python"
import os
import time
import requests

def all_items(path, **params):
    params = {**params, "limit": 200}
    while True:
        res = requests.get(
            f"https://partners.zvezdnyy-burabai.edu.kz/ext/v1{path}",
            headers={"Authorization": f"Bearer {os.environ['ZB_KEY']}"},
            params=params,
            timeout=30,
        )
        if res.status_code == 429:
            time.sleep(int(res.headers.get("Retry-After", 1)))
            continue  # та же порция ещё раз
        page = res.json()
        yield from page["items"]
        if not page.get("next_cursor"):
            return
        params["cursor"] = page["next_cursor"]

for booking in all_items("/bookings", status="active"):
    print(booking["id"])
```

```php title="PHP"
function allItems(string $path, array $params = []): Generator {
    $cursor = '';
    while (true) {
        $q = http_build_query(array_filter($params + ['limit' => 200, 'cursor' => $cursor]));
        $ch = curl_init("https://partners.zvezdnyy-burabai.edu.kz/ext/v1$path?$q");
        curl_setopt_array($ch, [
            CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('ZB_KEY')],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 30,
        ]);
        $raw = curl_exec($ch);
        if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) === 429) { sleep(1); continue; }
        $page = json_decode($raw, true);
        yield from $page['items'];
        $cursor = $page['next_cursor'] ?? '';
        if ($cursor === '') return;
    }
}

foreach (allItems('/bookings', ['status' => 'active']) as $booking) echo $booking['id'], PHP_EOL;
```

</CodeTabs>
