# API Documentation

Kaspi POS Automation предоставляет публичный REST API `/api/v1/*`,
совместимый с apipay.kz по форме запросов, ответов и webhook payloads.
Этот документ — авторитетная справка для интеграторов (cabin-backend и
других мерчантов).

OpenAPI 3.0 spec: [`docs/openapi.yaml`](./openapi.yaml).

## Содержание

- [Базовый URL](#базовый-url)
- [Аутентификация](#аутентификация)
- [Лимиты](#лимиты)
- [Формат ошибок](#формат-ошибок)
- [Песочница](#песочница)
- [Endpoints](#endpoints)
  - [Health](#health)
  - [Account](#account)
  - [Invoices](#invoices)
  - [Refunds](#refunds)
  - [Catalog](#catalog)
  - [Subscriptions](#subscriptions)
  - [Webhook configuration](#webhook-configuration)
- [Webhooks (outbound)](#webhooks-outbound)
- [Статусы](#статусы)
- [Маппинг Kaspi → canonical](#маппинг-kaspi--canonical)
- [Примеры кода](#примеры-кода)
- [Дашборд API (`/api/dashboard/v1/*`)](#дашборд-api-apidashboardv1)
- [Operator portal API (legacy)](#operator-portal-api-legacy)

---

## Базовый URL

```
https://<host>/api/v1
```

Локальная разработка: `http://localhost:3000/api/v1`.

---

## Аутентификация

Все эндпоинты, кроме `/health`, требуют заголовок:

```
X-API-Key: <api_key>
```

- Сравнение ключа — **timing-safe** (`crypto.timingSafeEqual` поверх
  hex-декодированных буферов).
- Заголовок обязателен на каждый запрос. Тело запроса никогда не
  содержит ключ.
- Сандбокс vs production определяется по флагу `is_sandbox` на самом
  API-ключе. Префикс ключа — человекочитаемый признак:
  - `kpa_test_*` — sandbox-ключ (`is_sandbox: true`)
  - `kpa_live_*` — production-ключ (`is_sandbox: false`)

Получить ключ можно через operator portal (`POST /api/portal/tenants` →
`POST /api/portal/tenants/{tenantId}/api-keys/rotate`). Plaintext ключа
показывается только в момент создания / ротации.

---

## Лимиты

- **60 запросов в минуту на тенанта**. Bucket в памяти процесса.
- Перевышение → HTTP `429 Too Many Requests` с телом
  `{ "error": { "code": "RATE_LIMITED", "message": "..." } }` и
  заголовком:

  ```
  Retry-After: <seconds>
  ```

- Bucket изолирован по `tenant_id`, не по IP. Один тенант не может
  «отъесть» лимит другого через общий egress.
- Перезапуск процесса сбрасывает counters — поведение «soft»,
  допустимое для single-instance деплоя.

---

## Формат ошибок

apipay envelope:

```json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "fields": {
      "amount": "Amount must be greater than 0."
    }
  }
}
```

Поле `fields` присутствует только когда есть пошаговая валидация по
конкретным полям. `code` — стабильный машинный идентификатор.

| HTTP | code                      | Когда                                                                       |
| ---- | ------------------------- | --------------------------------------------------------------------------- |
| 400  | `BAD_REQUEST`             | Некорректное тело / Content-Type                                            |
| 401  | `UNAUTHORIZED`            | Отсутствует или неверный `X-API-Key`                                        |
| 404  | `NOT_FOUND`               | Ресурс не существует или не виден тенанту                                   |
| 409  | `KASPI_SESSION_REQUIRED`  | Kaspi-сессия не настроена. Подключите Kaspi Business как кассир до использования live API key. Срабатывает, когда `kpa_live_*` ключ используется без сконфигурированной Kaspi-сессии. |
| 409  | `SUBSCRIPTION_EXISTS`     | Подписка с таким `external_subscriber_id` уже существует у тенанта в `active` / `paused` статусе. См. [Subscriptions → Идемпотентность](#идемпотентность). |
| 410  | `GONE`                    | Ресурс истёк / удалён (зарезервировано)                                     |
| 422  | `VALIDATION_ERROR`        | Не прошла валидация полей                                                   |
| 422  | `INVALID_STATE_TRANSITION`| Запрещённый переход состояния подписки (`/pause` не из `active`, `/resume` не из `paused`, `/cancel` на терминальной). |
| 429  | `RATE_LIMITED`            | Превышен лимит 60 RPM                                                       |
| 500  | `INTERNAL`                | Внутренняя ошибка сервера                                                   |
| 502  | `UPSTREAM_ERROR`          | Ошибка upstream-провайдера (Kaspi). Тело ответа содержит `request_id` для корреляции с поддержкой. Исходный текст ошибки логируется только на стороне сервера. |

> 404 vs 403: для чужих ресурсов всегда возвращается 404 (не 403) —
> чтобы не давать тенанту oracle на существование чужих ID.

> 502 ответы дополнительно содержат `request_id` в `error.fields`:
> `{ "error": { "code": "UPSTREAM_ERROR", "message": "...", "fields": { "request_id": "<hex>" } } }`.
> Тенанты указывают этот `request_id` при обращении в поддержку.

---

## Песочница

Sandbox-режим включается через `is_sandbox` на самом API-ключе.

- В sandbox `POST /invoices` создаёт счёт **локально**, без запроса к
  Kaspi. Опциональное поле `simulate` (`paid` / `cancelled` / `expired`)
  предустанавливает финальный статус и сразу эмитит
  `invoice.status_changed`.
- Production-ключ (`kpa_live_*`) маршрутизирует через Kaspi. Если у
  тенанта ещё не подключена Kaspi-сессия, запрос **закрывается с ошибкой
  `409 KASPI_SESSION_REQUIRED`** — раньше был silent fallback в sandbox,
  теперь система fail-closed, чтобы тенант не отправлял боевой трафик в
  симулятор по ошибке.
- В Sandbox-инвойсах флаг `is_sandbox: true` присутствует в теле
  ответа и в payload webhook.

---

## Endpoints

### Health

#### `GET /api/v1/health`

Liveness probe, не требует `X-API-Key`.

```json
{ "status": "ok", "api": "v1" }
```

---

### Account

#### `GET /api/v1/me`

Возвращает текущего тенанта и метаданные API-ключа.

```json
{
  "tenant": { "id": "tenant_...", "name": "Demo Coffee", "mode": "production", ... },
  "api_key": { "id": "ak_...", "name": "production-key", "is_sandbox": false }
}
```

---

### Invoices

#### `POST /api/v1/invoices`

Создание счёта по номеру телефона.

**Headers:**

```
X-API-Key: <api_key>
Content-Type: application/json
```

**Body:**

```json
{
  "amount": 15000,
  "phone_number": "87001234567",
  "description": "Order #123",
  "external_order_id": "order_123"
}
```

| Поле                | Тип     | Обязательное | Правила                                       |
| ------------------- | ------- | ------------ | --------------------------------------------- |
| `amount`            | number  | да           | 0.01 – 99 999 999.99                          |
| `phone_number`      | string  | да           | формат `8XXXXXXXXXX` (11 цифр, ведущая 8)     |
| `description`       | string  | нет          | максимум 500 символов                         |
| `external_order_id` | string  | нет          | идентификатор для idempotency                 |
| `is_sandbox`        | boolean | нет          | принудительно sandbox для production-ключа    |
| `simulate`          | enum    | нет          | sandbox-only: `paid`, `cancelled`, `expired`  |

**Response (201):**

```json
{
  "id": 42,
  "external_order_id": "order_123",
  "amount": "15000.00",
  "status": "pending",
  "created_at": "2026-05-14T10:30:00Z"
}
```

**Errors:** `401 UNAUTHORIZED`, `409 KASPI_SESSION_REQUIRED`
(live-ключ без сконфигурированной Kaspi-сессии), `422 VALIDATION_ERROR`
(поля `amount`, `phone_number`, `description`), `429 RATE_LIMITED`,
`502 UPSTREAM_ERROR` (ошибка upstream-Kaspi, с `request_id`).

---

#### `POST /api/v1/invoices/qr`

Создание QR-счёта.

**Body:**

```json
{ "amount": 2500, "description": "Counter order" }
```

Поля: `amount` (обязательно), `description`, `external_order_id`,
`latitude`, `longitude`, `is_sandbox`, `simulate`.

**Response (201):** идентичный shape с `POST /invoices` плюс
поля `qr_token_url`, `qr_image_url`, `qr_expires_at` доступны через
`GET /invoices/{id}`.

---

#### `GET /api/v1/invoices`

Список счетов компании.

**Query:**

| Параметр | Тип     | Описание                                     |
| -------- | ------- | -------------------------------------------- |
| `limit`  | integer | 1–200 (default 50)                           |
| `offset` | integer | 0+ (default 0)                               |
| `status` | string  | один из канонических статусов (см. ниже)     |
| `from`   | string  | ISO 8601 — нижняя граница `created_at`       |
| `to`     | string  | ISO 8601 — верхняя граница `created_at`      |

**Response (200):**

```json
{
  "items": [ { "id": 42, "status": "paid", ... } ],
  "total": 137,
  "limit": 50,
  "offset": 0
}
```

---

#### `GET /api/v1/invoices/{id}`

Детальная карточка счёта.

**Response (200):**

```json
{
  "id": 42,
  "external_order_id": "order_123",
  "amount": "15000.00",
  "total_refunded": "0.00",
  "is_fully_refunded": false,
  "description": "Order #123",
  "status": "paid",
  "paid_at": "2026-05-14T14:35:00Z",
  "is_sandbox": false,
  "is_recurring": false,
  "kaspi_invoice_id": "KP-...",
  "client_name": "John Doe",
  "client_phone": "87001234567",
  "client_comment": null,
  "created_at": "2026-05-14T10:30:00Z",
  "items": []
}
```

**Errors:** `401`, `404 NOT_FOUND`, `429`.

---

#### `POST /api/v1/invoices/{id}/cancel`

Отменить счёт. Допустимо только для `pending`.

**Response (200):** обновлённый Invoice со `status: "cancelled"`.

**Errors:** `404 NOT_FOUND`, `422 VALIDATION_ERROR` (`Only pending
invoices can be cancelled.`), `429`, `502 UPSTREAM_ERROR` (ошибка
Kaspi-стороны, с `request_id`).

---

#### `POST /api/v1/invoices/{id}/refund`

Полный или частичный возврат. Доступно для `paid` или
`partially_refunded`.

**Body:**

```json
{ "amount": 5000, "reason": "Customer request" }
```

`amount` опционален — по умолчанию возвращается весь
`available_for_refund`.

**Response (201):**

```json
{
  "refund": {
    "id": 7,
    "invoice_id": 42,
    "amount": "5000.00",
    "status": "completed",
    "reason": "Customer request",
    "is_sandbox": false,
    "created_at": "2026-05-14T15:00:00Z"
  },
  "invoice": { ... }
}
```

Эмитит `invoice.status_changed` со статусом `partially_refunded` или
`refunded`.

**Errors:** `404`, `422 VALIDATION_ERROR` (`amount` вне диапазона), `429`,
`502 UPSTREAM_ERROR` (ошибка Kaspi-стороны, с `request_id`).

---

#### `GET /api/v1/invoices/{id}/refunds`

Список возвратов по счёту.

```json
{ "items": [ { "id": 7, "amount": "5000.00", ... } ] }
```

---

#### `POST /api/v1/invoices/status/check`

Принудительная синхронизация статусов из Kaspi.

**Body:**

```json
{ "invoice_ids": [42, 43, 44] }
```

**Response (200):**

```json
{
  "items": [
    { "id": 42, "status": "paid" },
    { "id": 43, "status": "expired" },
    { "id": 44, "status": "not_found" }
  ]
}
```

Для каждого invoice, статус которого изменился, эмитится
`invoice.status_changed`.

---

### Refunds

#### `GET /api/v1/refunds`

Все возвраты тенанта.

```json
{ "items": [ { "id": 7, "invoice_id": 42, "amount": "5000.00", ... } ] }
```

---

### Catalog

#### `GET /api/v1/catalog/units`

Доступные единицы измерения.

```json
{ "data": [
  { "code": "pcs", "name_ru": "шт", "name_kk": "дн", "name_en": "pcs", "precision": 0 },
  { "code": "kg", "name_ru": "кг", "name_kk": "кг", "name_en": "kg", "precision": 3 }
] }
```

---

#### `GET /api/v1/catalog`

Список товаров. Query: `search`, `is_active`, `page`, `per_page`.

```json
{ "data": [ { "id": 1, "name": "Latte 300 ml", "price": "1200.00", "unit": "piece", "image_url": "/api/v1/catalog/images/abc..." } ], "total": 12, "page": 1, "per_page": 50 }
```

---

#### `POST /api/v1/catalog`

Batch-создание товаров. Принимает один объект, массив или
`{ "items": [...] }`.

**Body:**

```json
[
  { "sku": "LATTE-300", "name": "Latte 300 ml", "price": 1200, "unit": "piece" },
  { "name": "Delivery", "price": 700 }
]
```

**Response (201):**

```json
{ "data": [ { "id": 1, "name": "Latte 300 ml", ... } ] }
```

**Errors:** `422 VALIDATION_ERROR` по индексам `items.0.name`,
`items.0.price`.

---

#### `POST /api/v1/catalog/upload-image`

Загрузка изображения товара. **Бинарный multipart upload.**

**Headers:**

```
X-API-Key: <api_key>
Content-Type: multipart/form-data; boundary=...
```

**Body:** `multipart/form-data` с одним полем `file`. Ограничения:

- максимум 5 МБ (`CATALOG_IMAGE_MAX_BYTES`)
- MIME: `image/jpeg`, `image/png`, `image/webp`
- имя файла из `Content-Disposition` **игнорируется** — сервер
  генерирует `<imageId>.<ext>` (`imageId` — 32 hex)

**Response (201):**

```json
{
  "image_id": "f3b1e2…",
  "url": "/api/v1/catalog/images/f3b1e2…",
  "mime": "image/jpeg",
  "size": 184231,
  "checksum": "sha256-hex"
}
```

**Errors:** `400 BAD_REQUEST` (не multipart), `422 VALIDATION_ERROR`
(нет поля `file`, превышение размера, недопустимый MIME), `429`.

Пример cURL:

```bash
curl -X POST http://localhost:3000/api/v1/catalog/upload-image \
  -H "X-API-Key: $API_KEY" \
  -F "file=@./latte.jpg;type=image/jpeg"
```

---

#### `GET /api/v1/catalog/images/{imageId}`

Стрим изображения. `imageId` валидируется по `^[a-zA-Z0-9_-]{1,64}$`.
Tenant scope проверяется трижды (запись каталога, путь файла, тенант на
запись). Ответ — двоичные байты с корректным `Content-Type` и
`Cache-Control: private, max-age=3600`.

**Errors:** `404 NOT_FOUND` для чужих картинок, `422 VALIDATION_ERROR`
при некорректном `imageId`.

---

#### `GET /api/v1/catalog/{id}`, `PATCH /api/v1/catalog/{id}`, `DELETE /api/v1/catalog/{id}`

CRUD по одному товару. `DELETE` — soft-delete (`is_active: false`).

---

### Subscriptions

Подписки (recurring billing) — это правила автоматического выставления
счетов по расписанию. Каждый billing cycle создаёт **новый инвойс** через
тот же поток `POST /invoices`. Инвойс несёт поле `subscription_id`, по
которому консумер группирует.

- **Связь с инвойсами:** scheduler в фоне (каждые 60 с) находит подписки
  с наступившим `next_billing_at` и создаёт инвойсы через тот же
  Kaspi remote-invoice путь. На каждый цикл — один link record в
  под-ресурсе `/subscriptions/:id/invoices` и обычный инвойс в `/invoices`.
- **Жизненный цикл:** `active → paused → active → cancelled` или
  `active → expired` (после исчерпания retry × grace_period_days).
  `cancelled` и `expired` — терминальные.
- **Sandbox:** подписки наследуют флаг `is_sandbox` от API-ключа,
  использованного при создании. Sandbox-подписки **не вызывают Kaspi** —
  scheduler создаёт sandbox-инвойсы локально (тот же поток, что и
  `POST /invoices` с sandbox-ключом).
- **Часовой пояс:** `Asia/Almaty` (UTC+5, без DST). Поле `billing_day`
  интерпретируется в этом TZ: например, `billing_day: 5` для месячной
  подписки означает 5-е число каждого месяца **по времени Алматы**.
  Переопределяется env-переменной `SUBSCRIPTION_TZ`.
- **Webhook:** подписки **не публикуют отдельные события `subscription.*`**.
  Все subscription-driven инвойсы поднимают тот же
  `invoice.status_changed`, но поле `invoice.subscription_id` несёт
  ссылку на подписку (отсутствует для one-off инвойсов — drop-in
  совместимость сохранена).

#### Эндпоинты — обзор

| Метод | Путь | Назначение |
| --- | --- | --- |
| `POST` | `/api/v1/subscriptions` | Создать подписку (idempotent по `external_subscriber_id`) |
| `GET` | `/api/v1/subscriptions` | Список с фильтрами + пагинацией |
| `GET` | `/api/v1/subscriptions/{id}` | Деталь + `stats` + `last_payment` |
| `PUT` | `/api/v1/subscriptions/{id}` | Обновить mutable-поля (не терминальные) |
| `POST` | `/api/v1/subscriptions/{id}/pause` | `active` → `paused` |
| `POST` | `/api/v1/subscriptions/{id}/resume` | `paused` → `active` (пересчёт `next_billing_at`) |
| `POST` | `/api/v1/subscriptions/{id}/cancel` | Терминальная отмена |
| `GET` | `/api/v1/subscriptions/{id}/invoices` | Список инвойсов, созданных подпиской |

---

#### `POST /api/v1/subscriptions`

Создание подписки.

**Headers:**

```
X-API-Key: <api_key>
Content-Type: application/json
```

**Body:**

```json
{
  "phone_number": "87001234567",
  "amount": 15000,
  "billing_period": "monthly",
  "billing_day": 5,
  "description": "Подписка Pro tier",
  "subscriber_name": "ТОО Альфа",
  "external_subscriber_id": "sub_abc_001",
  "started_at": "2026-06-01",
  "max_retry_attempts": 3,
  "retry_interval_hours": 24,
  "grace_period_days": 7,
  "metadata": { "plan": "pro", "seats": 5 },
  "bill_immediately": false
}
```

| Поле | Тип | Обязательное | Правила |
| --- | --- | --- | --- |
| `phone_number` | string | да | формат `8XXXXXXXXXX` (11 цифр, ведущая 8) |
| `amount` | number | XOR с `cart_items` | 100 – 1 000 000 KZT. Должен быть либо `amount`, либо `cart_items`. |
| `cart_items` | array | XOR с `amount` | `[{ "catalog_item_id": 1, "count": 2 }]`. Сумма цикла = Σ `price × count` из snapshot. |
| `billing_period` | enum | да | `daily` / `weekly` / `biweekly` / `monthly` / `quarterly` / `yearly` |
| `billing_day` | integer | для `monthly`/`quarterly`/`yearly` | 1–28 (клэмп для Feb-safety) |
| `description` | string | нет | максимум 255 символов |
| `subscriber_name` | string | нет | максимум 255 символов |
| `external_subscriber_id` | string | нет | максимум 255. Idempotency-ключ — tenant-scoped, status-aware. |
| `started_at` | date | нет | ISO `YYYY-MM-DD`. По умолчанию — сегодня. Первый billing cycle. |
| `max_retry_attempts` | integer | нет | 1–10. По умолчанию **3**. |
| `retry_interval_hours` | integer | нет | 1–168. По умолчанию **24**. |
| `grace_period_days` | integer | нет | 1–30. По умолчанию **7**. |
| `metadata` | object | нет | Произвольные tenant-side данные. |
| `bill_immediately` | boolean | нет | По умолчанию `false`. Если `true` — `next_billing_at = now()`, ближайший tick (≤60 с) создаст первый инвойс. |

**Response (201):**

```json
{
  "id": 17,
  "phone_number": "87001234567",
  "amount": "15000.00",
  "billing_period": "monthly",
  "billing_day": 5,
  "status": "active",
  "description": "Подписка Pro tier",
  "subscriber_name": "ТОО Альфа",
  "external_subscriber_id": "sub_abc_001",
  "next_billing_at": "2026-06-04T19:00:00.000Z",
  "is_sandbox": false,
  "created_at": "2026-05-14T10:30:00.000Z"
}
```

> При `bill_immediately: true` `next_billing_at` равно `now()` — на момент
> чтения ответа это уже «в прошлом». Первый инвойс появится в течение ≤60 с
> (следующий scheduler tick). До этого момента `GET /subscriptions/:id`
> вернёт `last_payment: null`, а `GET /subscriptions/:id/invoices` — пустой
> список.

**Errors:**

- `401 UNAUTHORIZED`
- `409 SUBSCRIPTION_EXISTS` — collision по `external_subscriber_id` среди
  `active` / `paused` подписок этого тенанта. Тело несёт `fields.id` и
  `fields.external_subscriber_id` существующей подписки.
- `409 KASPI_SESSION_REQUIRED` — live-ключ без сконфигурированной
  Kaspi-сессии.
- `422 VALIDATION_ERROR` — нарушение полей (`amount` вне диапазона,
  `billing_period` не из enum, `billing_day` вне 1–28, `cart_items`
  ссылается на удалённые товары, etc.).
- `429 RATE_LIMITED`

---

#### `GET /api/v1/subscriptions`

Пагинированный список.

**Query:**

| Параметр | Тип | Описание |
| --- | --- | --- |
| `page` | integer | 1+ (default 1) |
| `per_page` | integer | 1–100 (default 10) |
| `status` | string | `active` / `paused` / `cancelled` / `expired`. Допустимо CSV (`status=active,paused`). |
| `phone_number` | string | substring-match по цифрам |
| `external_subscriber_id` | string | exact match |
| `search` | string | substring-match по phone / subscriber_name / external_subscriber_id / description |

**Response (200):**

```json
{
  "items": [
    { "id": 17, "status": "active", "billing_period": "monthly", ... }
  ],
  "meta": { "current_page": 1, "last_page": 3, "per_page": 10, "total": 23 }
}
```

---

#### `GET /api/v1/subscriptions/{id}`

Деталь с агрегированными `stats` и `last_payment`.

**Response (200):**

```json
{
  "subscription": {
    "id": 17,
    "phone_number": "87001234567",
    "amount": "15000.00",
    "billing_period": "monthly",
    "billing_day": 5,
    "status": "active",
    "description": "Подписка Pro tier",
    "subscriber_name": "ТОО Альфа",
    "external_subscriber_id": "sub_abc_001",
    "next_billing_at": "2026-07-04T19:00:00.000Z",
    "is_sandbox": false,
    "created_at": "2026-05-14T10:30:00.000Z",
    "failed_attempts": 0,
    "in_grace_period": false,
    "metadata": { "plan": "pro", "seats": 5 }
  },
  "stats": {
    "total_payments": 2,
    "successful_payments": 2,
    "failed_payments": 0,
    "total_collected": "30000.00"
  },
  "last_payment": {
    "amount": "15000.00",
    "status": "paid",
    "paid_at": "2026-06-04T19:05:23.000Z"
  }
}
```

`last_payment` равен `null`, пока не завершился ни один billing cycle.

**Errors:** `401`, `404 NOT_FOUND`, `429`.

---

#### `PUT /api/v1/subscriptions/{id}`

Обновление **только mutable-полей**. Запрещено на `cancelled` / `expired`
(возвращает `422 VALIDATION_ERROR`). Поля `is_sandbox`, `tenant_id`, `id`,
`phone_number`, `billing_period`, `status`, `next_billing_at`,
`first_failed_at`, `in_grace_period`, статистические счётчики **отклоняются
whitelist-ом** (defence in depth — на route-слое и в store-слое).

**Mutable-поля:** `amount`, `billing_day`, `description`, `subscriber_name`,
`external_subscriber_id`, `max_retry_attempts`, `retry_interval_hours`,
`grace_period_days`, `metadata`, `cart_items`.

**Body (пример):**

```json
{
  "amount": 18000,
  "metadata": { "plan": "pro_plus", "seats": 7 }
}
```

**Response (200):** обновлённый базовый shape подписки.

**Семантика:** изменения **НЕ влияют на in-flight pending инвойс** —
текущий открытый инвойс продолжает биллить старую сумму. Новое значение
применяется со **следующего** billing cycle. PUT с новым `cart_items`
пересчитывает snapshot.

**Errors:**

- `404 NOT_FOUND`
- `409 SUBSCRIPTION_EXISTS` — обновление `external_subscriber_id` на
  значение, уже занятое другой `active` / `paused` подпиской этого
  тенанта.
- `422 VALIDATION_ERROR` — подписка в терминальном состоянии (`status:
  cancelled` / `expired`), либо нарушена валидация полей, либо
  `cart_items` ссылается на удалённые товары.
- `429`

> Сейчас PUT **не позволяет** очистить `external_subscriber_id` в `null`
> (явный `null` трактуется как «не менять»). Если нужно сбросить
> idempotency-ключ — отмените подписку и создайте новую.

---

#### `POST /api/v1/subscriptions/{id}/pause`

`active` → `paused`. Замораживает `next_billing_at` (scheduler пропускает
подписку, пока она в `paused`).

**Response (200):** обновлённая подписка со `status: "paused"`.

**Errors:**

- `404 NOT_FOUND`
- `422 INVALID_STATE_TRANSITION` — подписка не в `active`.

---

#### `POST /api/v1/subscriptions/{id}/resume`

`paused` → `active`. Пересчитывает `next_billing_at = now() + 1 period unit`
(а не «возобновляет с того места, где остановилась»). Долгая пауза НЕ
вызывает back-billing.

**Response (200):** обновлённая подписка со `status: "active"` и новым
`next_billing_at`.

**Errors:**

- `404 NOT_FOUND`
- `422 INVALID_STATE_TRANSITION` — подписка не в `paused`.

---

#### `POST /api/v1/subscriptions/{id}/cancel`

`active` / `paused` → `cancelled`. Терминальная операция.

**Response (200):** обновлённая подписка со `status: "cancelled"`.

**Errors:**

- `404 NOT_FOUND`
- `422 INVALID_STATE_TRANSITION` — подписка уже в `cancelled` / `expired`.

---

#### `GET /api/v1/subscriptions/{id}/invoices`

Под-ресурс — все billing cycles этой подписки (link-records, joined с
полным инвойсом).

**Query:**

| Параметр | Тип | Описание |
| --- | --- | --- |
| `page` | integer | 1+ (default 1) |
| `per_page` | integer | 1–100 (default 20) |

**Response (200):**

```json
{
  "items": [
    {
      "id": 42,
      "invoice_id": 187,
      "billing_period_start": "2026-06-04T19:00:00.000Z",
      "billing_period_end": "2026-07-04T19:00:00.000Z",
      "billing_period_label": "June 2026",
      "amount": "15000.00",
      "attempt_number": 1,
      "status": "paid",
      "failure_reason": null,
      "paid_at": "2026-06-04T19:05:23.000Z",
      "invoice": { "id": 187, "status": "paid", "amount": "15000.00", ... },
      "created_at": "2026-06-04T19:00:00.000Z"
    }
  ],
  "meta": { "current_page": 1, "last_page": 1, "per_page": 20, "total": 1 }
}
```

`status` link-row — один из `pending` / `paid` / `failed` / `cancelled` /
`expired`. `failure_reason` — короткое описание от Kaspi (digit-sequences
≥10 знаков **редактируются** до `[REDACTED]` на стороне сервера для защиты
от утечки телефонов / ИИН в логи и API).

**Errors:** `401`, `404 NOT_FOUND`, `429`.

---

#### Статусы подписки

| Статус | Описание | Терминальный? |
| --- | --- | --- |
| `active` | Активна, биллинг идёт по расписанию | нет |
| `paused` | Приостановлена; биллинг не запускается | нет |
| `cancelled` | Отменена вручную через `POST /cancel` | да |
| `expired` | Истекла после исчерпания retry × `grace_period_days` | да |

State machine:

```
active   ─── /pause ───►   paused
paused   ─── /resume ──►   active   (next_billing_at = now + 1 period)
active   ─── /cancel ──►   cancelled (terminal)
paused   ─── /cancel ──►   cancelled (terminal)
active   ─── scheduler ─►  expired   (после grace_period_days)
```

PUT / pause / resume / cancel на терминальной подписке → `422
INVALID_STATE_TRANSITION` (или `VALIDATION_ERROR` для PUT).

---

#### Семантика `billing_period`

Scheduler вычисляет `next_billing_at` от текущего значения (cadence
preservation — не «`now + 1 period`», а «`prev_next_billing_at + 1
period`»).

| Период | Правило | `billing_day` |
| --- | --- | --- |
| `daily` | + 1 день | игнорируется |
| `weekly` | + 7 дней | игнорируется |
| `biweekly` | + 14 дней | игнорируется |
| `monthly` | + 1 месяц | используется; clamp до `min(28, lastDayOfMonth)` (Feb-safety) |
| `quarterly` | + 3 месяца | используется; clamp до 28 |
| `yearly` | + 1 год | используется; clamp до 28 |

Время дня для `monthly` / `quarterly` / `yearly` — **00:00 Almaty** (UTC+5),
сконвертированное в UTC. Year-roll работает прозрачно (декабрь → январь
следующего года).

> При нештатно повреждённом значении `billing_period` (out of enum)
> scheduler выпадает в `monthly` и логирует WARN. Это защита от corrupted
> state — обычным API-путём такое значение пройти не может (enum
> валидируется при create + PUT).

---

#### Retry + grace policy

При неуспешном billing cycle (invoice → `cancelled` / `expired` / `failed`):

1. `subscription.failed_attempts += 1`.
2. Если `failed_attempts < max_retry_attempts`: `next_billing_at = now() +
   retry_interval_hours ± 10% jitter`. Jitter рассеивает retry storm.
3. Если `failed_attempts >= max_retry_attempts` И `!in_grace_period`:
   фиксируется `grace_started_at = now()`, `in_grace_period = true`.
   Дальнейшие тики этой подписки не создают новых инвойсов.
4. Когда `now - grace_started_at >= grace_period_days * 24h` →
   scheduler ставит `status = 'expired'`.

При успешной оплате (`paid`):

- `failed_attempts = 0`
- `first_failed_at = null`
- `grace_started_at = null`
- `in_grace_period = false`
- `next_billing_at` продвигается на одно `period` от `billing_period_start`
  (cadence preserved).

**Важно:** `grace_started_at` фиксируется на **первом** failure, который
**перешагнул** порог `max_retry_attempts`, и больше не перезаписывается.
Это соответствует архитектурному решению R6 — grace-окно фиксированной
длины относительно момента исчерпания retry, а не «sliding window» от
последнего fail.

---

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

`external_subscriber_id` — tenant-scoped idempotency hint:

- Коллизия с уже **`active`** или **`paused`** подпиской того же тенанта →
  `409 SUBSCRIPTION_EXISTS`. Ответ несёт `fields.id` +
  `fields.external_subscriber_id` существующей подписки.
- Коллизия с **`cancelled`** или **`expired`** подпиской — **НЕ** считается
  коллизией; новая подписка создаётся.
- Один и тот же `external_subscriber_id` в **разных тенантах** — конфликта
  нет (multi-tenant scoping).

PUT с новым `external_subscriber_id` тоже проверяется на коллизию (`409
SUBSCRIPTION_EXISTS`).

---

#### Cart items

Если подписка создана с `cart_items`, то **на момент создания** сервер
резолвит каждую запись `{ catalog_item_id, count }` в snapshot:

```
{ catalog_item_id, name, price, count, unit, image_id }
```

Snapshot **замораживается** на подписке. Дальнейший биллинг считает
`amount = Σ (price × count)` именно из snapshot — изменения каталога,
**удаление** или **обновление цены** товара **не влияют** на подписку.

Чтобы обновить состав корзины, нужен PUT `/subscriptions/:id` с новым
`cart_items` — это пересоберёт snapshot. Изменения применяются со
следующего billing cycle (in-flight invoice не пересчитывается).

---

#### Webhook — subscription_id на invoice

Все subscription-driven инвойсы поднимают тот же `invoice.status_changed`,
что и one-off инвойсы. Различие — поле `invoice.subscription_id`:

- **Subscription-driven** — `invoice.subscription_id: <id>` (число).
- **One-off** — поле **отсутствует** в payload (не `null`, именно
  отсутствует — apipay-shape сохранён для существующих интеграторов).

```json
{
  "event": "invoice.status_changed",
  "invoice": {
    "id": 187,
    "amount": "15000.00",
    "status": "paid",
    "subscription_id": 17,
    "client_phone": "87001234567",
    "paid_at": "2026-06-04T19:05:23.000Z"
  },
  "timestamp": "2026-06-04T19:05:24.000Z"
}
```

Никакие отдельные события `subscription.*` (`subscription.payment_succeeded`,
`subscription.expired` и т.п.) **не публикуются**. Состояние подписки
(`active` → `expired` и т.п.) опрашивается через
`GET /subscriptions/:id`.

---

#### Ошибки (subscription-specific)

| HTTP | code | Когда |
| --- | --- | --- |
| 409 | `SUBSCRIPTION_EXISTS` | Idempotency collision по `external_subscriber_id` (POST или PUT). |
| 422 | `INVALID_STATE_TRANSITION` | `/pause` не из `active`; `/resume` не из `paused`; `/cancel` на терминальной. |
| 422 | `VALIDATION_ERROR` | Нарушение полей (`amount` range, `billing_period` enum, `billing_day` для `daily`, `cart_items` ссылается на удалённый товар, PUT на терминальной, и т.д.). |

---

### Webhook configuration

#### `GET /api/v1/webhook/configure`

```json
{ "webhook": { "url": "https://...", "events": ["invoice.status_changed"], "enabled": true, "secret_set": true } }
```

#### `POST /api/v1/webhook/configure`

```json
{ "url": "https://example.com/kaspi-webhook", "enabled": true, "events": ["invoice.status_changed"], "rotate_secret": false }
```

| Поле            | Тип     | Обязательное | Правила                                                                              |
| --------------- | ------- | ------------ | ------------------------------------------------------------------------------------ |
| `url`           | string  | нет          | `http://` или `https://`. Приватные адреса (loopback `127.0.0.0/8`, link-local `169.254.0.0/16`, RFC1918, CGNAT `100.64.0.0/10`) отклоняются с `VALIDATION_ERROR` (SSRF-защита). Self-hosted операторы могут отключить проверку через `ALLOW_PRIVATE_WEBHOOK_URLS=1`. |
| `enabled`       | boolean | нет          | Включить / выключить доставку                                                        |
| `events`        | array   | нет          | Подписки на события (на данный момент только `invoice.status_changed`)               |
| `rotate_secret` | boolean | нет          | По умолчанию `false`. Когда `true`, сервер генерирует новый webhook-secret и возвращает его в ответе. Иначе существующий секрет остаётся без изменений. |

**Response (200):** обновлённый `tenant`. Поле `webhookSecret` возвращается
**ТОЛЬКО** при первичном создании секрета ИЛИ когда явно передано
`rotate_secret: true`. Рутинные обновления (включение/выключение, смена
URL) **НЕ** содержат секрет в ответе — это сделано, чтобы случайные
HAR-захваты / скриншоты HTTP-инструментов не утекали ключ для подписи
webhook.

#### `POST /api/v1/webhook/test`

Отправляет `webhook.test` событие на сконфигурированный URL и
возвращает результат доставки.

---

## Webhooks (outbound)

### Единственное событие: `invoice.status_changed`

Эмитится при любом переходе статуса инвойса (включая возвраты —
`partially_refunded` / `refunded`).

**Headers:**

```
Content-Type: application/json
X-Webhook-Signature: sha256=<hex>
```

**Payload:**

```json
{
  "event": "invoice.status_changed",
  "invoice": {
    "id": 42,
    "external_order_id": "order_123",
    "amount": "15000.00",
    "status": "paid",
    "client_name": "John Doe",
    "client_phone": "87001234567",
    "paid_at": "2026-05-14T14:35:00Z"
  },
  "timestamp": "2026-05-14T14:35:01Z"
}
```

> Если инвойс был создан подпиской (см. [Subscriptions](#subscriptions)),
> объект `invoice` дополнительно несёт поле `subscription_id` — числовой
> идентификатор подписки. Для one-off инвойсов это поле **отсутствует**
> (не `null`, именно отсутствует — drop-in совместимость сохранена).

### Подпись

HMAC-SHA256 поверх **raw body bytes** с `tenant.webhook.secret`.
Receiver должен проверять подпись через `crypto.timingSafeEqual`.

```javascript
import crypto from 'crypto';

export function verifyWebhook(rawBody, header, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  const a = Buffer.from(header || '');
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// В Express обработчике (raw body!)
app.post('/kaspi-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyWebhook(req.body, req.headers['x-webhook-signature'], WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }
  const payload = JSON.parse(req.body.toString('utf8'));
  // ... handle payload.invoice.status
  res.status(200).end();
});
```

### Retry

- 3 попытки доставки.
- Кидерж: 0с / 5с / 30с.
- Timeout одной попытки: 10с.
- Очередь персистится в `webhook-retries.json` — переживает рестарт.
- После 3 неудач уведомление отбрасывается (в логе остаётся запись).

---

## Статусы

| Статус               | Описание                                              | Терминальный |
| -------------------- | ----------------------------------------------------- | ------------ |
| `pending`            | Ожидает оплаты                                        | нет          |
| `cancelling`         | Отмена в процессе                                     | нет          |
| `paid`               | Оплачен                                               | да           |
| `cancelled`          | Отменён (вручную или клиентом)                        | да           |
| `expired`            | Истёк по TTL либо платёж терминально не прошёл       | да           |
| `partially_refunded` | Частичный возврат применён, баланс ещё доступен       | да           |
| `refunded`           | Полностью возвращён                                   | да           |

Внутренние значения `processing` и `error` **из публичного API удалены**.
Внутри poller они никогда не публикуются — `processing` маппится в
`pending`, `error` — в `expired`.

---

## Маппинг Kaspi → canonical

Полная таблица raw-статусов Kaspi и их канонических апипай-аналогов
(используется в `src/polling.js → mapKaspiStatusToCanonical()`):

| Raw Kaspi status            | Canonical    | Источник     |
| --------------------------- | ------------ | ------------ |
| `QrTokenCreated`            | `pending`    | QR           |
| `Wait`                      | `pending`    | QR           |
| `QrTokenScanned`            | `pending`    | QR           |
| `PaymentConfirmation`       | `pending`    | QR + invoice |
| `RemotePaymentCreated`      | `pending`    | invoice      |
| `Processed`                 | `paid`       | QR + invoice |
| `Paid`                      | `paid`       | QR + invoice |
| `QrTokenDiscarded`          | `expired`    | QR           |
| `Expired`                   | `expired`    | QR + invoice |
| `CancelledByUser`           | `cancelled`  | QR           |
| `NotConfirmedByUser`        | `cancelled`  | QR           |
| `RemotePaymentCanceled`     | `cancelled`  | invoice      |
| `RemotePaymentRejected`     | `expired`    | invoice      |
| `Error`                     | `expired`    | QR           |
| `ProcessingFailed`          | `expired`    | QR           |
| `Rejected`                  | `expired`    | QR           |
| `InsufficientFunds`         | `expired`    | QR           |
| `InsufficientFundsError`    | `expired`    | QR           |
| `CancelledByExternalSource` | `expired`    | QR           |
| `IrisSrcBlockCode1`         | `expired`    | QR           |
| `IrisSrcBlockCode3`         | `expired`    | QR           |
| `IrisSrcBlockCode9`         | `expired`    | QR           |
| `IrisDestBlockCode3`        | `expired`    | QR           |
| `IrisDestBlockCode5`        | `expired`    | QR           |
| `IrisDestBlockCode7`        | `expired`    | QR           |
| `IrisDestBlockCode10`       | `expired`    | QR           |
| _неизвестный raw_           | `pending`    | fallback     |

Примечание: `partially_refunded` и `refunded` рассчитываются из
refund-таблицы, а не из raw-статуса Kaspi. `cancelling` — асинхронный
intent, выставляется самим API при отмене и не приходит от Kaspi
напрямую.

---

## Примеры кода

### cURL — создать sandbox-invoice

```bash
curl -X POST http://localhost:3000/api/v1/invoices \
  -H "X-API-Key: kpa_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 5000,
    "phone_number": "87001234567",
    "description": "Order #123",
    "external_order_id": "order_123",
    "simulate": "paid"
  }'
```

### JavaScript (fetch) — создать invoice и опросить статус

```javascript
const API = 'https://<host>/api/v1';
const KEY = process.env.KASPI_POS_API_KEY;

async function createInvoice(amount, phone, externalId) {
  const r = await fetch(`${API}/invoices`, {
    method: 'POST',
    headers: { 'X-API-Key': KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      amount,
      phone_number: phone,
      external_order_id: externalId,
    }),
  });
  if (!r.ok) {
    const { error } = await r.json();
    throw new Error(`${error.code}: ${error.message}`);
  }
  return r.json();
}

async function getInvoice(id) {
  const r = await fetch(`${API}/invoices/${id}`, {
    headers: { 'X-API-Key': KEY },
  });
  return r.json();
}
```

### JavaScript — загрузка изображения

```javascript
import fs from 'fs';
import FormData from 'form-data';

const form = new FormData();
form.append('file', fs.createReadStream('./latte.jpg'), { contentType: 'image/jpeg' });

const r = await fetch(`${API}/catalog/upload-image`, {
  method: 'POST',
  headers: { 'X-API-Key': KEY, ...form.getHeaders() },
  body: form,
});
const { image_id, url } = await r.json();
```

---

## Дашборд API (`/api/dashboard/v1/*`)

> **Внутренний surface.** Используется первой стороной — мерчантским
> SPA по адресу `/app/`. **НЕ входит в apipay drop-in контракт** и не
> предназначен для сторонних интеграторов. Стабильность контракта не
> гарантируется между релизами — SPA и сервер обновляются синхронно.
> Для интеграций используйте `/api/v1/*` с `X-API-Key`.

Дашборд предоставляет signup/login, управление API-ключами и мастер
подключения Kaspi для конкретного мерчанта, авторизованного по
HMAC-подписанной сессии в cookie.

### Базовый URL

```
https://<host>/api/dashboard/v1
```

### Аутентификация

- **Cookie-based session.** Cookie `kpa_session` несёт payload
  `{ user_id, exp }` + HMAC-SHA256 over `SESSION_SECRET`. Атрибуты:
  `HttpOnly; Secure; SameSite=Lax; Path=/`. TTL 7 дней по умолчанию
  (`SESSION_TTL_SECONDS`).
- **Double-submit CSRF.** На все state-changing запросы (POST/PUT/DELETE)
  требуется header `X-CSRF-Token: <value>` со значением, равным cookie
  `csrf`. Cookie `csrf` НЕ `HttpOnly` — SPA её читает; сравнение
  timing-safe. `GET`-эндпоинты от CSRF освобождены.
- **`X-API-Key` НЕ используется на этой поверхности** — это другая
  схема, мутуально исключающая apipay. Live/test API-ключи мерчанта
  работают только на `/api/v1/*`.
- SPA должна слать `credentials: 'include'` на каждый fetch.

### Модель безопасности

- HMAC-signed stateless cookie session (без серверного session-store)
- Double-submit CSRF
- scrypt password hashing (N=32768, r=8, p=1, 16-byte salt, PHC envelope
  `scrypt$N$r$p$saltHex$keyHex`)
- Per-IP rate-limit на `/signup` + `/login` (10 RPM по умолчанию,
  `SIGNUP_LOGIN_RPM`)
- Per-email lockout после неудачных входов (10 fails / 15 min → 15 min
  lock; `Retry-After: 900`)
- Маскирование номера телефона в ответах (`8XX***4567`) — сырой номер
  никогда не уходит клиенту

### Формат ошибок

Envelope — тот же `{ error: { code, message, fields? } }`, что и на
`/api/v1/*`, чтобы SPA переиспользовала рендер ошибок.

Дополнительные коды, специфичные для дашборда:

| HTTP | code                       | Когда                                                                          |
| ---- | -------------------------- | ------------------------------------------------------------------------------ |
| 401  | `UNAUTHORIZED`             | Сессионная cookie отсутствует / просрочена / повреждена                        |
| 403  | `CSRF_MISMATCH`            | `X-CSRF-Token` отсутствует или не совпадает с cookie `csrf`                     |
| 409  | `EMAIL_TAKEN`              | Signup: email уже зарегистрирован                                              |
| 401  | `INVALID_CREDENTIALS`      | Login: неверный email/пароль (то же тело и для unknown email — anti-enum)      |
| 429  | `LOCKED`                   | Per-email lockout: слишком много неудачных входов. Заголовок `Retry-After: 900` |
| 410  | `SMS_FLOW_EXPIRED`         | `kaspi/sms/confirm` без предшествующего `request`, либо после 5-min stash TTL  |
| 422  | `SMS_CODE_INVALID`         | Kaspi отверг код OTP                                                           |
| 422  | `SMS_REJECTED`             | Kaspi отверг запрос на SMS (rate-limit Kaspi-стороны и т.п.)                   |
| 422  | `PHONE_INVALID`            | Невалидный `phone_number`                                                      |
| 502  | `KASPI_SESSION_REJECTED`   | Kaspi вернул шаг finish-entrance без полезной сессии — попробуйте заново       |
| 502  | `SMS_REQUEST_FAILED`       | Upstream-вызов Kaspi `send-phone` упал                                         |

Прочие коды (`VALIDATION_ERROR`, `RATE_LIMITED`, `NOT_FOUND`,
`INTERNAL_ERROR`) совпадают по семантике с `/api/v1/*`.

---

### Endpoints

#### `POST /api/dashboard/v1/signup`

Создаёт пользователя + тенанта + первый live API-ключ атомарно.
Возвращает `secret` API-ключа **ОДИН РАЗ** в ответе. Sets cookies
`kpa_session` + `csrf`.

- **Auth:** анонимный
- **CSRF:** не требуется
- **Rate-limit:** per-IP `SIGNUP_LOGIN_RPM` (default 10/min)

**Body:**

```json
{ "email": "owner@example.com", "password": "correcthorse" }
```

| Поле       | Тип    | Правила                                                |
| ---------- | ------ | ------------------------------------------------------ |
| `email`    | string | required; ≤254 символов; формат `^[^\s@]+@[^\s@]+\.[^\s@]+$` |
| `password` | string | required; 8–256 символов                               |

**Response (201):**

```json
{
  "user":   { "id": 1, "tenant_id": "tenant_...", "email": "owner@example.com", "created_at": "..." },
  "tenant": { "id": "tenant_...", "name": "...", "mode": "sandbox", "api_keys": [...] },
  "api_key": {
    "id": "key_...",
    "name": "Default API key",
    "prefix": "kpa_live_xxxxxxx",
    "mode": "live",
    "is_sandbox": false,
    "created_at": "2026-05-14T10:30:00Z",
    "secret": "kpa_live_xxxxxxx_<полный-секрет>"
  },
  "webhook_secret": "whsec_...",
  "csrf_token": "..."
}
```

> `api_key.secret` показывается ОДИН раз — на сервере хранится только
> SHA-256 hash. Сохраните его сразу.

**Errors:** `409 EMAIL_TAKEN`, `422 VALIDATION_ERROR`, `429 RATE_LIMITED`.

---

#### `POST /api/dashboard/v1/login`

Проверяет email+пароль (timing-safe). На успех ставит cookies
`kpa_session` + `csrf` и возвращает пользователя/тенанта.

- **Auth:** анонимный
- **CSRF:** не требуется
- **Rate-limit:** per-IP `SIGNUP_LOGIN_RPM` + per-email lockout
  (10 fails / 15 min → 15 min `LOCKED`)

**Body:**

```json
{ "email": "owner@example.com", "password": "correcthorse" }
```

**Response (200):**

```json
{
  "user":   { "id": 1, "tenant_id": "tenant_...", "email": "owner@example.com", ... },
  "tenant": { "id": "tenant_...", ... },
  "csrf_token": "..."
}
```

**Errors:** `401 INVALID_CREDENTIALS` (то же тело и при unknown email,
и при wrong password — anti-enumeration); `422 VALIDATION_ERROR`;
`429 RATE_LIMITED` (per-IP) или `429 LOCKED` (per-email) с
`Retry-After`.

---

#### `POST /api/dashboard/v1/logout`

Очищает cookies `kpa_session` + `csrf`. CSRF требуется — иначе
возможна cross-site logout-атака.

- **Auth:** анонимный (cookie может быть невалидна)
- **CSRF:** да

**Response (200):**

```json
{ "ok": true }
```

**Errors:** `403 CSRF_MISMATCH`.

---

#### `GET /api/dashboard/v1/me`

Текущий пользователь + тенант. Bootstrap-вызов для SPA.

- **Auth:** валидная сессия обязательна
- **CSRF:** не требуется (safe verb)

**Response (200):**

```json
{
  "user":   { "id": 1, "tenant_id": "tenant_...", "email": "...", ... },
  "tenant": {
    "id": "tenant_...",
    "name": "...",
    "mode": "production",
    "kaspi_session": { "phone_masked": "8XX***4567", "profile_id": "...", "connected_at": "..." },
    "api_keys": [{ "id": "key_...", "prefix": "kpa_live_...", "is_sandbox": false }],
    "webhook": { "url": "...", "events": ["invoice.status_changed"], "enabled": true, "secret_set": true }
  }
}
```

> `tenant.kaspi_session.phone_masked` — единственное поле с телефоном
> в ответе. Сырой номер на этой поверхности не отдаётся ни в каких
> ответах.

**Errors:** `401 UNAUTHORIZED`.

---

#### `POST /api/dashboard/v1/password`

Смена пароля. Проверяет `current_password` timing-safely. На успех
**пере-выпускает** session-cookie и CSRF (защита от утечки прежней
сессии).

- **Auth:** валидная сессия
- **CSRF:** да

**Body:**

```json
{ "current_password": "correcthorse", "new_password": "evenbettercorrecthorse" }
```

**Response (200):**

```json
{ "ok": true, "csrf_token": "<NEW>" }
```

**Errors:** `401 UNAUTHORIZED`, `401 INVALID_CREDENTIALS` (wrong
`current_password`), `403 CSRF_MISMATCH`, `422 VALIDATION_ERROR`.

---

#### `GET /api/dashboard/v1/api-keys`

Список API-ключей текущего тенанта. **Без секретов** — только публичная
проекция (id, prefix, mode, флаги).

- **Auth:** валидная сессия
- **CSRF:** не требуется

**Response (200):**

```json
{
  "items": [
    {
      "id": "key_...",
      "name": "Default API key",
      "prefix": "kpa_live_xxxxxxx",
      "mode": "live",
      "is_sandbox": false,
      "created_at": "...",
      "last_used_at": "...",
      "revoked_at": null
    }
  ]
}
```

**Errors:** `401 UNAUTHORIZED`.

---

#### `POST /api/dashboard/v1/api-keys`

Выпустить новый API-ключ. Секрет возвращается **ОДИН РАЗ**.

- **Auth:** валидная сессия
- **CSRF:** да

**Body:**

```json
{ "name": "Production cabin", "mode": "live" }
```

| Поле   | Тип    | Правила                                                            |
| ------ | ------ | ------------------------------------------------------------------ |
| `name` | string | required; regex `^[A-Za-z0-9 _.-]{1,64}$`                          |
| `mode` | enum   | `live` (→ `kpa_live_*`) либо `test` (→ `kpa_test_*`, `is_sandbox: true`) |

**Response (201):**

```json
{
  "api_key": {
    "id": "key_...",
    "name": "Production cabin",
    "prefix": "kpa_live_xxxxxxx",
    "mode": "live",
    "is_sandbox": false,
    "created_at": "...",
    "secret": "kpa_live_xxxxxxx_<полный-секрет>"
  }
}
```

**Errors:** `401 UNAUTHORIZED`, `403 CSRF_MISMATCH`, `422 VALIDATION_ERROR`.

---

#### `POST /api/dashboard/v1/api-keys/:id/rotate`

Атомарная ротация — `:id` помечается revoked, выпускается новый ключ с
тем же `name` и `mode`. Cross-tenant `:id` → `404 NOT_FOUND` (нельзя
ротировать чужой ключ).

- **Auth:** валидная сессия
- **CSRF:** да

**Response (200):** идентичен `POST /api-keys` (новый секрет один раз).

**Errors:** `401 UNAUTHORIZED`, `403 CSRF_MISMATCH`, `404 NOT_FOUND`.

---

#### `POST /api/dashboard/v1/api-keys/:id/revoke`

Помечает ключ revoked. Идемпотентно (повторный revoke не сдвигает
`revoked_at`).

- **Auth:** валидная сессия
- **CSRF:** да

**Response (200):**

```json
{ "ok": true, "id": "key_..." }
```

**Errors:** `401 UNAUTHORIZED`, `403 CSRF_MISMATCH`, `404 NOT_FOUND`.

---

#### `POST /api/dashboard/v1/kaspi/sms/request`

Стартует Kaspi SMS-flow на стороне сервера: Kaspi отправляет код на
указанный номер кассира. Сервер удерживает `processId` в памяти (TTL
5 минут), клиенту его НЕ отдаёт.

- **Auth:** валидная сессия
- **CSRF:** да

**Body:**

```json
{ "phone_number": "87001234567" }
```

**Response (200):**

```json
{ "ok": true, "expires_in": 300 }
```

**Errors:** `401 UNAUTHORIZED`, `403 CSRF_MISMATCH`, `422 PHONE_INVALID`,
`422 SMS_REJECTED`, `502 SMS_REQUEST_FAILED`.

---

#### `POST /api/dashboard/v1/kaspi/sms/confirm`

Финализирует Kaspi-сессию. Сервер вызывает `verify-otp` + `finish-entrance`,
шифрует `vtokenSecret` и сохраняет в `tenant.kaspi_session`.

- **Auth:** валидная сессия
- **CSRF:** да

**Body:**

```json
{ "code": "123456" }
```

**Response (200):**

```json
{
  "ok": true,
  "kaspi": {
    "connected_at": "2026-05-14T10:35:00Z",
    "profile_id": "...",
    "phone_masked": "8XX***4567"
  }
}
```

**Errors:** `401 UNAUTHORIZED`, `403 CSRF_MISMATCH`, `410 SMS_FLOW_EXPIRED`
(нет `request` или прошло 5 минут), `422 SMS_CODE_INVALID`,
`502 KASPI_SESSION_REJECTED`.

---

#### `POST /api/dashboard/v1/kaspi/disconnect`

Очищает `tenant.kaspi_session` и приостанавливает все активные
подписки этого тенанта (`active → paused`). Возвращает `200 { ok: true }`.

- **Auth:** валидная сессия
- **CSRF:** да

**Errors:** `401 UNAUTHORIZED`, `403 CSRF_MISMATCH`.

---

#### `GET /api/dashboard/v1/kaspi/status`

Read-only снимок. Никогда не отдаёт `tokenSN` / `vtokenSecret`; телефон
маскирован.

- **Auth:** валидная сессия
- **CSRF:** не требуется

**Response (200):**

```json
{
  "connected": true,
  "connected_at": "2026-05-14T10:35:00Z",
  "profile_id": "...",
  "phone_masked": "8XX***4567"
}
```

Для неподключённого тенанта — все поля кроме `connected: false` будут
`null`.

**Errors:** `401 UNAUTHORIZED`.

---

## Operator portal API (legacy)

Кроме публичного `/api/v1`, в репозитории остаётся внутренний portal API
для регистрации компаний и SMS-flow Kaspi. Эти эндпоинты не входят в
apipay-совместимый surface — они используются собственным web-кабинетом
(`public/`) и cabin-backend во время онбординга.

### Portal endpoints (краткая справка)

| Метод | Путь | Auth | Описание |
| --- | --- | --- | --- |
| `POST` | `/api/portal/tenants` | — | Зарегистрировать компанию |
| `GET`  | `/api/portal/tenants/{tenantId}` | `X-Admin-Token` | Настройки компании |
| `POST` | `/api/portal/tenants/{tenantId}/session` | `X-Admin-Token` | Сохранить Kaspi session |
| `POST` | `/api/portal/tenants/{tenantId}/api-keys/rotate` | `X-Admin-Token` | Выпустить новый API key |
| `PUT`  | `/api/portal/tenants/{tenantId}/webhook` | `X-Admin-Token` | Настроить webhook |
| `POST` | `/api/portal/tenants/{tenantId}/webhook/test` | `X-Admin-Token` | Отправить `webhook.test` |

### Kaspi SMS-flow (operator-only)

`/api/auth/init` → `/api/auth/send-phone` → `/api/auth/verify-otp` —
3-шаговая SMS-авторизация для подключения номера кассира Kaspi.
Возвращает `tokenSN` + `vtokenSecret`, которые сохраняются на стороне
сервера через `POST /api/portal/tenants/{tenantId}/session`.
Интеграторам публичного API эти эндпоинты не нужны.

### Прямые routes (`/api/invoice`, `/api/qr`, `/api/history`, `/api/refund`)

Сохраняются для совместимости с web-кабинетом. Требуют заголовков
`X-Token-SN` / `X-Vtoken-Secret` (raw Kaspi session). Не используются
интеграторами публичного API.

---

> Полная машиночитаемая спецификация: [`docs/openapi.yaml`](./openapi.yaml).
> Документация на казахском: [`docs/API.kk.md`](./API.kk.md).
