{
  "openapi": "3.0.0",
  "info": {
    "title": "ApiPay.kz REST API",
    "version": "2.1.0",
    "description": "REST API для приёма платежей через Kaspi Pay в Казахстане: счета по номеру телефона\nи по QR. ApiPay — независимый сервис поверх вашего Kaspi Pay: он выставляет счета\nчерез штатную роль «Кассир», деньги приходят напрямую на ваш Kaspi-счёт. Поддержка\nкаталога товаров, подписок, возвратов, мультикассирности и вебхуков. При регистрации\nсоздаётся sandbox-организация для тестирования без реального Kaspi.\n\n## Аутентификация\n\nЗаголовок `X-API-Key: {ключ}` на всех эндпоинтах, кроме `GET /status`. Организация\nрезолвится строго из ключа — интегратор видит только свою организацию. Ключ выдаётся\nв личном кабинете ApiPay.kz.\n\n⚠️ `403 organization_archived` означает, что организация ключа отправлена в архив: ключ\nостаётся активным, но работать им нельзя, и перевыпуск ключа ничего не меняет — доступ\nвозвращает владелец аккаунта. Отказ приходит на любую операцию. Не путайте с `401`\n(ключ неверен, истёк или деактивирован) — там как раз помогает перевыпуск.\n\n## Rate limiting\n\nОбщий лимит — **200 запросов/мин на ключ**. Отдельные эндпоинты\nимеют собственные лимиты (указаны в описании операции):\n- `GET /invoices/{id}` — 1000/мин (поллинг статуса без вебхуков);\n- `POST /invoices/bulk` — 20/мин;\n- `POST /invoices/qr` — 60/мин на организацию;\n- `POST /catalog/scan` — 30/мин + 2000/сутки;\n- `POST /catalog/bulk-delete` — 10/мин;\n- `POST /catalog/upload-image` — 60/мин, 2000/сутки на пару (ключ, организация) и\n  6000/сутки на ключ;\n- касса (`/cashbox/*`) — 30/мин;\n- `POST /clients/check` — 60/мин + 10 000/сутки на ключ, 200/мин + 20 000/сутки на\n  организацию, 10/мин на одного кассира;\n- авторизация кассира: `auth/init` и `auth/send-phone` — 5/мин, `auth/verify-otp` —\n  10/мин (на пару ключ+кассир).\n\n### Заголовки лимита\n\nКаждый ответ v1 несёт `X-RateLimit-Limit` и `X-RateLimit-Remaining`; на `429` к ним\nдобавляются `Retry-After` и `X-RateLimit-Reset`.\n\n⚠️ **Заголовки описывают тот бакет, где осталось МЕНЬШЕ ВСЕГО, а не лимит конкретной\nручки.** К запросу применяется несколько лимитеров сразу (общий на ключ + свой у\nэндпоинта), и наружу выходит самый дефицитный из них. Поэтому на одном и том же роуте\nзнаменатель может меняться между запросами — сравнивайте `Remaining` с `Limit` из ТОГО ЖЕ\nответа, а не с числом из этой таблицы.\n\n### Превышение\n\n`429`. Поминутные лимитеры отдают `error_code: request_rate_limited` и поля `limit`,\n`remaining`, `reset_at`, `retry_after_seconds`; ⚠️ поле `message` при этом осталось\nпрежним — `\"Too Many Attempts.\"`, разбор строки не сломан.\nКвотные (cap) лимиты — свои `error_code` (см. `RateLimitCap`) и `retry_after_seconds`.\n\n## Конверт ошибок\n\n- **Валидация:** `422 {\"message\":\"Validation failed\",\"errors\":{\"<поле>\":[\"...\"]}}`.\n- **Бизнес-логика:** `{\"error\":\"<slug>\",\"error_code\":\"<slug>\",\"message\":\"<текст>\"}` —\n  `error_code` это стабильный snake_case-идентификатор причины (каталог — схема\n  `ErrorCode`); `error` дублирует его для обратной совместимости. Стройте\n  `switch`-логику по `error_code`, а не по русскому тексту `message`.\n\n## Права интеграции (ключи, выданные партнёром)\n\nЕсли ключ выдан вам партнёром-интегратором, набор операций определяет **мерчант**: он\nподтверждает список прав при подключении и может изменить его в своём кабинете.\nОперация вне выданного набора отвечает `403 grant_not_granted` на обеих поверхностях —\nи на `/api/v1/*`, и на её кабинетном зеркале. Ответ намеренно одинаков для «права нет»,\n«операция закрыта интеграциям навсегда» и «операции нет в каталоге прав»: по коду ответа\nсостав чужого набора не восстанавливается. Ретрай бессмысленен — набор меняет мерчант.\n\n⚠️ `403 channel_required` означает, что живого доступа к организации у интеграции нет:\nмерчант его отозвал. Ключ при этом продолжает существовать — восстанавливает доступ\nмерчант.\n\n⚠️ `403 grant_snapshot_unreadable` означает сбой на нашей стороне (запись прав нечитаема),\nа не отказ в праве; обратитесь в поддержку.\n\n⚠️ Ключи, которые мерчант выпустил себе сам, этой проверкой не затронуты.\n\n## Форматы дат\n\nISO 8601. Ресурсы **Invoice / Refund / Subscription / CatalogItem / Connection** и все\nвебхуки — UTC, offset `+00:00` (например `2026-01-10T12:00:00+00:00`). Эндпоинты\n**`GET /tariff`** и **`GET /account/health`** отдают Asia/Almaty, offset `+05:00`\n(историческая несогласованность, не путать).\n\nОкна фильтров (`date_from`/`date_to`, `start_date`/`end_date`, `from`/`to`) задаются\nв зоне мерчанта **Asia/Almaty**: голая дата — это его календарные сутки целиком.\nКаждая ступень точности опциональна — `2026-08-01`, `2026-08-01 11`, `2026-08-01 11:30`,\n`2026-08-01 11:30:15`. Периодом является ТОЛЬКО голая дата: как только указано время,\nграница означает ровно этот момент (`…18` и `…18:00` эквивалентны, час до конца часа\nне растягивается).\nЯвный офсет в значении уважается как указан; в query-строке `+` кодируется как `%2B`.\n\n## Телефон\n\nПлательщик — формат `8XXXXXXXXXX` (11 цифр, начинается с `8`), регэксп `^8\\d{10}$`.\nТелефон кассира при авторизации (`auth/send-phone`) — формат `7XXXXXXXXXX`\n(регэксп `^7\\d{10}$`). Lookup (`POST /clients/check`) принимает разные формы и\nнормализует к `8XXXXXXXXXX`.\n\n## Пагинация\n\nФормы различаются по эндпоинту:\n- **Плоская** `{current_page, data, total}` — `GET /invoices`, `GET /refunds`.\n- **Meta-обёртка** `{data, links, meta}` — `GET /subscriptions` и `GET /catalog`\n  (offset и keyset). В targeted-режиме каталога\n  (`ntins[]`/`barcodes[]`/`ids[]`/`external_refs[]`) — `{data:[...]}` без `links`/`meta`.\n- **Особые формы:** `GET /invoices/{id}/refunds` — `{invoice, refunds, total}`;\n  `GET /subscriptions/{id}/invoices` — `{data, meta}` без `last_page`.\n\n## Вебхуки\n\nИсходящие POST-уведомления на `webhook_url` API-ключа. Подпись\n`X-Webhook-Signature: sha256=<hmac_sha256(raw_body, webhook_secret)>` (если задан\nсекрет). Все таймстампы — UTC `+00:00`. События и payload'ы — в секции `x-webhooks`.\nОтвечайте `200 OK` быстро (до 5 с), обработку выполняйте асинхронно, дедуплицируйте\nсобытия.\n\n## Sandbox\n\nПри `sandbox_mode=true` организация не ходит в Kaspi. Доступны симуляции жизненного\nцикла (тег `Sandbox`). В вебхуках sandbox-события помечены `is_sandbox: true`.\n",
    "contact": {
      "name": "ApiPay.kz Support",
      "url": "https://wa.me/77003076512"
    }
  },
  "servers": [
    {
      "url": "https://api.apipay.kz/api/v1",
      "description": "Production API"
    }
  ],
  "security": [
    {
      "ApiKeyAuth": []
    }
  ],
  "tags": [
    {
      "name": "System",
      "description": "Служебные эндпоинты (health-check без авторизации)."
    },
    {
      "name": "Invoices",
      "description": "Счета на оплату — создание (по номеру / QR / пакетно), просмотр, отмена, статистика."
    },
    {
      "name": "Refunds",
      "description": "Возвраты по счетам."
    },
    {
      "name": "QR Refunds",
      "description": "Возврат со сканированием QR клиентом — клиент подтверждает личность, мерчант выбирает операцию и возвращает."
    },
    {
      "name": "Static QR",
      "description": "Статические печатные QR, привязанные к сделке (мост qr.apipay.kz)"
    },
    {
      "name": "Receipts",
      "description": "Фискальные чеки в Kaspi OFD для оплат наличными / через POS другого банка (не прошедших через Kaspi QR)."
    },
    {
      "name": "Tariff & Account",
      "description": "Информация мерчанта о себе — тарифный каталог, статус подписки на ApiPay, health аккаунта."
    },
    {
      "name": "Connections",
      "description": "Управление кассирами собственной организации из CRM — CRUD и OTP-переавторизация. Требует per-key флаг can_manage_cashiers."
    },
    {
      "name": "Catalog",
      "description": "Каталог товаров — единицы измерения, листинг/экспорт, создание, Нацкаталог-скан."
    },
    {
      "name": "Clients",
      "description": "Публичная проверка номера в Kaspi."
    },
    {
      "name": "Subscriptions",
      "description": "Рекуррентные подписки с автоматическим перевыставлением счетов."
    },
    {
      "name": "Sandbox",
      "description": "Симуляция жизненного цикла счетов и подписок (только при sandbox_mode=true)."
    },
    {
      "name": "Cashbox",
      "description": "Касса Kaspi — смены, сводка по наличным, сверка наших счетов с кассой, PDF-отчёт, тумблеры (автозакрытие/автоизъятие) и async-закрытие смены. Работают у организаций, к аккаунту Kaspi Pay которых подключена касса Kaspi (ОФД) — та же связка, что включает каталог товаров; без неё GET /cashbox/shifts и POST /cashbox/shifts/close отвечают 409 cashbox_kkm_unknown, а GET /cashbox/summary и оба тумблера — 409 rfo_missing. У кассовых эндпоинтов отдельный минутный лимит — 30 запросов/мин на ключ. В песочнице касса отвечает детерминированными данными по вашим sandbox-счетам, к Kaspi не обращается, подключённого кассира и кассы Kaspi не требует — идентификаторы смен там синтетические, поэтому интеграцию, прошедшую в песочнице, проверяйте и на боевой организации."
    },
    {
      "name": "Webhooks",
      "description": "Исходящие вебхук-события (секция x-webhooks) + read-only логи их доставки (/webhook-logs). HMAC-подпись по сырому телу, таймстампы UTC +00:00."
    }
  ],
  "paths": {
    "/status": {
      "get": {
        "tags": [
          "System"
        ],
        "summary": "Health-check",
        "description": "Проверка доступности API. **Без авторизации** (`X-API-Key` не требуется).\nПодпадает под общий гостевой лимит `60 запросов/мин на IP`.\n",
        "operationId": "getStatus",
        "security": [],
        "responses": {
          "200": {
            "description": "Сервис доступен",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "example": "ok"
                    },
                    "timestamp": {
                      "type": "string",
                      "format": "date-time",
                      "description": "Текущее время сервера, ISO 8601 UTC (+00:00).",
                      "example": "2026-07-05T14:30:00+00:00"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/invoices": {
      "get": {
        "tags": [
          "Invoices"
        ],
        "summary": "Список счетов",
        "description": "Пагинированный список счетов организации с фильтрацией и сортировкой.\nПагинация — **плоская** `{current_page, data, total}` (не meta-обёртка).\nДаты счетов в ОТВЕТЕ — UTC `+00:00` (как у всех ресурсов, см. «Форматы дат»).\n\nИтоги по смене (продажи/возвраты/выручка и сверка с цифрами Kaspi) отдаёт\nотдельный `GET /api/v1/cashbox/reconciliation`.\n\n⚠️ **Список — не полная выручка организации.** Счета, выставленные через ApiPay,\nпопадают в него сразу. Продажи, проведённые в приложении Kaspi Pay мимо ApiPay —\nнапример другим кассиром, — подтягиваются из истории Kaspi: не мгновенно и не всегда\nполностью. Подтянутая продажа датируется временем самой операции и появляется внутри\nуже пройденного периода: для отчётности перезапрашивайте окно, а не только новые\nзаписи. Отделить одно от другого можно фильтром `origin`.\n\n**Окно `date_from`/`date_to` задаётся в зоне мерчанта (Asia/Almaty)**, а не в UTC:\n\n- голая дата `2026-08-01` — это КАЛЕНДАРНЫЕ СУТКИ мерчанта целиком\n  (`date_to` растягивается до `23:59:59`);\n- дата со временем — точный момент; минуты и секунды опциональны, поэтому\n  `2026-08-01 11`, `2026-08-01 11:00` и `2026-08-01 11:00:00` означают одно и то же.\n  Так задаётся смена, переходящая через полночь\n  (`date_from=2026-08-03 11&date_to=2026-08-04 03`);\n- явный офсет (`2026-08-01T11:00:00+03:00`, `...Z`) уважается как указано.\n  В query-строке `+` нужно кодировать как `%2B`.\n\nОбе границы включительные. ⚠️ Растягивается до конца периода ТОЛЬКО голая дата:\n`date_to=2026-08-01 18` — это `18:00:00`, а не конец восемнадцатого часа.\n",
        "operationId": "listInvoices",
        "parameters": [
          {
            "name": "search",
            "in": "query",
            "description": "Поиск по описанию, телефону, external_order_id.",
            "schema": {
              "type": "string",
              "maxLength": 100
            }
          },
          {
            "name": "status[]",
            "in": "query",
            "description": "Фильтр по статусам (можно несколько значений).\n`partially_refunded` — счёт, по которому уже был частичный возврат: деньги он\nпринял, поэтому фильтр «покажи оплаченные» без него неполон.\n⚠️ Для сверки кассы фильтруйте по обоим статусам сразу\n(`status[]=paid&status[]=partially_refunded`) либо берите итоги из\n`GET /api/v1/cashbox/reconciliation`.\n",
            "schema": {
              "type": "array",
              "items": {
                "type": "string",
                "enum": [
                  "processing",
                  "pending",
                  "cancelling",
                  "paid",
                  "cancelled",
                  "expired",
                  "partially_refunded"
                ]
              }
            }
          },
          {
            "name": "origin",
            "in": "query",
            "description": "Происхождение счёта.\n`all` — всё подряд (по умолчанию).\n`apipay` — счёт выставлен через ApiPay: кабинетом, API-ключом, подпиской или печатным QR.\n`kaspi` — продажа проведена в приложении Kaspi Pay мимо ApiPay и подтянута из истории Kaspi.\nПоловины не пересекаются: `apipay` + `kaspi` в одном окне дают ровно `all`.\n",
            "schema": {
              "type": "string",
              "enum": [
                "all",
                "apipay",
                "kaspi"
              ],
              "default": "all"
            }
          },
          {
            "name": "date_from",
            "in": "query",
            "description": "Начало окна включительно — `Y-m-d` или `Y-m-d H[:ii[:ss]]` (зона Almaty).",
            "example": "2026-08-03 11:00",
            "schema": {
              "type": "string",
              "pattern": "^\\d{4}-\\d{2}-\\d{2}([ T]\\d{2}(:\\d{2}(:\\d{2}(\\.\\d{1,6})?)?)?(Z|[+-]\\d{2}:?\\d{2})?)?$"
            }
          },
          {
            "name": "date_to",
            "in": "query",
            "description": "Конец окна включительно, тот же формат. Голая дата = конец суток, время = ровно этот момент. Должен быть >= date_from.",
            "example": "2026-08-04 03:00",
            "schema": {
              "type": "string",
              "pattern": "^\\d{4}-\\d{2}-\\d{2}([ T]\\d{2}(:\\d{2}(:\\d{2}(\\.\\d{1,6})?)?)?(Z|[+-]\\d{2}:?\\d{2})?)?$"
            }
          },
          {
            "name": "date_field",
            "in": "query",
            "description": "По какому времени резать окно.\n`created_at` — момент выставления счёта (по умолчанию).\n`paid_at` — момент оплаты; так операции раскладывает по сменам терминал, поэтому\nдля сверки кассы используйте его. Неоплаченные счета при этом отсеиваются.\n",
            "schema": {
              "type": "string",
              "enum": [
                "created_at",
                "paid_at"
              ],
              "default": "created_at"
            }
          },
          {
            "name": "sort_by",
            "in": "query",
            "description": "Поле сортировки (невалидное значение → created_at).",
            "schema": {
              "type": "string",
              "enum": [
                "id",
                "amount",
                "client_name",
                "status",
                "created_at",
                "paid_at"
              ],
              "default": "created_at"
            }
          },
          {
            "name": "sort_order",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ],
              "default": "desc"
            }
          },
          {
            "name": "per_page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 10
            }
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 1
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Список счетов",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "current_page": {
                      "type": "integer",
                      "example": 1
                    },
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/InvoiceListItem"
                      }
                    },
                    "total": {
                      "type": "integer",
                      "description": "Всего счетов под фильтром (для пагинации).",
                      "example": 48
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "post": {
        "tags": [
          "Invoices"
        ],
        "summary": "Создать счёт",
        "description": "Создаёт счёт на оплату по номеру телефона. Обработка **асинхронная**: ответ `201`\nприходит со `status: \"processing\"` (Kaspi ещё не вызван), финальный статус\n(`pending`/`error`) — вебхуком `invoice.status_changed` или поллингом\n`GET /invoices/{id}`. Не пересоздавайте счёт, пока он в `processing`.\n\nДва режима суммы: без корзины (`amount`) или с корзиной (`cart_items`, сумма\nсчитается сервером). Организация с каталогом может выставить счёт и одной суммой,\nбез `cart_items`. Обратное неверно: организация без каталога с `cart_items`\nполучит `422` с `error_code: catalog_not_supported`.\nТак же ведёт себя `POST /invoices/bulk` — per-item, тем же кодом.\nДля `POST /invoices/qr` и `POST /static-qr` правило другое — там у организации\nс каталогом корзина обязательна.\n\n⛔ **Сумма — только целые тенге.** Счёт на номер телефона принимает\nисключительно целое число тенге; дробная сумма (`175.74`) отбивается сразу с\n`422 amount_must_be_whole_tenge`, и это относится и к итогу корзины. Проверяется\n**итог после скидок**: `discount_percentage` считается построчно, поэтому даже при\nцелых ценах итог может стать дробным (`999 ₸` со скидкой `10 %` → `899.10`) —\nокругляйте цены позиций или процент скидки. Минимальная сумма — `1`. Нужны тиыны —\nвыставляйте через `POST /invoices/qr`: там дробные суммы принимаются и оплачиваются.\n",
        "operationId": "createInvoice",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateInvoiceRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Счёт создан (status=processing)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InvoiceCreated"
                }
              }
            }
          },
          "400": {
            "description": "Организация не найдена/не верифицирована, лимит sandbox или сессия Kaspi не настроена",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "examples": {
                  "organization_required": {
                    "value": {
                      "error": "Organization not found or not verified",
                      "message": "Please verify an organization before creating invoices."
                    }
                  },
                  "kaspi_session_not_configured": {
                    "value": {
                      "error": "kaspi_session_not_configured",
                      "message": "Kaspi session is not configured. Please contact support."
                    }
                  },
                  "sandbox_invoice_limit": {
                    "value": {
                      "error": "sandbox_invoice_limit",
                      "message": "Maximum sandbox invoices limit reached (1000)."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`kyc_rejected` — мерчант отклонён на KYC-модерации; `tariff_inactive` — подписка на\nApiPay не активна (продлите тариф в кабинете). Грейс-периода у тарифа нет —\nблокировка наступает сразу после `expires_at`, который приходит в теле ответа.\n",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/TariffInactiveResponse"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "error": {
                          "type": "string",
                          "example": "kyc_rejected"
                        },
                        "error_code": {
                          "$ref": "#/components/schemas/ErrorCode"
                        },
                        "message": {
                          "type": "string",
                          "example": "Выставление счетов ограничено. Обратитесь в поддержку."
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "409": {
            "description": "`duplicate_idempotency_key` — дубль ключа идемпотентности (external_order_id_idempotency\nуже использован); `kaspi_session_expired` — сессия кассира Kaspi мертва, счёт не создан\n(нужна переавторизация кассира, ретрай не поможет).\n",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/DuplicateIdempotencyResponse"
                    },
                    {
                      "$ref": "#/components/schemas/SessionExpiredResponse"
                    }
                  ]
                }
              }
            }
          },
          "422": {
            "description": "Ошибка валидации. Особые случаи: `connection_ambiguous` (>1 активной connection\nбез primary, укажите `kaspi_connection_id`); ошибки скидки/каталога;\n`content_rejected` (контент-скрин, только в режиме block);\n`amount_must_be_whole_tenge` (сумма с тиынами по телефонному счёту не\nпринимается; округлите либо используйте `POST /invoices/qr`);\nпозиция корзины с `operation=delete` — `errors[\"cart_items.N.catalog_item_id\"]`,\nотдельного `error_code` у этой ветки нет (см. `CartItem.catalog_item_id`);\n`field_too_long` (страховочный код: значение поля превышает допустимую длину —\nукоротите его; имя поля в `errors`, повтор с тем же телом бесполезен);\n`description_too_long` (описание длиннее, чем Kaspi показывает покупателю — с\n2026-09-05 для всех, для организаций с 26.08.2026 уже сейчас).\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationErrorBody"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimitCap"
          },
          "503": {
            "$ref": "#/components/responses/InvoiceCreationUnavailable"
          }
        }
      }
    },
    "/invoices/bulk": {
      "post": {
        "tags": [
          "Invoices"
        ],
        "summary": "Пакетное создание счетов",
        "description": "Создаёт до 100 счетов одним запросом на **одного кассира** (`kaspi_connection_id`\nобщий на всю пачку; по умолчанию — primary). Доп. лимит **20/мин**.\nСтруктурная ошибка тела (невалидная схема) → `422`\nотклоняет весь батч атомарно; бизнес-ошибки отдельных позиций возвращаются\nпоэлементно в `invoices[]` (created/duplicate/failed), сам батч при этом `201`.\n\n⚠️ **Неактивный тариф — исключение из поэлементной модели:** `tariff_inactive`\nотбивает **весь запрос** `403`, а не приходит в `failed[]`. Причина не в конкретной\nпозиции, а в самой организации, поэтому запрос закрывается на входе.\n\n⛔ **Сумма — только целые тенге** (как и в `POST /invoices`). Позиция с тиынами\nприходит в `invoices[]` как `failed` с `error_code: amount_must_be_whole_tenge`;\nостальные позиции батча создаются нормально.\n\n⛔ **Товар в снятии тоже отбивается поэлементно.** Счёт с позицией корзины, над\nкоторой открыто снятие (`operation=delete`, `sellable=false`), приходит в\n`invoices[]` как `failed` с\n`error_code: catalog_item_not_found` и текстом `Catalog item is being deleted…`\nв `message`; остальные позиции батча создаются. Позиция восстановима — см.\n`CartItem.catalog_item_id`.\n",
        "operationId": "createInvoicesBulk",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "invoices"
                ],
                "properties": {
                  "kaspi_connection_id": {
                    "type": "integer",
                    "nullable": true,
                    "description": "Кассир на всю пачку (default — primary)."
                  },
                  "invoices": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 100,
                    "description": "Список счетов (1–100).",
                    "items": {
                      "$ref": "#/components/schemas/BulkInvoiceItem"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Батч обработан (см. поэлементные результаты). Отказ отдельной позиции не отменяет\nостальные и не меняет код ответа — разбирайте `invoices[]` построчно.\nСреди построчных `error_code` может прийти `field_too_long`: значение поля превышает\nдопустимую длину, эту позицию нужно повторить с укороченным значением,\nсоседние счета при этом уже созданы.\n",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "created": {
                      "type": "integer",
                      "example": 8
                    },
                    "duplicates": {
                      "type": "integer",
                      "example": 1
                    },
                    "failed": {
                      "type": "integer",
                      "example": 1
                    },
                    "invoices": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/BulkInvoiceResult"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "organization_required / sandbox_invoice_limit / not-verified / kaspi_session_not_configured",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TariffInactive"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitCap"
          },
          "503": {
            "$ref": "#/components/responses/InvoiceCreationUnavailable"
          }
        }
      }
    },
    "/invoices/qr": {
      "post": {
        "tags": [
          "Invoices"
        ],
        "summary": "Создать QR-счёт",
        "description": "Создаёт счёт для оплаты по QR-коду на экране кассы — без номера телефона.\n**Синхронный**: ответ `201` сразу со `status: \"pending\"` и QR-полями\n(pending-вебхука для QR нет). Покупатель сканирует QR приложением Kaspi.\n\nЖизненный цикл QR — **минуты**, не 24ч. `qr_expires_at` — момент, до которого\nQR ещё можно **отсканировать**; длительность окна считайте из ответа, а не\nконстантой. ⚠️ Окно ограничивает только скан: как только покупатель отсканировал\nQR и попал на экран оплаты, операция живёт дольше, и оплата, начатая под конец\nокна, завершится уже после `qr_expires_at`.\nТерминальный статус (`paid`/`cancelled`/`expired`) ставит Kaspi и приносит вебхук —\n**ориентируйтесь на него, а не на локальный отсчёт**. QR-счета **сосуществуют**: создание нового QR на той же кассе\nНЕ отменяет прежние — старый остаётся `pending` и мониторится до своего терминала.\nРеагируйте по каждому `invoice.id` отдельно. `cancelled` по QR = реальная отмена\nклиентом. Когда клиент отсканировал QR — приходит событие `invoice.qr_scanned`\n(`qr_substate: \"scanned\"`, `status` остаётся `pending`). Отмена QR-счёта не\nподдерживается; возврат — через отдельную ветку `POST /qr-refunds` (клиент\nсканирует возвратный QR).\n\n⛔ **Позиция корзины с `operation=delete` отбивается `422`** —\n`errors[\"cart_items.N.catalog_item_id\"]`, отдельного `error_code` у этой ветки нет.\nКак вернуть позицию — см. `CartItem.catalog_item_id`.\n",
        "operationId": "createQrInvoice",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateQrInvoiceRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "QR-счёт создан (status=pending)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QrInvoiceCreated"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`kyc_rejected` — мерчант отклонён на KYC-модерации; `tariff_inactive` — подписка на\nApiPay не активна (продлите тариф в кабинете). Грейс-периода у тарифа нет —\nблокировка наступает сразу после `expires_at`, который приходит в теле ответа.\n",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/TariffInactiveResponse"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "error": {
                          "type": "string",
                          "example": "kyc_rejected"
                        },
                        "error_code": {
                          "$ref": "#/components/schemas/ErrorCode"
                        },
                        "message": {
                          "type": "string",
                          "example": "Выставление счетов ограничено. Обратитесь в поддержку."
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "409": {
            "description": "`duplicate_idempotency_key` (дубль ключа); `kaspi_session_expired` (сессия кассира\nмертва — QR не создан, нужна переавторизация).\n",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/DuplicateIdempotencyResponse"
                    },
                    {
                      "$ref": "#/components/schemas/SessionExpiredResponse"
                    }
                  ]
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "description": "`qr_rate_limit` (60/мин на организацию) либо cap-лимит (`trial_daily_limit`/\n`tariff_limit_reached`/`outstanding_*`/`kyc_daily_limit_reached`) с\n`retry_after_seconds` и заголовком `Retry-After`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "500": {
            "description": "qr_render_failed — не удалось отрендерить PNG QR (также шлётся вебхук error)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "502": {
            "description": "kaspi_error — ошибка Kaspi при создании QR (также шлётся вебхук error)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/InvoiceCreationUnavailable"
          }
        }
      }
    },
    "/invoices/stats": {
      "get": {
        "tags": [
          "Invoices"
        ],
        "summary": "Статистика счетов",
        "description": "Агрегированная статистика счетов за период. Укажите **либо** `period`, **либо**\nпару `start_date`+`end_date` (иначе `422`). `pending_*` включает счета в статусе\n`processing`. Поля `total_collected` **нет** — сумма оплаченного это `paid_amount`.\n\nГраницы задаются в зоне мерчанта (Asia/Almaty): голая дата = календарные сутки\nцеликом, `Y-m-d H:i` = точная минута. По какому времени резать окно — выбирает\n`date_field` (по умолчанию `created_at`).\nЯвный офсет уважается как указан; в query-строке `+` кодируется как `%2B`.\n\nРучка принимает те же фильтры, что и список: `status[]`, `search`, `date_field`,\n`origin` — чтобы сводка совпадала с таблицей под ней. Границы периода при этом\nзадаются ТОЛЬКО парой `start_date`/`end_date` (или `period`): `date_from`/`date_to`\nот списка здесь не применяются.\n\n⚠️ **Фильтр сужает саму базу агрегата, а не выделяет строки в нём.** При\n`status[]=paid` поля `cancelled_*` и `expired_*` станут нулями, а `conversion_rate` —\n`100`. Это ожидаемо: вы спросили статистику по подмножеству.\n\n⚠️ `date_field=paid_at` отсеивает неоплаченные счета — у них `paid_at` пуст. Конверсия\nна таком окне тоже вырождается в `100`.\n\n⚠️ `paid_amount`, `paid_invoices` и `conversion_rate` здесь считают только статус\n`paid` и **не включают** `partially_refunded` — у мерчанта с частичными возвратами\nконверсия визуально занижена. Опаснее всего это в паре с фильтром: запрос\n`status[]=paid&status[]=partially_refunded` даст `total_invoices`, куда частично\nвозвращённые входят, и `paid_invoices`, куда они не входят, — то есть конверсию\nзаведомо ниже 100 % на выборке, где оплачены все. Для сверки кассы используйте\n`GET /api/v1/cashbox/reconciliation` — там раскладка кассового отчёта и сверка\nс цифрами Kaspi.\n",
        "operationId": "getInvoiceStats",
        "parameters": [
          {
            "name": "period",
            "in": "query",
            "description": "Предустановленный период (взаимоисключимо с start_date/end_date).",
            "schema": {
              "type": "string",
              "enum": [
                "today",
                "week",
                "month",
                "year"
              ]
            }
          },
          {
            "name": "start_date",
            "in": "query",
            "description": "Начало периода — `Y-m-d` или `Y-m-d H[:ii[:ss]]` (зона Almaty). Требует end_date.",
            "example": "2026-08-03 11:00",
            "schema": {
              "type": "string",
              "pattern": "^\\d{4}-\\d{2}-\\d{2}([ T]\\d{2}(:\\d{2}(:\\d{2}(\\.\\d{1,6})?)?)?(Z|[+-]\\d{2}:?\\d{2})?)?$"
            }
          },
          {
            "name": "end_date",
            "in": "query",
            "description": "Конец периода включительно, тот же формат. Голая дата = конец суток, время = ровно этот момент. Должен быть >= start_date.",
            "example": "2026-08-04 03:00",
            "schema": {
              "type": "string",
              "pattern": "^\\d{4}-\\d{2}-\\d{2}([ T]\\d{2}(:\\d{2}(:\\d{2}(\\.\\d{1,6})?)?)?(Z|[+-]\\d{2}:?\\d{2})?)?$"
            }
          },
          {
            "name": "status[]",
            "in": "query",
            "description": "Фильтр по статусам — те же значения, что у `GET /invoices`. ⚠️ Сужает базу\nагрегата целиком: поля по неотобранным статусам станут нулями.\n",
            "schema": {
              "type": "array",
              "items": {
                "type": "string",
                "enum": [
                  "processing",
                  "pending",
                  "cancelling",
                  "paid",
                  "cancelled",
                  "expired",
                  "partially_refunded"
                ]
              }
            }
          },
          {
            "name": "search",
            "in": "query",
            "description": "Поиск, тот же что у `GET /invoices` (описание, телефон, external_order_id и прочее).",
            "schema": {
              "type": "string",
              "maxLength": 100
            }
          },
          {
            "name": "date_field",
            "in": "query",
            "description": "По какому времени резать период.\n`created_at` — момент выставления счёта (по умолчанию).\n`paid_at` — момент оплаты; неоплаченные счета при этом отсеиваются.\n",
            "schema": {
              "type": "string",
              "enum": [
                "created_at",
                "paid_at"
              ],
              "default": "created_at"
            }
          },
          {
            "name": "origin",
            "in": "query",
            "description": "Происхождение счёта, та же ось, что у `GET /invoices`.\n`all` (по умолчанию) / `apipay` (выставлено через ApiPay) / `kaspi` (проведено\nв приложении Kaspi Pay мимо ApiPay и подтянуто из истории Kaspi).\n",
            "schema": {
              "type": "string",
              "enum": [
                "all",
                "apipay",
                "kaspi"
              ],
              "default": "all"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Статистика",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InvoiceStats"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "description": "Не указан ни period, ни пара дат (или невалидный диапазон)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationErrorBody"
                }
              }
            }
          }
        }
      }
    },
    "/invoices/{id}": {
      "get": {
        "tags": [
          "Invoices"
        ],
        "summary": "Получить счёт",
        "description": "Полный объект счёта с позициями. Доп. лимит **1000/мин**\nвместо общего 200/мин — под поллинг 1С без вебхуков.\nЧитает из БД/кэша, не бьёт в Kaspi (терминальные статусы кэшируются 24ч).\nСтатуса `refunded` не существует — полный возврат оставляет `paid` +\n`is_fully_refunded=true`.\n",
        "operationId": "getInvoice",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Данные счёта",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Invoice"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Счёт не найден",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "Invoice not found"
                    }
                  }
                }
              }
            }
          }
        }
      },
      "patch": {
        "tags": [
          "Invoices"
        ],
        "summary": "Изменить внутреннюю заметку счёта",
        "description": "Правит **только** `internal_comment` — внутреннюю заметку мерчанта («кто это /\nчто это»). Заметка в Kaspi не передаётся, плательщик её не видит, в чек она не\nпопадает; на сумму, статус и возвраты не влияет. Другие поля счёта этим методом\nне редактируются.\n\nДоступно в **любом** статусе, включая `paid` и `expired`: закрытый счёт всё ещё\nможно разметить — в том числе счета, приехавшие синком из истории Kaspi.\n\n`null` или пустая строка стирают заметку. Тело **без ключа** `internal_comment` →\n`422` (молчаливого «ничего не изменил» нет). Вебхук эта операция не порождает —\nновое значение уедет со следующим штатным событием по счёту.\n",
        "operationId": "updateInvoiceInternalComment",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateInvoiceInternalCommentRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Заметка сохранена; в теле — счёт целиком (та же форма, что GET /invoices/{id})",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Invoice"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TariffInactive"
          },
          "404": {
            "description": "Счёт не найден",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "Invoice not found"
                    }
                  }
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/invoices/{id}/cancel": {
      "post": {
        "tags": [
          "Invoices"
        ],
        "summary": "Отменить счёт",
        "description": "Отменяет счёт в статусе `pending` или `processing`. Sandbox / счёт без\n`kaspi_invoice_id` → `200` (синхронно) + вебхук `cancelled`. Production → `202`,\nстатус `cancelling` — **не считайте счёт отменённым сразу**. Итог придёт вебхуком\n(`cancelled` — отмена прошла, либо `error` с `invoice_already_paid`/\n`invoice_already_cancelled`/`invoice_not_found_in_kaspi`). Если Kaspi отказал\n(обычно счёт уже оплачен) — счёт тихо вернётся в `pending`, реальный статус\n(обычно `paid`) доставит sync.\n\n⛔ **Отмена QR-счёта (`is_qr_token: true`) не поддерживается — `409 qr_cancel_unsupported`.**\nЗапрос отклоняется сразу: статус счёта не меняется, в Kaspi ничего не уходит.\nQR перестаёт быть оплачиваемым по истечении окна на скан и уезжает в `expired` —\nточный момент указан в поле `expires_at` ответа, длительность окна константой не\nзашивайте. Нужен другой счёт — просто выставьте новый: QR сосуществуют, старый\nне мешает.\n",
        "operationId": "cancelInvoice",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Счёт отменён синхронно (sandbox / без kaspi_invoice_id)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": {
                      "type": "string",
                      "example": "Invoice cancelled"
                    },
                    "invoice": {
                      "$ref": "#/components/schemas/Invoice"
                    }
                  }
                }
              }
            }
          },
          "202": {
            "description": "Отмена поставлена в очередь (production)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": {
                      "type": "string",
                      "example": "Invoice cancellation queued"
                    },
                    "invoice_id": {
                      "type": "integer",
                      "example": 42
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Счёт нельзя отменить (не в статусе pending/processing)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TariffInactive"
          },
          "404": {
            "description": "Счёт не найден"
          },
          "409": {
            "description": "`qr_cancel_unsupported` — счёт выставлен по QR, отмена для него не\nподдерживается. Статус счёта не изменён. Дождитесь `expired` (момент — в\n`expires_at`).\n",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ErrorResponse"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "expires_at": {
                          "type": "string",
                          "format": "date-time",
                          "nullable": true,
                          "description": "Момент, после которого QR перестанет быть оплачиваемым; совпадает с `qr_expires_at` счёта. Если пришёл `null` — ориентируйтесь на вебхук перехода в `expired`, а не на локальный отсчёт.",
                          "example": "2026-08-11T08:00:41+00:00"
                        }
                      }
                    }
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/invoices/{id}/refund": {
      "post": {
        "tags": [
          "Invoices"
        ],
        "summary": "Создать возврат",
        "description": "Полный или частичный возврат по оплаченному счёту. Без `amount` — полный возврат.\nОтвет `201` = возврат принят и поставлен в очередь (`status: pending`). Итог —\nвебхук `invoice.refunded` (`completed`/`failed`, причина в `refund.error_code`).\nДля счетов с корзиной можно указать `return_items` — на позицию **ровно одно** из\nполей `count` (целые штуки) ИЛИ `amount` (произвольная сумма); оба или ни одного → `422`.\n",
        "operationId": "createRefund",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateRefundRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Возврат создан и поставлен в очередь",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": {
                      "type": "string",
                      "example": "Refund queued for processing"
                    },
                    "refund": {
                      "$ref": "#/components/schemas/RefundShort"
                    },
                    "invoice": {
                      "type": "object",
                      "properties": {
                        "id": {
                          "type": "integer",
                          "example": 42
                        },
                        "amount": {
                          "type": "string",
                          "example": "15000.00"
                        },
                        "total_refunded": {
                          "type": "string",
                          "example": "5000.00"
                        },
                        "available_for_refund": {
                          "type": "number",
                          "description": "Сумма, доступная для возврата (число, не строка).",
                          "example": 10000
                        },
                        "pending_refund_amount": {
                          "type": "number",
                          "description": "Сумма ожидающих возвратов (число, не строка).",
                          "example": 0
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Нельзя сделать возврат (счёт не оплачен / полностью возвращён / превышает доступное)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TariffInactive"
          },
          "404": {
            "description": "Счёт не найден"
          },
          "422": {
            "description": "refund_window_expired / контент-скрин / ошибки return_items",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationErrorBody"
                }
              }
            }
          }
        }
      }
    },
    "/invoices/{id}/refunds": {
      "get": {
        "tags": [
          "Invoices"
        ],
        "summary": "Возвраты по счёту",
        "description": "Список возвратов конкретного счёта с агрегатами по счёту. Даты — UTC +00:00.",
        "operationId": "listInvoiceRefunds",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Возвраты счёта",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "invoice": {
                      "type": "object",
                      "properties": {
                        "id": {
                          "type": "integer",
                          "example": 42
                        },
                        "amount": {
                          "type": "string",
                          "example": "15000.00"
                        },
                        "total_refunded": {
                          "type": "string",
                          "example": "5000.00"
                        },
                        "available_for_refund": {
                          "type": "number",
                          "example": 10000
                        },
                        "is_fully_refunded": {
                          "type": "boolean",
                          "example": false
                        }
                      }
                    },
                    "refunds": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Refund"
                      }
                    },
                    "total": {
                      "type": "integer",
                      "example": 1
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Счёт не найден"
          }
        }
      }
    },
    "/invoices/{id}/receipt": {
      "get": {
        "tags": [
          "Invoices"
        ],
        "summary": "Чек Kaspi по оплаченному счёту",
        "description": "Ссылки на чек Kaspi по оплаченному счёту: `share_link` — то, что отдают\nпокупателю, `download_link` — прямой PDF (обе несут секретный `hash`),\n`receipt_link` — фискальная форма чека, секрета не несёт.\n\nЭто чек Kaspi по оплате счёта. Фискальные чеки за наличные и POS другого банка —\nотдельный раздел `/receipts` (Kaspi OFD), к этой ручке отношения он не имеет.\n\n**Ответ асинхронный.** Первый вызов ставит задачу и отдаёт `202 {status:\"pending\"}` —\nповторите запрос через `poll_after` секунд. Как только чек получен, тот же URL\nотдаёт `200 {status:\"ready\"}`. Результат кэшируется: чек неизменен, поэтому\nповторный вызов по тому же счёту отвечает сразу.\n\nДоступно по счетам в статусе `paid` и `partially_refunded` (частичный возврат —\nэто по-прежнему оплаченный счёт). Тот же `409` приходит, если у оплаченного счёта\nещё нет числового идентификатора Kaspi. В песочнице возвращаются детерминированные\nссылки-заглушки, помеченные `sandbox=1`.\n\n⚠️ `download_link` и `share_link` содержат секретный параметр `hash` — по нему\nчек откроет кто угодно. Не публикуйте их и не кладите в логи. Выданную ссылку\nотозвать нельзя: если она утекла, закрыть доступ нечем.\n\n⚠️ У ручки отдельный минутный лимит — он строже общего.\n",
        "operationId": "getInvoiceReceipt",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Чек получен",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InvoiceReceipt"
                }
              }
            }
          },
          "202": {
            "description": "Чек запрашивается — повторите запрос через poll_after секунд",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "example": "pending"
                    },
                    "poll_after": {
                      "type": "integer",
                      "description": "Через сколько секунд повторить.",
                      "example": 2
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TariffInactive"
          },
          "404": {
            "description": "Счёт не найден",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "Invoice not found"
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "Счёт не оплачен либо сессия кассира недоступна",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "Слишком много запросов чеков",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "503": {
            "description": "Kaspi не отдал чек",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/invoices/status/check": {
      "post": {
        "tags": [
          "Invoices"
        ],
        "summary": "Массовая проверка статусов",
        "description": "Возвращает актуальные статусы нескольких счетов организации за один запрос.\n`invoice_ids.*` не проверяется на существование (анти-энумерация) — несуществующие\nи чужие ID молча не попадут в ответ. Скоуп — организация ключа.\n",
        "operationId": "bulkCheckInvoiceStatus",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "invoice_ids"
                ],
                "properties": {
                  "invoice_ids": {
                    "type": "array",
                    "description": "ID счетов для проверки. Отправляйте не более 100 ID за запрос, крупные списки разбивайте на части.",
                    "items": {
                      "type": "integer"
                    },
                    "example": [
                      42,
                      43,
                      44
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Актуальные статусы",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "invoices": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "integer",
                            "example": 42
                          },
                          "status": {
                            "type": "string",
                            "example": "paid"
                          },
                          "kaspi_invoice_id": {
                            "type": "string",
                            "nullable": true,
                            "example": "13234689513"
                          },
                          "amount": {
                            "type": "string",
                            "example": "5000.00"
                          },
                          "error_message": {
                            "type": "string",
                            "nullable": true
                          },
                          "updated_at": {
                            "type": "string",
                            "format": "date-time",
                            "example": "2026-01-10T12:00:00+00:00"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Организация не верифицирована",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/invoices/{invoice}/simulate-status": {
      "post": {
        "tags": [
          "Sandbox"
        ],
        "summary": "Симулировать статус счёта",
        "description": "**Только sandbox** (`is_sandbox=true` у счёта; в рабочем режиме недоступно\nни при каких условиях). Переводит sandbox-счёт из `pending` в\n`paid`/`cancelled`/`expired`/`error` и шлёт вебхук `invoice.status_changed`,\nлибо симулирует транзиентное событие `qr_scanned` (только для QR-счетов:\nстатус остаётся `pending`, уходит вебхук `invoice.qr_scanned` с\n`qr_substate: \"scanned\"`, повтор → `400 already_scanned`).\n\nПри `status=error` счёт получает `error_code: sandbox_simulated_error` и\n`error_message` (свой текст — через одноимённый параметр). Опционально можно\nзадать `kaspi_source_type`/`kaspi_sale_type` (иначе выбираются случайно при `paid`).\n\nRate-limit: 60/мин на ключ (отдельный бакет, не расходует общий лимит 200/мин).\n",
        "operationId": "simulateInvoiceStatus",
        "parameters": [
          {
            "name": "invoice",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "status"
                ],
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "paid",
                      "cancelled",
                      "expired",
                      "error",
                      "qr_scanned"
                    ]
                  },
                  "kaspi_source_type": {
                    "type": "string",
                    "nullable": true,
                    "enum": [
                      "GOLD",
                      "RED",
                      "LOAN",
                      "BUSINESSACCOUNT",
                      "BANKINTEGRATIONACCOUNT"
                    ]
                  },
                  "kaspi_sale_type": {
                    "type": "string",
                    "nullable": true,
                    "enum": [
                      "Remote",
                      "QR",
                      "Static",
                      "Restaurant"
                    ]
                  },
                  "error_message": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 255,
                    "description": "Кастомный текст ошибки (только при `status=error`; по умолчанию «Симулированная ошибка (sandbox).»)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Статус симулирован (для `qr_scanned` статус счёта остаётся `pending`)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": {
                      "type": "string",
                      "example": "Invoice status simulated"
                    },
                    "invoice": {
                      "$ref": "#/components/schemas/Invoice"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "invalid_status_transition (счёт не в pending), not_qr_invoice\n(`qr_scanned` для не-QR счёта), already_scanned (повторный `qr_scanned`)\nили organization_required\n",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "invalid_status_transition"
                    },
                    "message": {
                      "type": "string"
                    },
                    "current_status": {
                      "type": "string",
                      "example": "paid"
                    },
                    "allowed_from": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "example": [
                        "pending"
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "not_sandbox — симуляция доступна только для sandbox-счетов",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "404": {
            "description": "Счёт не найден",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/static-qr": {
      "post": {
        "tags": [
          "Static QR"
        ],
        "summary": "Создать печатный QR под сделку (отложенный счёт)",
        "description": "Создаёт печатный лист, привязанный к сделке. Kaspi **не вызывается** — счёт\nматериализуется при скане на `qr.apipay.kz/{token}` в контексте вашей организации\n(деньги и вебхук приходят вам штатно). Тело — как у счёта: `amount` **либо**\n`cart_items`. Ответ несёт `token` (кодируется в QR), `short_code` (печатается рядом\nдля ручного ввода), `print_url`, `qr_image_url` (готовый PNG).\nМодель: **одна сделка** (`single_use=true`) — после оплаты повторный скан показывает\n«Оплачено»; повторные сканы до оплаты переиспользуют живой Kaspi-QR (не плодят счета).\n\n**Несовместимость ловится здесь, а не при скане.** Лист печатают один раз, а\nматериализуется он спустя дни, поэтому всё, что сделало бы скан покупателя нерабочим\n(нет `cart_items` у мерчанта с каталогом, чужая/удалённая позиция каталога, чужой или\nнеактивный `kaspi_connection_id`, неоднозначный кассир, непроверенная организация,\nзабракованное `description`), отбивается **на генерации** — см. `400`/`422`.\n\n⚠️ **Проверка работает на момент генерации.** Если позиция каталога из `cart_items`\nпозже уйдёт на снятие (`status=deleting`) или будет снята (`status=deleted`), скан такого листа перестанет\nсоздавать счёт до возврата позиции — а переиздать напечатанный лист нельзя. Верните\nпозицию обычным `POST /catalog`, и лист снова заработает. Перед массовым удалением\nкаталога проверьте, какие позиции стоят в напечатанных листах.\n\nВ **sandbox** не проверяется только верификация организации (её счета в Kaspi не\nуходят) — проверки кассира и корзины действуют и в песочнице, в отличие от\n`POST /invoices/qr`, который в sandbox кассира не резолвит вовсе. Это сделано\nнамеренно: лист с чужим или неоднозначным адресатом сломался бы при скане, и\nпесочница нужна ровно для того, чтобы увидеть это до печати.\n\n⚠️ **Оплата с листа по номеру телефона требует целой суммы.** QR-ветка тиыны\nпринимает, телефонная — нет: лист с дробной `amount` или дробным итогом `cart_items`\nпокупателю по номеру телефона выставить не удастся (`422 amount_must_be_whole_tenge`),\nа переиздать напечатанный лист нельзя. Для листов, где нужна оплата по номеру,\nзадавайте целую сумму.\n",
        "operationId": "createStaticQr",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateStaticQrRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Лист создан",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StaticQrCreated"
                }
              }
            }
          },
          "400": {
            "description": "`Organization not found or not verified` — боевая (не sandbox) организация ещё не\nверифицирована, счёт по такому листу создать было бы нельзя. Форма ответа — как у\n`POST /invoices/qr`. ⚠️ В этом теле `error` несёт **человеческую фразу, а не slug**,\nи `error_code` отсутствует — не разбирайте `error` машинно (см. пример).\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": "Organization not found or not verified",
                  "message": "Please verify an organization before creating invoices."
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`static_qr_disabled` — фича выключена; `tariff_inactive` — подписка на ApiPay не активна.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "422": {
            "description": "Ошибки формы тела (`{message: \"Validation failed\", errors: {…}}`) плюс проверки\n«этот лист сможет материализоваться», выполняемые до печати:\n\n* мерчант **с** каталогом без `cart_items` либо **без** каталога с `cart_items` —\n  тело несёт `message` и `error_code` (`catalog_requires_cart_items` /\n  `catalog_not_supported`), но **без** `errors`, как на `POST /invoices/qr`;\n* `cart_items.*.catalog_item_id` не принадлежит вашей организации, имеет\n  `status=deleted`, стоит в очереди на снятие (`status=deleting`), имеет брошенное\n  создание (`status=failed` при `operation=create`) или без цены —\n  `errors[\"cart_items.0.catalog_item_id\"]` с тем же текстом, что вернуло бы\n  создание счёта (`Catalog item does not belong to your organization.` /\n  `… has been deleted.` / `Catalog item is being deleted…` /\n  `Catalog item was not created in Kaspi…` / `… has no price set.`). Позицию из\n  очереди снятия можно вернуть обычным `POST /catalog`, после чего она снова\n  принимается в корзину; брошенное создание возобновляется только\n  `PATCH /catalog/{id}`;\n* `kaspi_connection_id` чужой или неактивный — `errors.kaspi_connection_id`\n  (`422`, а не `404`: код ответа не раскрывает существование чужого кассира);\n* `connection_ambiguous` — у организации больше одного активного кассира и нет\n  primary; передайте `kaspi_connection_id` явно;\n* `description` забракован контент-скринингом (он становится именем позиции в\n  чеке Kaspi) — `errors.description` + `error_code`.\n\n⚠️ **Несуществующий** `catalog_item_id` / `kaspi_connection_id` отдаёт **ровно то\nже тело**, что чужой: ответ намеренно не различает «нет такого id» и «id чужой»,\nиначе по нему перебирались бы чужие сущности.\n\nКоды ветки: `catalog_requires_cart_items` и `catalog_not_supported` —\nте же, что на `POST /invoices/qr` (добавлены 11.08.2026 аддитивно,\n`message` не менялся).\n",
            "content": {
              "application/json": {
                "schema": {
                  "anyOf": [
                    {
                      "$ref": "#/components/schemas/ValidationErrorBody"
                    },
                    {
                      "$ref": "#/components/schemas/ErrorResponse"
                    }
                  ]
                },
                "examples": {
                  "form": {
                    "summary": "Ошибка формы тела / скоупа корзины или кассира",
                    "value": {
                      "message": "Validation failed",
                      "errors": {
                        "cart_items.0.catalog_item_id": [
                          "Catalog item does not belong to your organization."
                        ]
                      }
                    }
                  },
                  "cart_parity": {
                    "summary": "Паритет has_catalog ↔ cart_items (message + error_code, без errors)",
                    "value": {
                      "message": "This organization requires cart items. Include cart_items in request.",
                      "error_code": "catalog_requires_cart_items"
                    }
                  },
                  "connection_ambiguous": {
                    "summary": "Больше одного активного кассира без primary",
                    "value": {
                      "error": "connection_ambiguous",
                      "message": "Organization has multiple active connections. Please specify kaspi_connection_id."
                    }
                  }
                }
              }
            }
          }
        }
      },
      "get": {
        "tags": [
          "Static QR"
        ],
        "summary": "Список печатных QR организации",
        "description": "Печатные листы вашей организации (новые сверху). Плоская пагинация `page`/`per_page` (≤100).",
        "operationId": "listStaticQr",
        "parameters": [
          {
            "name": "per_page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            }
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "default": 1
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Список",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/StaticQrCreated"
                      }
                    },
                    "current_page": {
                      "type": "integer",
                      "example": 1
                    },
                    "last_page": {
                      "type": "integer",
                      "example": 1
                    },
                    "per_page": {
                      "type": "integer",
                      "example": 25
                    },
                    "total": {
                      "type": "integer",
                      "example": 3
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/static-qr/{id}": {
      "get": {
        "tags": [
          "Static QR"
        ],
        "summary": "Получить печатный QR под сделку",
        "description": "Карточка листа. Чужой id → `404` (non-enumeration).",
        "operationId": "getStaticQr",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Карточка листа",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StaticQrCreated"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Лист не найден",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      },
      "delete": {
        "tags": [
          "Static QR"
        ],
        "summary": "Отключить печатный QR под сделку",
        "description": "Помечает лист `disabled` — последующие сканы показывают «неактивно», новые счета не\nсоздаются. Уже созданные счета не трогаются. Чужой id → `404`.\n",
        "operationId": "disableStaticQr",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Лист отключён (возвращается карточка со status=disabled)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StaticQrCreated"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Лист не найден",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/receipts/preview": {
      "post": {
        "tags": [
          "Receipts"
        ],
        "summary": "Превью фискального чека",
        "description": "Синхронное превью чека (pre-details) перед выбиванием — для UI. Возвращает\nсписок строк `{Title, Subtitle, isBoldText}` (сумма, способ оплаты).\n\nДля оплат, НЕ прошедших через Kaspi QR: `payment_type=3` (наличные),\n`payment_type=5` (POS другого банка). `403 fiscal_receipts_disabled` — только\nкогда приостановлена запись через целевого кассира.\nSandbox отдаёт детерминированное превью без Kaspi.\n",
        "operationId": "previewReceipt",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "payment_type",
                  "total_price"
                ],
                "properties": {
                  "payment_type": {
                    "type": "integer",
                    "enum": [
                      3,
                      5
                    ],
                    "description": "3 — наличные, 5 — POS другого банка."
                  },
                  "total_price": {
                    "type": "number",
                    "description": "Сумма чека.",
                    "example": 10
                  },
                  "kaspi_connection_id": {
                    "type": "integer",
                    "nullable": true,
                    "description": "Конкретный кассир; по умолчанию — primary/единственный активный."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Превью",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "Title": {
                            "type": "string",
                            "example": "Способ оплаты"
                          },
                          "Subtitle": {
                            "type": "string",
                            "example": "Наличные"
                          },
                          "isBoldText": {
                            "type": "boolean",
                            "example": false
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Не определена организация (organization_required)"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`fiscal_receipts_disabled` — приостановлена запись через целевого кассира;\n`tariff_inactive` — подписка на ApiPay не активна (грейса нет, блок сразу\nпосле `expires_at`; тело несёт `expires_at`).\n"
          },
          "409": {
            "description": "Нет активного кассира (kaspi_session_not_configured)"
          },
          "422": {
            "description": "Неоднозначность кассира (connection_ambiguous) или невалидный payment_type/total_price"
          },
          "503": {
            "description": "Kaspi недоступен (receipt_preview_unavailable)"
          }
        }
      }
    },
    "/receipts": {
      "post": {
        "tags": [
          "Receipts"
        ],
        "summary": "Выбить фискальный чек",
        "description": "Асинхронно выбивает фискальный чек в Kaspi OFD для оплаты наличными\n(`payment_type=3`) или через POS другого банка (`payment_type=5`). Позиции —\nиз синхронизированного каталога по `catalog_item_id` (как `/invoices` with-cart);\nтолько фискально зарегистрированные (иначе `item_not_fiscal`).\n\nСоздаёт чек в статусе `pending` и ставит задачу выбивания. Клиент узнаёт итог\nчерез `GET /receipts/{id}` или вебхук `receipt.issued`/`receipt.failed`.\n\n**Идемпотентность:** `client_operation_id` уникален на организацию — повтор с тем\nже ключом не выбивает второй чек (`409 duplicate_client_operation_id`); повтор\nпосле `failed` разрешён. `403 fiscal_receipts_disabled` — только когда\nприостановлена запись через целевого кассира чека.\n\n**Песочница.** Чек зеркалит бой: позиция с `barcode`+`ntin` даёт `issued` с\nреальными суммами, позиция без `ntin` — `failed`/`item_not_fiscal`. Kaspi не\nвызывается, фискальный документ не пишется. Поле `simulate` (только sandbox,\nиначе `403 not_sandbox`) форсирует исход — так воспроизводятся `shift_closed`,\n`item_not_fiscal`, `receipt_kaspi_error`. Вебхуки `receipt.issued`/`receipt.failed`\nуходят при любом терминальном статусе чека.\n",
        "operationId": "createReceipt",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "payment_type",
                  "client_operation_id",
                  "cart_items"
                ],
                "properties": {
                  "payment_type": {
                    "type": "integer",
                    "enum": [
                      3,
                      5
                    ]
                  },
                  "client_operation_id": {
                    "type": "string",
                    "maxLength": 191,
                    "description": "Ключ идемпотентности (уникален на организацию)."
                  },
                  "kaspi_connection_id": {
                    "type": "integer",
                    "nullable": true
                  },
                  "received_amt": {
                    "type": "number",
                    "nullable": true,
                    "description": "Наличные: полученная сумма (>= итога, для сдачи). POS другого банка игнорируется (= итогу)."
                  },
                  "cart_items": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 100,
                    "items": {
                      "type": "object",
                      "required": [
                        "catalog_item_id",
                        "quantity"
                      ],
                      "properties": {
                        "catalog_item_id": {
                          "type": "integer",
                          "description": "ID позиции из GET /catalog."
                        },
                        "quantity": {
                          "type": "integer",
                          "minimum": 1
                        },
                        "price": {
                          "type": "number",
                          "nullable": true,
                          "description": "Цена за единицу; по умолчанию — selling_price из каталога."
                        }
                      }
                    }
                  },
                  "simulate": {
                    "type": "object",
                    "nullable": true,
                    "description": "**Только песочница** (боевая организация → `403 not_sandbox`). Форсирует\nисход чека, чтобы интегратор обкатал обработку ошибок.\n",
                    "required": [
                      "status"
                    ],
                    "properties": {
                      "status": {
                        "type": "string",
                        "enum": [
                          "issued",
                          "failed"
                        ],
                        "description": "issued — обычный sandbox-путь (корзина резолвится строго); failed — терминальный сбой."
                      },
                      "error_code": {
                        "type": "string",
                        "nullable": true,
                        "enum": [
                          "shift_closed",
                          "item_not_fiscal",
                          "receipt_kaspi_error"
                        ],
                        "description": "Код при status=failed; по умолчанию receipt_kaspi_error."
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Чек принят в обработку",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer"
                    },
                    "status": {
                      "type": "string",
                      "example": "pending"
                    },
                    "client_operation_id": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "organization_required"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`fiscal_receipts_disabled` — приостановлена запись через целевого кассира;\n`not_sandbox` — simulate прислан вне песочницы; `tariff_inactive` — подписка\nна ApiPay не активна.\n"
          },
          "409": {
            "description": "Дубль `client_operation_id` (`duplicate_client_operation_id`); нет кассира\n(`kaspi_session_not_configured`); `kaspi_session_expired` — сессия кассира мертва,\nчек не выбит (нужна переавторизация кассира).\n\nУ дубля в теле приходят `receipt_id` и `status` уже созданного чека — по ним\nчитайте его через `GET /receipts/{id}`, а не выбивайте заново.\n",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "duplicate_client_operation_id"
                    },
                    "message": {
                      "type": "string"
                    },
                    "receipt_id": {
                      "type": "integer",
                      "description": "Только у duplicate_client_operation_id: id ранее созданного чека.",
                      "example": 4412
                    },
                    "status": {
                      "type": "string",
                      "description": "Только у duplicate_client_operation_id: статус того чека.",
                      "example": "issued"
                    }
                  }
                }
              }
            }
          },
          "422": {
            "description": "Ошибка валидации / connection_ambiguous"
          }
        }
      },
      "get": {
        "tags": [
          "Receipts"
        ],
        "summary": "История фискальных чеков",
        "description": "Пагинированный список чеков организации, свежие сверху (`created_at DESC`).\nПагинация — **плоская** `{current_page, data, total}` (не meta-обёртка).\nЭлемент списка — та же форма, что у `GET /receipts/{id}`.\n\nВыборка скоупится режимом организации: боевая организация видит только боевые\nчеки, тестовая — только тестовые.\n\n**Чтение истории не гейтится ничем**: список уже выбитых чеков остаётся доступен\nвсегда. `403 fiscal_receipts_disabled` бывает только на `POST /receipts`\nи `POST /receipts/preview` — когда выбивание чеков через этого кассира\nприостановлено.\n",
        "operationId": "listReceipts",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "description": "Фильтр по статусу.",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "issued",
                "failed"
              ]
            }
          },
          {
            "name": "payment_type",
            "in": "query",
            "description": "3 — наличные, 5 — POS другого банка.",
            "schema": {
              "type": "integer",
              "enum": [
                3,
                5
              ]
            }
          },
          {
            "name": "invoice_id",
            "in": "query",
            "description": "Чеки, привязанные к конкретному счёту.",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "from",
            "in": "query",
            "description": "Нижняя граница `created_at` (дата или дата-время; минуты и секунды опциональны —\n`2026-07-13`, `2026-07-13 10`, `2026-07-13 10:30`). Голая дата и дата-время без\nсмещения трактуются в **Asia/Almaty** (+05:00) — календарный день мерчанта;\nявное смещение (`2026-07-13T10:00:00+03:00`, `...Z`) берётся как есть.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "to",
            "in": "query",
            "description": "Верхняя граница `created_at`; должна быть >= `from`, иначе `422`. Голая дата\n(`2026-07-13`) включает весь день целиком **по Asia/Almaty**; дата-время (минуты\nи секунды опциональны) означает ровно этот момент — `10` это `10:00:00`, а не\nконец часа. Без смещения трактуется в Asia/Almaty, с явным — берётся как есть.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "per_page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 20
            }
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "default": 1
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Список чеков",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "current_page": {
                      "type": "integer",
                      "example": 1
                    },
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/FiscalReceiptListItem"
                      }
                    },
                    "total": {
                      "type": "integer",
                      "example": 12
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "organization_required"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/receipts/{id}": {
      "get": {
        "tags": [
          "Receipts"
        ],
        "summary": "Статус фискального чека",
        "description": "Возвращает текущий статус чека и его реквизиты (`fpd`, `operation_id`, `link` —\nпубличная страница чека, открывается в обычном браузере, — `shift_number`).\n`status`: `pending` | `issued` | `failed`. Чужой `id` → `404`.\n",
        "operationId": "showReceipt",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Статус чека",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "pending",
                        "issued",
                        "failed"
                      ]
                    },
                    "payment_type": {
                      "type": "integer",
                      "enum": [
                        3,
                        5
                      ]
                    },
                    "invoice_id": {
                      "type": "integer",
                      "nullable": true
                    },
                    "client_operation_id": {
                      "type": "string",
                      "nullable": true
                    },
                    "total_price": {
                      "type": "string",
                      "nullable": true
                    },
                    "received_amt": {
                      "type": "string",
                      "nullable": true
                    },
                    "fpd": {
                      "type": "string",
                      "nullable": true
                    },
                    "operation_id": {
                      "type": "string",
                      "nullable": true
                    },
                    "operation_time": {
                      "type": "string",
                      "nullable": true
                    },
                    "shift_number": {
                      "type": "integer",
                      "nullable": true
                    },
                    "link": {
                      "type": "string",
                      "nullable": true,
                      "description": "Публичная страница чека — `receipt.kaspi.kz/web/fiscal?i={ФПД}&f={РНМ кассы}&s={сумма}&t={время операции}`, открывается в любом браузере. У чеков песочницы и в редком ответе Kaspi без РНМ кассы здесь остаётся внутренняя страница приложения Kaspi Pay (`/preview/cashier`), которая вне приложения чек не отрисовывает.",
                      "example": "https://receipt.kaspi.kz/web/fiscal?i=000000000000&f=000000000000&s=10&t=2026-07-12%2015%3A25%3A43"
                    },
                    "error_code": {
                      "type": "string",
                      "nullable": true
                    },
                    "error_message": {
                      "type": "string",
                      "nullable": true
                    },
                    "created_at": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Чек не найден (receipt_not_found)"
          }
        }
      }
    },
    "/qr-refunds": {
      "post": {
        "tags": [
          "QR Refunds"
        ],
        "summary": "Старт QR-возврата (немедленный, deprecated)",
        "deprecated": true,
        "description": "Создаёт сессию возврата и отдаёт возвратный QR (`qr_token_url`/`qr_image_url`),\nкоторый мерчант показывает клиенту прямо у кассы. Клиент сканирует → сессия переходит\nв `customer_identified` (вебхук `qr_refund.identified`), после чего доступен список\nего возвратных операций (`GET .../operations`).\n\n⚠️ **Окно скана — не более 90 секунд**, и это изменение поведения. Раньше `expires_at`\nбрался из `ExpireDate` Kaspi (около пяти минут), тогда как реальное окно составляло\n~90 секунд: интегратор видел живой QR у уже мёртвой сессии. Теперь `expires_at` —\nминимум из подсказки провайдера и нашего потолка, а `scan_wait_timeout_seconds`\nсообщает ДЕЙСТВУЮЩЕЕ окно. Просроченные `qr_token_url`/`qr_image_url` не выдаются ни\nв этом ответе, ни в `GET /qr-refunds/{id}`.\n\n⛔ Отправив запрос, повторять его нельзя: попытка одноразовая. `502\nqr_refund_activation_failed` означает «начать не удалось, нужен новый старт», а не\n«попробуйте ещё раз тем же запросом».\n\n**Deprecated** в пользу `POST /qr-refunds/links`: покупателя обычно нет рядом с\nкассой, и отсканировать QR с чужого экрана ему нечем. Эндпоинт остаётся рабочим и\nудаляться не планируется.\n",
        "operationId": "startQrRefund",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "kaspi_connection_id": {
                    "type": "integer",
                    "nullable": true,
                    "description": "Кассир; по умолчанию primary"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Сессия создана",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QrRefundSession"
                }
              }
            }
          },
          "202": {
            "description": "qr_refund_activation_in_progress — попытка потрачена, исход ещё не записан; повторять нельзя, состояние читать через GET /qr-refunds/{id}"
          },
          "400": {
            "description": "organization_required / organization_not_verified / kaspi_session_not_configured / sandbox_limit"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TariffInactive"
          },
          "409": {
            "description": "qr_return_scan_timeout — окно скана закрылось до выдачи QR"
          },
          "422": {
            "description": "connection_ambiguous / невалидный kaspi_connection_id"
          },
          "502": {
            "description": "qr_refund_activation_failed — провайдер не подтвердил старт; попытка потрачена"
          },
          "503": {
            "description": "kaspi_session_invalid — сессия кассира недоступна; qr_refund_temporarily_unavailable — операция временно недоступна (ни одного вызова провайдера не сделано)"
          }
        }
      }
    },
    "/qr-refunds/links": {
      "post": {
        "tags": [
          "QR Refunds"
        ],
        "summary": "Выпустить ссылку «Возврат ApiPay»",
        "description": "Создаёт одноразовую ссылку, которую мерчант отправляет покупателю. **Kaspi при этом\nне вызывается и таймер не идёт.** Покупатель открывает ссылку и нажимает кнопку —\nтолько тогда создаётся возвратный QR и начинается окно скана (не более 90 секунд).\n\n`customer_url` возвращается **ровно один раз**: в базе хранится только необратимый\nхеш, и повторно получить адрес нельзя ни через `GET /qr-refunds/{id}`, ни как-либо\nещё. Потеряли — выпускайте новую ссылку и отзывайте старую.\n\nСрок неактивированной ссылки — `link_expires_at` (24 часа) и не продлевается ничем.\nОдна ссылка материализует не более одной операции у Kaspi.\n\nДальнейшее состояние читается через `GET /qr-refunds/{id}`; денежное выполнение —\nчерез существующий `POST /qr-refunds/{id}/execute`. ⛔ Ссылка сама по себе денег не\nдвигает и прав на это не даёт.\n",
        "operationId": "issueQrRefundLink",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "kaspi_connection_id": {
                    "type": "integer",
                    "nullable": true,
                    "description": "Кассир; по умолчанию primary. Фиксируется при выпуске и не пересматривается"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Ссылка выпущена",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QrRefundLink"
                }
              }
            }
          },
          "400": {
            "description": "organization_required / organization_not_verified / kaspi_session_not_configured"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TariffInactive"
          },
          "422": {
            "description": "connection_ambiguous / невалидный kaspi_connection_id"
          },
          "429": {
            "description": "qr_refund_link_limit_reached — слишком много неиспользованных ссылок"
          },
          "503": {
            "description": "qr_refund_temporarily_unavailable / kaspi_session_invalid"
          }
        }
      }
    },
    "/qr-refunds/links/{id}": {
      "delete": {
        "tags": [
          "QR Refunds"
        ],
        "summary": "Отозвать неактивированную ссылку",
        "description": "Гасит ссылку, которую покупатель ещё не открыл. Повторный отзыв той же ссылки\nидемпотентен и возвращает тот же снимок.\n\n⛔ Отозвать уже активированную ссылку нельзя: `409 qr_refund_link_not_revocable`.\nПосле активации сессия живёт своим окном скана и завершится сама.\n\n⚠️ Роут доступен и при истёкшем тарифе: запереть дверь изнутри мерчант обязан мочь\nвсегда. Выпуск новой ссылки при этом остаётся платной операцией.\n",
        "operationId": "revokeQrRefundLink",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Ссылка отозвана: статус `expired`, `error_code` = `qr_refund_link_revoked`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QrRefundSession"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Ссылка не найдена (not_found) — чужая и несуществующая неотличимы"
          },
          "409": {
            "description": "qr_refund_link_not_revocable — ссылку уже открыли либо она терминальна"
          },
          "503": {
            "description": "qr_refund_temporarily_unavailable"
          }
        }
      }
    },
    "/qr-refunds/{id}": {
      "get": {
        "tags": [
          "QR Refunds"
        ],
        "summary": "Статус сессии QR-возврата",
        "operationId": "getQrRefund",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Снимок сессии",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QrRefundSession"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TariffInactive"
          },
          "404": {
            "description": "Сессия не найдена (not_found)"
          }
        }
      }
    },
    "/qr-refunds/{id}/operations": {
      "get": {
        "tags": [
          "QR Refunds"
        ],
        "summary": "Возвратные операции клиента",
        "description": "Доступно только после `customer_identified`. Возвращает операции клиента\n(возвраты, оплаты — с признаком `returnable`: `none`/`partial`/`full`). Keyset-\nпагинация через `cursor` (`has_more`/`next_cursor`).\n",
        "operationId": "listQrRefundOperations",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Список операций",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QrRefundOperationList"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TariffInactive"
          },
          "404": {
            "description": "not_found"
          },
          "409": {
            "description": "qr_refund_not_identified / qr_refund_expired / qr_refund_completed"
          }
        }
      }
    },
    "/qr-refunds/{id}/operations/{ref}": {
      "get": {
        "tags": [
          "QR Refunds"
        ],
        "summary": "Детали возвратной операции",
        "operationId": "getQrRefundOperation",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "ref",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Непрозрачный operation_ref из списка"
          }
        ],
        "responses": {
          "200": {
            "description": "Детали операции + возвратные позиции",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QrRefundOperationDetails"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TariffInactive"
          },
          "404": {
            "description": "not_found"
          },
          "409": {
            "description": "qr_refund_not_identified / qr_refund_expired / qr_refund_completed"
          },
          "422": {
            "description": "operation_not_returnable (в т.ч. невалидный ref)"
          }
        }
      }
    },
    "/qr-refunds/{id}/execute": {
      "post": {
        "tags": [
          "QR Refunds"
        ],
        "summary": "Выполнить возврат (синхронно)",
        "description": "Полный возврат — без `amount`/`items`. Частичный — `amount` ИЛИ `items`\n(взаимоисключимы).\n\n`200` = возврат выполнен и доказан (терминал), приходит вебхук `qr_refund.completed`.\n\n⛔ **Ошибки делятся на два класса, и разница принципиальна.**\n\nДо отправки денег (`403`, `409 qr_refund_not_identified|qr_refund_expired`, `422`,\n`503`) сессия остаётся `customer_identified` — денежный запрос не уходил, повторить\nс другой операцией или суммой можно.\n\nПосле отправки денег ответ всегда `202`, и повторять запрос **нельзя**:\n\n- `202 qr_refund_execution_uncertain` — ответ Kaspi не доказал ни успех, ни отказ\n  (обрыв, таймаут чтения, неопознанная форма). Kaspi мог возврат применить. В теле —\n  снимок сессии в статусе `execution_uncertain`, приходит вебхук\n  `qr_refund.execution_uncertain`;\n- `202 qr_refund_execution_result_unavailable` — исход сохранён, но состояние сессии\n  достоверно неизвестно. Снимка в теле нет намеренно.\n\nОба разбираются вручную через поддержку. `Retry-After` не отдаётся.\n\nПовторный `execute` поверх уже идущего возврата → `409 qr_refund_execution_in_progress`,\nповерх недоказанного → `409 qr_refund_execution_uncertain`. Денег ни один из них не\nдвигает.\n\nОтказ Kaspi «на счёте мерчанта нет денег на возврат» → `422 refund_insufficient_funds`:\nэто ошибка ДО отправки денег, сессия остаётся `customer_identified`, и после\nпополнения счёта возврат повторяется на ней же, пока не истёк срок идентификации.\n",
        "operationId": "executeQrRefund",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ExecuteQrRefundRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Возврат выполнен и доказан",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QrRefundExecuted"
                }
              }
            }
          },
          "202": {
            "description": "⛔ Возврат отправлен, исход не доказан. Повторять запрос НЕЛЬЗЯ.\n`qr_refund_execution_uncertain` — со снимком сессии;\n`qr_refund_execution_result_unavailable` — без снимка.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QrRefundExecutionAccepted"
                }
              }
            }
          },
          "400": {
            "description": "organization_not_verified"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "tariff_inactive / qr_refund_actor_no_longer_authorized / not_sandbox (поле simulate вне песочницы)"
          },
          "404": {
            "description": "not_found"
          },
          "409": {
            "description": "qr_refund_not_identified / qr_refund_expired / qr_refund_completed / qr_refund_execution_in_progress / qr_refund_execution_uncertain"
          },
          "422": {
            "description": "operation_not_returnable / refund_amount_exceeds_available / partial_refund_requires_return_items / refund_insufficient_funds"
          },
          "502": {
            "description": "kaspi_error — Kaspi недоступен либо ответил неразбираемо на ревалидации ДО отправки денег. Сессия остаётся customer_identified, повтор допустим"
          },
          "503": {
            "description": "qr_refund_execution_disabled / qr_refund_execution_context_unavailable / kaspi_session_invalid / kaspi_session_unavailable"
          }
        }
      }
    },
    "/qr-refunds/{id}/simulate": {
      "post": {
        "tags": [
          "Sandbox"
        ],
        "summary": "Симулировать переход QR-возврата (sandbox)",
        "description": "Только sandbox (боевая сессия → `403 not_sandbox`). `identified` — клиент\nотсканировал (client_name = \"Иван И.\"); `expired` — QR просрочен. «Не отсканировал,\nждёт» = начальное `awaiting_scan` (просто `GET /qr-refunds/{id}`, без simulate).\n",
        "operationId": "simulateQrRefund",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "event"
                ],
                "properties": {
                  "event": {
                    "type": "string",
                    "enum": [
                      "identified",
                      "expired"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Снимок сессии",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QrRefundSession"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "not_sandbox"
          },
          "404": {
            "description": "not_found"
          }
        }
      }
    },
    "/refunds": {
      "get": {
        "tags": [
          "Refunds"
        ],
        "summary": "Список возвратов",
        "description": "Все возвраты организации (по всем счетам) с фильтрацией. Пагинация — **плоская**\n`{current_page, data, total}`. Даты в ОТВЕТЕ — UTC `+00:00`.\n\nОкно `date_from`/`date_to` — по времени ОПЕРАЦИИ возврата, в зоне мерчанта\n(Asia/Almaty): голая дата = календарные сутки целиком, `Y-m-d H:i` = точная минута.\n",
        "operationId": "listRefunds",
        "parameters": [
          {
            "name": "status[]",
            "in": "query",
            "description": "Фильтр по статусам возврата.",
            "schema": {
              "type": "array",
              "items": {
                "type": "string",
                "enum": [
                  "pending",
                  "processing",
                  "completed",
                  "failed"
                ]
              }
            }
          },
          {
            "name": "invoice_id",
            "in": "query",
            "description": "Фильтр по ID счёта (должен существовать).",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "date_from",
            "in": "query",
            "description": "Начало окна включительно — `Y-m-d` или `Y-m-d H[:ii[:ss]]` (зона Almaty).",
            "example": "2026-08-03 11:00",
            "schema": {
              "type": "string",
              "pattern": "^\\d{4}-\\d{2}-\\d{2}([ T]\\d{2}(:\\d{2}(:\\d{2}(\\.\\d{1,6})?)?)?(Z|[+-]\\d{2}:?\\d{2})?)?$"
            }
          },
          {
            "name": "date_to",
            "in": "query",
            "description": "Конец окна включительно, тот же формат. Голая дата = конец суток, время = ровно этот момент. Должен быть >= date_from.",
            "example": "2026-08-04 03:00",
            "schema": {
              "type": "string",
              "pattern": "^\\d{4}-\\d{2}-\\d{2}([ T]\\d{2}(:\\d{2}(:\\d{2}(\\.\\d{1,6})?)?)?(Z|[+-]\\d{2}:?\\d{2})?)?$"
            }
          },
          {
            "name": "per_page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 10
            }
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 1
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Список возвратов",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "current_page": {
                      "type": "integer",
                      "example": 1
                    },
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/RefundWithInvoice"
                      }
                    },
                    "total": {
                      "type": "integer",
                      "example": 25
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/webhook-logs": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Логи доставки вебхуков",
        "description": "Read-only список доставок вебхуков своей организации — для верификации,\nчто вебхук по счёту/событию реально ушёл и что ответил приёмник (в т.ч.\nдля автономных агент-циклов в sandbox). Пагинация — **плоская**\n`{current_page, data, total}`. Ре-отправка (retry) здесь недоступна —\nтолько в кабинете.\n",
        "operationId": "listWebhookLogs",
        "parameters": [
          {
            "name": "invoice_id",
            "in": "query",
            "description": "Фильтр по ID счёта.",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "event",
            "in": "query",
            "description": "Фильтр по событию (например `invoice.status_changed`, `invoice.qr_scanned`, `invoice.refunded`).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "status",
            "in": "query",
            "description": "Статус доставки.",
            "schema": {
              "type": "string",
              "enum": [
                "success",
                "failed"
              ]
            }
          },
          {
            "name": "date_from",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "date_to",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "sort_by",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "created_at",
                "response_time_ms",
                "response_status"
              ],
              "default": "created_at"
            }
          },
          {
            "name": "sort_order",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ],
              "default": "desc"
            }
          },
          {
            "name": "per_page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 20
            }
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 1
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Список доставок (пустой, если у ключа нет организации)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "current_page": {
                      "type": "integer",
                      "example": 1
                    },
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/WebhookLogEntry"
                      }
                    },
                    "total": {
                      "type": "integer",
                      "example": 25
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/webhook-logs/{id}": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Одна доставка вебхука",
        "description": "Детали одной доставки (полные `request_body`/`response_body`). Лог чужой\nорганизации → `404` (non-enumeration).\n",
        "operationId": "getWebhookLog",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Доставка вебхука",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookLogEntry"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Лог не найден (или принадлежит другой организации)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": {
                      "type": "string",
                      "example": "Not found"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/catalog/webhook-logs": {
      "get": {
        "tags": [
          "Catalog"
        ],
        "summary": "Логи доставок catalog.item_processed",
        "description": "Read-only список доставок вебхука `catalog.item_processed` своей организации\n(отдельно от `/webhook-logs` — своя таблица, ротация **3 дня**). Пагинация\n**плоская** `{current_page, data, total}`. Все логи организации видны любому\nеё ключу (лог принадлежит организации; орг резолвится из ключа).\n",
        "operationId": "listCatalogWebhookLogs",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "description": "Итог доставки.",
            "schema": {
              "type": "string",
              "enum": [
                "success",
                "failed"
              ]
            }
          },
          {
            "name": "catalog_item_id",
            "in": "query",
            "description": "Фильтр по ID позиции каталога.",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "created_after",
            "in": "query",
            "description": "Только доставки не раньше указанного момента (ISO 8601).",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "sort_order",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ],
              "default": "desc"
            }
          },
          {
            "name": "per_page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 20
            }
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 1
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Список доставок (пустой, если у ключа нет организации)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "current_page": {
                      "type": "integer",
                      "example": 1
                    },
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CatalogWebhookLogEntry"
                      }
                    },
                    "total": {
                      "type": "integer",
                      "example": 25
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/catalog/queue": {
      "get": {
        "tags": [
          "Catalog"
        ],
        "summary": "Остаток очереди приёма каталога + ETA",
        "description": "Остаток **своей** pending-очереди приёма каталога (POST /catalog) со слим-полями +\nблок `queue` с честным ETA в минутах. ETA учитывает общую FIFO-очередь кассира\n(несколько организаций на одном кассире). Пагинация **плоская**\n`{current_page, data, total, updating, deleting, queue}`. `data`/`total` описывают\nтолько создание; открытые update/delete видны отдельными счётчиками. Скоуп —\nорганизация ключа (чужие строки недоступны). Rate-limit **600/min per key**\n(не жжёт общий лимит 200/min).\n",
        "operationId": "getCatalogQueue",
        "parameters": [
          {
            "name": "sort_order",
            "in": "query",
            "description": "FIFO — ближайшие к обработке первыми (asc).",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ],
              "default": "asc"
            }
          },
          {
            "name": "per_page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 20,
              "maximum": 200,
              "default": 100
            }
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "default": 1
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Остаток очереди (пустой, если у ключа нет организации → state=not_connected)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "current_page": {
                      "type": "integer",
                      "example": 1
                    },
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CatalogQueueItem"
                      }
                    },
                    "total": {
                      "type": "integer",
                      "example": 1234
                    },
                    "updating": {
                      "type": "integer",
                      "description": "Позиции с открытой правкой в Kaspi (`operation=update`). Зеркало уже содержит новое значение, поэтому только этот счётчик отличает «правка принята» от «правка подтверждена Kaspi».",
                      "example": 0
                    },
                    "deleting": {
                      "type": "integer",
                      "description": "Позиции, ожидающие СНЯТИЯ в Kaspi (очередь массового удаления). Считается отдельно: `data`/`total` описывают только приём, и без этого счётчика ответ выглядел бы «очередь пуста», пока тысячи позиций ждут удаления.",
                      "example": 0
                    },
                    "queue": {
                      "$ref": "#/components/schemas/CatalogQueueBlock"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/catalog/errors": {
      "get": {
        "tags": [
          "Catalog"
        ],
        "summary": "Ошибки приёма каталога",
        "description": "Failed-позиции приёма каталога своей организации с **обезличенными** текстами\nошибок (сырые ответы Kaspi не отдаются). Фильтр по периоду **отказа** (`failed_at`,\nто же поле отдаётся в каждой строке ответа); при отсутствии `from` — окно\n**последних 7 дней**. Пагинация **плоская** `{current_page, data, total}`.\nRate-limit **600/min per key** (не жжёт общий лимит 200/min).\n\n⛔ Фильтр `batch_id` удалён вместе с агрегатом партий и теперь **отклоняется явно**\n(`422 catalog_batch_filter_removed`), а не игнорируется: раньше он сужал выборку до\nпартии, и молчаливый игнор вернул бы все ошибки организации за окно — ответ,\nнеотличимый от правды. Отбирайте свои позиции окном `from`/`to` и собственным\nсписком `external_ref`.\n",
        "operationId": "getCatalogErrors",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "description": "Начало окна (включительно), по failed_at (момент отказа). Default — now-7d при отсутствии.\nМинуты и секунды опциональны (`2026-07-13`, `2026-07-13 10`, `2026-07-13 10:30`).\nГолая дата и дата-время без смещения трактуются в **Asia/Almaty** (+05:00) —\nкалендарный день мерчанта; явное смещение берётся как есть.\n",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "to",
            "in": "query",
            "description": "Конец окна (включительно). Должен быть >= from, иначе 422. Голая дата\n(`2026-07-13`) включает весь день целиком **по Asia/Almaty**; дата-время (минуты\nи секунды опциональны) означает ровно этот момент — `10` это `10:00:00`, а не\nконец часа. Без смещения трактуется в Asia/Almaty, с явным — берётся как есть.\n",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "sort_order",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ],
              "default": "desc"
            }
          },
          {
            "name": "per_page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 20,
              "maximum": 200,
              "default": 100
            }
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "default": 1
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Ошибки приёма (пустой, если у ключа нет организации)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "current_page": {
                      "type": "integer",
                      "example": 1
                    },
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CatalogErrorItem"
                      }
                    },
                    "total": {
                      "type": "integer",
                      "example": 999
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "description": "Ошибка валидации параметров ЛИБО удалённый фильтр `batch_id` (`error_code: catalog_batch_filter_removed`).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "catalog_batch_filter_removed"
                    },
                    "error_code": {
                      "$ref": "#/components/schemas/ErrorCode"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/tariff/plans": {
      "get": {
        "tags": [
          "Tariff & Account"
        ],
        "summary": "Каталог тарифов",
        "description": "Список тарифов (start/business/pro/pro_max) и планов (тариф × период 1/3/6 мес). Это подписка мерчанта на ApiPay.",
        "operationId": "getTariffPlans",
        "responses": {
          "200": {
            "description": "Тарифы и планы",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "tiers": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string",
                            "example": "start"
                          },
                          "label": {
                            "type": "string",
                            "example": "Старт"
                          },
                          "daily_limit": {
                            "type": "integer",
                            "nullable": true,
                            "description": "Лимит счетов в день из каталога тарифов (start 30, business 100, pro 300, pro_max 600). null — лимит в каталоге не задан, объём согласуется индивидуально; ни у одного из действующих тарифов null не встречается.",
                            "example": 30
                          },
                          "base_price": {
                            "type": "integer",
                            "example": 10000
                          }
                        }
                      }
                    },
                    "plans": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "tier_id": {
                            "type": "string",
                            "example": "start"
                          },
                          "period_months": {
                            "type": "integer",
                            "example": 3
                          },
                          "price": {
                            "type": "integer",
                            "example": 28500
                          },
                          "discount_percent": {
                            "type": "integer",
                            "example": 5
                          },
                          "label": {
                            "type": "string",
                            "example": "Старт, 3 месяца"
                          },
                          "price_per_month": {
                            "type": "number",
                            "example": 9500
                          }
                        }
                      }
                    },
                    "is_custom": {
                      "type": "boolean",
                      "description": "true — у организации индивидуальные условия. Строка её тарифа в\n`tiers[]` и планы этого тарифа в `plans[]` уже пересчитаны по\nдоговорённости (имя, `daily_limit`, `base_price`, `price`); остальные\nтарифы остаются каталожными. Скидок за длинный период у индивидуальной\nцены нет: цена периода = цена месяца × месяцев.\n",
                      "example": false
                    },
                    "can_change_tier": {
                      "type": "boolean",
                      "description": "false — сменить тариф самостоятельно нельзя, оплата другого тарифа\nотдаст `409 custom_tariff_locked`. Продление своего тарифа работает\nкак обычно.\n",
                      "example": true
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/tariff": {
      "get": {
        "tags": [
          "Tariff & Account"
        ],
        "summary": "Статус своей подписки на ApiPay",
        "description": "Снимок подписки мерчанта на ApiPay (lifecycle + суммы). Это подписочная плата\nмерчант→ApiPay, **не оборот мерчанта**. Даты — Asia/Almaty `+05:00`.\n",
        "operationId": "getTariff",
        "responses": {
          "200": {
            "description": "Снимок тарифа",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TariffSnapshot"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "description": "Организация не резолвится из ключа",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": {
                      "type": "string",
                      "example": "Organization not resolved"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/account/health": {
      "get": {
        "tags": [
          "Tariff & Account"
        ],
        "summary": "Health своего аккаунта",
        "description": "Состояние аккаунта: API, подключение кассира (в т.ч. `session_status` — так\nдетектится «слетела Kaspi-сессия» поллингом, без вебхука), тариф и признак\nнакопления счетов (`invoicing.accumulating` — активный кассир в hold-наборе,\nсчета копятся и в Kaspi не уходят). Даты — Asia/Almaty `+05:00`.\n",
        "operationId": "getAccountHealth",
        "responses": {
          "200": {
            "description": "Health аккаунта",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AccountHealth"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "description": "Организация не резолвится из ключа",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": {
                      "type": "string",
                      "example": "Organization not resolved"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/connections": {
      "get": {
        "tags": [
          "Connections"
        ],
        "summary": "Список кассиров",
        "description": "Кассиры организации ключа. Требует per-key флаг\n`can_manage_cashiers` (иначе `403 cashier_management_disabled`).\n",
        "operationId": "listConnections",
        "responses": {
          "200": {
            "description": "Список кассиров",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Connection"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/CashierManagementDisabled"
          }
        }
      },
      "post": {
        "tags": [
          "Connections"
        ],
        "summary": "Создать кассира",
        "description": "Создаёт pending-кассира (self-hosted) для последующей OTP-авторизации.\nKill-switch мультикассирности и инвариант primary — на сервере.\n",
        "operationId": "createConnection",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "label": {
                    "type": "string",
                    "nullable": true,
                    "minLength": 2,
                    "maxLength": 120,
                    "description": "Название кассира."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Кассир создан",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Connection"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/CashierManagementDisabled"
          },
          "422": {
            "description": "Гейт мультикассирности (запрет 2-го+ кассира) или валидация",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/connections/{connection}": {
      "put": {
        "tags": [
          "Connections"
        ],
        "summary": "Переименовать кассира",
        "operationId": "updateConnection",
        "parameters": [
          {
            "$ref": "#/components/parameters/ConnectionId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "label"
                ],
                "properties": {
                  "label": {
                    "type": "string",
                    "minLength": 2,
                    "maxLength": 120
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Кассир обновлён",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Connection"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/CashierManagementDisabled"
          },
          "404": {
            "$ref": "#/components/responses/ConnectionNotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "delete": {
        "tags": [
          "Connections"
        ],
        "summary": "Деактивировать кассира",
        "description": "Помечает кассира inactive (soft delete). Гарды последнего/primary кассира → 422.\n\n⚠️ Денежное последствие. После деактивации возвраты по счетам, оплаченным через этого\nкассира, через API не проходят: запрос принимается, но возврат завершается статусом\n`failed` — приходит вебхук `invoice.refunded` со `status: failed`. Такие возвраты\nпроводят вручную в приложении Kaspi Pay. Проведите нужные возвраты до деактивации.\n",
        "operationId": "deleteConnection",
        "parameters": [
          {
            "$ref": "#/components/parameters/ConnectionId"
          }
        ],
        "responses": {
          "200": {
            "description": "Кассир деактивирован",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean",
                      "example": true
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/CashierManagementDisabled"
          },
          "404": {
            "$ref": "#/components/responses/ConnectionNotFound"
          },
          "422": {
            "description": "Нельзя удалить последнего/primary кассира",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/connections/{connection}/primary": {
      "post": {
        "tags": [
          "Connections"
        ],
        "summary": "Назначить кассира основным",
        "operationId": "setPrimaryConnection",
        "parameters": [
          {
            "$ref": "#/components/parameters/ConnectionId"
          }
        ],
        "responses": {
          "200": {
            "description": "Кассир назначен primary",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Connection"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/CashierManagementDisabled"
          },
          "404": {
            "$ref": "#/components/responses/ConnectionNotFound"
          },
          "422": {
            "description": "`connection_identity_unverified` — connection ещё не подтвердила полную business identity и не может стать primary.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/connections/{connection}/auth/init": {
      "post": {
        "tags": [
          "Connections"
        ],
        "summary": "Старт авторизации кассира",
        "description": "Шаг 1 из 3 self-hosted авторизации. `force=true` — переавторизация поверх живой\nсессии (при слёте). Лимит **5/мин**. External-mode\nкассир → `409 external_mode_not_reauthable`. Тестовая (sandbox) организация\nпартнёра → `409 test_organization`: реальный кассир к ней не привязывается,\nпесочница проходится мок-контуром Partner API.\n",
        "operationId": "connectionAuthInit",
        "parameters": [
          {
            "$ref": "#/components/parameters/ConnectionId"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "force": {
                    "type": "boolean",
                    "default": false
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Процесс авторизации начат",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "process_id": {
                      "type": "string",
                      "example": "a1b2c3d4"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`cashier_management_disabled` — у ключа нет флага `can_manage_cashiers`.\n`kyc_required` — анкета «Расскажите о бизнесе» ещё не одобрена: подключить\nкассира можно после одобрения. Повтор до одобрения бесполезен. Переподключение\nуже привязанного кассира этим кодом не отбивается.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "examples": {
                  "cashier_management_disabled": {
                    "value": {
                      "error": "cashier_management_disabled",
                      "message": "This API key cannot manage cashiers"
                    }
                  },
                  "kyc_required": {
                    "value": {
                      "error": "kyc_required",
                      "message": "Business profile must be approved before connecting a cashier"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/ConnectionNotFound"
          },
          "409": {
            "description": "`external_mode_not_reauthable` — external-кассира нельзя переавторизовать\nчерез этот API. `test_organization` — организация тестовая (`is_test`),\nреальная авторизация кассира для неё запрещена: состояние постоянное,\nретрай бессмыслен.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "examples": {
                  "test_organization": {
                    "value": {
                      "error": "test_organization",
                      "message": "Test organization cannot authorize a real Kaspi cashier"
                    }
                  }
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/EntranceAuthDisabled"
          }
        }
      }
    },
    "/connections/{connection}/auth/send-phone": {
      "post": {
        "tags": [
          "Connections"
        ],
        "summary": "Отправить телефон кассира (SMS)",
        "description": "Шаг 2 из 3. Kaspi отправляет OTP на номер кассира. Лимит **5/мин**.",
        "operationId": "connectionAuthSendPhone",
        "parameters": [
          {
            "$ref": "#/components/parameters/ConnectionId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "phoneNumber"
                ],
                "properties": {
                  "phoneNumber": {
                    "type": "string",
                    "pattern": "^7\\d{10}$",
                    "description": "Телефон кассира, формат 7XXXXXXXXXX.",
                    "example": "77001234567"
                  },
                  "confirm_duplicate": {
                    "type": "boolean",
                    "default": false,
                    "description": "Подтверждение осознанной перепривязки: этот кассир уже подключён к ДРУГОЙ организации того же владельца. Без флага такой запрос отбивается `409 duplicate_cashier_confirm` с описанием существующей организации. Флаг действует только на организации того же владельца."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "SMS отправлена",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean",
                      "example": true
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`cashier_management_disabled` — у ключа нет флага `can_manage_cashiers`.\n`kyc_required` — анкета «Расскажите о бизнесе» ещё не одобрена: SMS кассиру\nне отправляется вовсе. Повтор до одобрения бесполезен; переподключение уже\nпривязанного кассира (тот же номер) этим кодом не отбивается.\n`cashier_change_requires_merchant` — ключ выдан партнёром на организацию, аккаунтом\nкоторой партнёр не владеет, и номер отличается от телефона текущего кассира.\nПереавторизация ТОГО ЖЕ кассира и первое подключение (кассира ещё нет) проходят;\nсмену кассира проводит сам мерчант — своей дверью либо по ссылке-приглашению.\nПовтор запроса не поможет.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "examples": {
                  "cashier_management_disabled": {
                    "value": {
                      "error": "cashier_management_disabled",
                      "message": "This API key cannot manage cashiers"
                    }
                  },
                  "kyc_required": {
                    "value": {
                      "error": "kyc_required",
                      "message": "Business profile must be approved before connecting a cashier"
                    }
                  },
                  "cashier_change_requires_merchant": {
                    "value": {
                      "error": "cashier_change_requires_merchant",
                      "message": "Changing the cashier requires the merchant"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/ConnectionNotFound"
          },
          "409": {
            "description": "`context_expired` — контекст процесса протух либо Kaspi его закрыл: нужен новый\n`init`, повторять `send-phone` бесполезно. `no_process` — авторизация не начата\n(или уже закрыта предыдущим терминальным исходом). `cashier_unavailable` —\nкассира сейчас нельзя подключить; причина намеренно не раскрывается, повтор\nне поможет, обратитесь в поддержку. `test_organization` — организация\nтестовая (`is_test`): реальный кассир к ней не привязывается.\n`duplicate_cashier_confirm` — кассир уже подключён к ДРУГОЙ\nорганизации ТОГО ЖЕ владельца; это не блокировка, а подтверждение:\nповторите запрос с `confirm_duplicate: true`, если перепривязка намеренная.\nТело содержит `can_override` и `existing_organization`\n(`connection_status` — статус коннекшна: active|inactive|pending|error).\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "examples": {
                  "context_expired": {
                    "value": {
                      "error": "context_expired",
                      "message": "Auth session expired — re-initialize"
                    }
                  },
                  "cashier_unavailable": {
                    "value": {
                      "error": "cashier_unavailable",
                      "message": "Cashier is unavailable for connection"
                    }
                  },
                  "duplicate_cashier_confirm": {
                    "value": {
                      "error": "duplicate_cashier_confirm",
                      "message": "Cashier is already linked to another organization of yours",
                      "can_override": true,
                      "existing_organization": {
                        "id": 42,
                        "name": "ТОО Example",
                        "connection_status": "inactive",
                        "invoices_count": 12
                      }
                    }
                  }
                }
              }
            }
          },
          "422": {
            "description": "Ошибка формата `phoneNumber` (стандартная Laravel-форма валидации) либо\n`not_cashier` (у номера есть пароль/роли сверх кассира), `not_registered`\n(номер не заведён кассиром в Kaspi).\n",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/ErrorResponse"
                    },
                    {
                      "$ref": "#/components/schemas/ValidationErrorBody"
                    }
                  ]
                },
                "examples": {
                  "not_cashier": {
                    "value": {
                      "error": "not_cashier",
                      "message": "Number is not a cashier-only account"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "`rate_limited` — за сутки с этого аккаунта пробовали слишком много РАЗНЫХ\nномеров кассиров. ⚠️ Окно СУТОЧНОЕ: `Retry-After` содержит секунды до\nобнуления счётчика (часы, не минуты) — повторять раньше бесполезно.\nУже подключённые кассиры этого владельца в счётчик не входят, поэтому\nпереавторизация рабочей точки лимитом не блокируется.\n",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Секунд до обнуления суточного счётчика"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "examples": {
                  "rate_limited": {
                    "value": {
                      "error": "rate_limited",
                      "message": "Too many cashier connection attempts — retry later"
                    }
                  }
                }
              }
            }
          },
          "502": {
            "description": "`sms_failed` — Kaspi не вернул экран ввода OTP.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "examples": {
                  "sms_failed": {
                    "value": {
                      "error": "sms_failed",
                      "message": "OTP screen was not returned"
                    }
                  }
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/CashierAuthUnavailable"
          }
        }
      }
    },
    "/connections/{connection}/auth/verify-otp": {
      "post": {
        "tags": [
          "Connections"
        ],
        "summary": "Подтвердить OTP кассира",
        "description": "Шаг 3 из 3 (point of no return). Лимит **10/мин**. При\nневерном OTP — `200` с `success:false` (можно повторить). При успехе — `success:true`\nи сессия кассира активирована.\n\n⚠️ **Не всякий неуспех повторяем.** Kaspi может код **принять** и увести на\nуниверсальную регистрацию («введите ИИН») — это значит, что номер не заведён\nкассиром. Тогда шаг отдаёт **`422 not_registered`**, а не `200`: сессия\nавторизации закрыта, повторять OTP или слать SMS заново бессмысленно —\nследующий `send-phone` вернёт `409 no_process`. Нужен новый `init` после того,\nкак номер добавлен кассиром в Kaspi Pay → Кассиры. Так же терминальны\n`409 context_expired` (Kaspi потерял контекст → новый `init`) и\n`503 kaspi_busy` (анти-абуз Kaspi → пауза по `Retry-After`, затем новый `init`).\n",
        "operationId": "connectionAuthVerifyOtp",
        "parameters": [
          {
            "$ref": "#/components/parameters/ConnectionId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "otp"
                ],
                "properties": {
                  "otp": {
                    "type": "string",
                    "description": "Код из SMS, 4–6 цифр.",
                    "example": "123456"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Результат проверки OTP (success true/false)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean",
                      "example": true
                    },
                    "mode": {
                      "type": "string",
                      "example": "self",
                      "description": "Только при success=true."
                    },
                    "org_name": {
                      "type": "string",
                      "nullable": true,
                      "description": "Только при success=true."
                    },
                    "phone": {
                      "type": "string",
                      "description": "Маскированный телефон кассира (при success=true).",
                      "example": "770****4567"
                    },
                    "sandbox_mode": {
                      "type": "boolean",
                      "example": false
                    },
                    "catalog_block_reason": {
                      "type": "string",
                      "nullable": true,
                      "enum": [
                        "idn_conflict",
                        "no_idn",
                        "no_kaspi_org_id",
                        "no_tradepoint"
                      ],
                      "example": null,
                      "description": "Почему каталог товаров этой организации не заработает; `null` — блокировки нет\n(или каталог организации не нужен). Отдаётся только при `success=true`.\n\n`idn_conflict` — legacy-маркер старого collision-flow; новые auth\nконфликты возвращаются отдельным HTTP 409 и этот marker не пишут;\nрешается поддержкой.\n`no_idn` — Kaspi не вернул БИН организации.\n`no_kaspi_org_id` — не получен контекст организации Kaspi.\n`no_tradepoint` — код торговой точки организации в Kaspi определить\nне удалось.\n\nВо всех четырёх случаях каталог останется пустым до вмешательства поддержки:\n`GET /catalog` будет отдавать `200` с пустым списком.\n\n⚠️ Список значений ОТКРЫТ и может пополняться. Обрабатывайте неизвестный код\nобщей веткой («каталог недоступен»), а не `switch` без `default`.\n"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`cashier_management_disabled` — у ключа нет флага `can_manage_cashiers`.\n`kyc_required` — терминально: анкета организации не одобрена (редкий случай —\nодобрение отозвали между `send-phone` и `verify-otp`). Kaspi код при этом уже\nпринял, но подключение не создаётся; сессия закрыта, нужен новый `init` после\nодобрения анкеты.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "examples": {
                  "cashier_management_disabled": {
                    "value": {
                      "error": "cashier_management_disabled",
                      "message": "This API key cannot manage cashiers"
                    }
                  },
                  "kyc_required": {
                    "value": {
                      "error": "kyc_required",
                      "message": "Business profile must be approved before connecting a cashier"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/ConnectionNotFound"
          },
          "409": {
            "description": "Терминально: `context_expired` — Kaspi потерял контекст процесса (нужен новый\n`init`, а не повтор OTP); `no_process` — авторизация не начата либо уже\nзакрыта предыдущим терминальным исходом; `test_organization` — организация\nтестовая (`is_test`), реальная авторизация кассира для неё запрещена;\n`organization_identity_conflict` — Kaspi вернул другую полную\nidentity либо та же пара уже закреплена за другой Organization.\nCashier auth не меняет владельца и не раскрывает данные чужой\nорганизации.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "examples": {
                  "context_expired": {
                    "value": {
                      "error": "context_expired",
                      "message": "Auth session expired — re-initialize"
                    }
                  },
                  "no_process": {
                    "value": {
                      "error": "no_process",
                      "message": "Auth session is not initialized"
                    }
                  },
                  "organization_identity_conflict": {
                    "value": {
                      "error": "organization_identity_conflict",
                      "message": "Kaspi organization identity conflicts with this organization"
                    }
                  }
                }
              }
            }
          },
          "422": {
            "description": "Ошибка формата `otp` (стандартная Laravel-форма валидации) либо терминальный\n`not_registered` — Kaspi принял код, но увёл на регистрацию пользователя:\nномер не заведён кассиром. Сессия закрыта, ретрай бессмыслен.\n",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/ErrorResponse"
                    },
                    {
                      "$ref": "#/components/schemas/ValidationErrorBody"
                    }
                  ]
                },
                "examples": {
                  "not_registered": {
                    "value": {
                      "error": "not_registered",
                      "message": "Number is not a registered Kaspi cashier"
                    }
                  }
                }
              }
            }
          },
          "502": {
            "description": "`organization_identity_unavailable` — Kaspi не вернул единую полную пару IDN + OrganizationId. Ни session, ни Organization не изменены.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/CashierAuthUnavailable"
          }
        }
      }
    },
    "/connections/{connection}/auth/status": {
      "get": {
        "tags": [
          "Connections"
        ],
        "summary": "Статус сессии кассира",
        "operationId": "connectionAuthStatus",
        "parameters": [
          {
            "$ref": "#/components/parameters/ConnectionId"
          }
        ],
        "responses": {
          "200": {
            "description": "Статус сессии",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "connection_id": {
                      "type": "integer",
                      "example": 7
                    },
                    "mode": {
                      "type": "string",
                      "example": "self"
                    },
                    "status": {
                      "type": "string",
                      "description": "Статус кассира (напр. active/pending/inactive).",
                      "example": "active"
                    },
                    "auth_status": {
                      "type": "string",
                      "description": "Статус self-сессии (none, если сессии нет).",
                      "example": "active"
                    },
                    "attempt_status": {
                      "type": "string",
                      "description": "Статус отдельной попытки авторизации; когда попытки нет — строка `none`, не null. Рабочая session в начале reauth не очищается. Значения контрактом не фиксированы — ветвитесь по `status`/`auth_status`, а не по этому полю.",
                      "example": "none"
                    },
                    "phone": {
                      "type": "string",
                      "nullable": true,
                      "description": "Маскированный телефон.",
                      "example": "770****4567"
                    },
                    "last_used_at": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true,
                      "example": "2026-01-10T12:00:00+00:00"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/CashierManagementDisabled"
          },
          "404": {
            "$ref": "#/components/responses/ConnectionNotFound"
          }
        }
      }
    },
    "/connections/{connection}/auth/logout": {
      "post": {
        "tags": [
          "Connections"
        ],
        "summary": "Отключить кассира (disconnect)",
        "description": "Отключает кассира: сессия и незавершённая попытка авторизации закрываются, подключение\nпереходит в `status: inactive` и `session_mode: external`, а `kaspi_user_id`\nосвобождается — тот же номер после этого можно подключить в другой организации.\nСтрока кассира остаётся в `GET /connections`: это не `DELETE /connections/{connection}`,\nи отключить можно и основного, и единственного кассира (у `DELETE` это `422`).\nИдемпотентно: повтор на уже отключённой строке → `200`. Отдельного лимита у метода нет —\nдействует общий лимит v1. После отключения `auth/init` снова подключает эту же строку.\n\n⚠️ Пока кассир отключён, приём платежей этой точкой не работает: создание счетов\nотвечает `kaspi_session_not_configured`.\n\n⚠️ Денежное последствие. После отключения возвраты по счетам, оплаченным через этого\nкассира, через API не проходят: запрос принимается, но возврат завершается статусом\n`failed` — приходит вебхук `invoice.refunded` со `status: failed`. Такие возвраты\nпроводят вручную в приложении Kaspi Pay. Проведите нужные возвраты до отключения.\n",
        "operationId": "connectionAuthLogout",
        "parameters": [
          {
            "$ref": "#/components/parameters/ConnectionId"
          }
        ],
        "responses": {
          "200": {
            "description": "Кассир отключён (или уже был отключён)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean",
                      "example": true
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/CashierManagementDisabled"
          },
          "404": {
            "$ref": "#/components/responses/ConnectionNotFound"
          }
        }
      }
    },
    "/catalog/units": {
      "get": {
        "tags": [
          "Catalog"
        ],
        "summary": "Единицы измерения",
        "description": "Список единиц измерения для товаров. Получите перед созданием товаров, чтобы использовать корректные unit_id.",
        "operationId": "listCatalogUnits",
        "responses": {
          "200": {
            "description": "Единицы измерения",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "integer",
                            "example": 1
                          },
                          "name": {
                            "type": "string",
                            "example": "штука"
                          },
                          "name_kaz": {
                            "type": "string",
                            "example": "дана"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/catalog": {
      "get": {
        "tags": [
          "Catalog"
        ],
        "summary": "Список товаров каталога",
        "description": "Товары каталога. **Четыре режима чтения** (по приоритету):\n1. **targeted** — `ntins[]`/`barcodes[]`/`ids[]`/`external_refs[]` (OR, все статусы,\n   суммарно ≤200 значений по 4 наборам) → форма `{data:[...]}` без пагинации\n   (точечное подтверждение отправленного набора);\n2. **incremental** — `updated_after` → инкрементальный экспорт изменённого;\n3. **keyset** — `cursor` (курсорная пагинация без deep-offset, для экспорта 100k+);\n4. **default (offset)** — постранично `page`/`per_page`.\n\n**Статусы (default = `active`):** без параметра `statuses[]` ЛЮБОЙ режим отдаёт\nтолько `active` — позиции, которые есть в каталоге и над которыми не висит\nнезакрытая операция создания/снятия. Остальные четыре значения запрашиваются явно\n(в т.ч. в incremental: `?updated_after=…&statuses[]=active&statuses[]=deleted`).\nЗапрос без параметров и `?statuses[]=active` дают ОДИН И ТОТ ЖЕ набор: скрытых осей\nу default нет.\n\n`status` — **производное** поле: оно вычисляется из состояния строки, а не хранится.\nПриоритет веток фиксирован (первое совпадение выигрывает):\n`failed` (операция брошена) → `pending` (открыто создание) → `deleting` (открыто\nснятие) → `deleted` (позиция снята) → `active`. Открытая операция проверяется ВЫШЕ\nсостояния позиции. Поэтому снятая позиция со следом брошенной операции читается как\n`failed`, а снятая позиция, для которой уже открыто повторное создание, — как\n`pending`: она создаётся заново (в каталоге Kaspi её сейчас нет), и вид намерения\nвиден в `operation`. Продавать её при этом можно — `sellable=true`, как и у любой\nпозиции, создание которой В РАБОТЕ. ⚠️ Если создание брошено после исчерпания попыток\n(`status=failed`), позиция НЕ продаётся: `sellable=false` — подтверждения Kaspi не\nбудет, пока вы не отправите `PATCH /catalog/{id}`.\n\nОсь операции отдаётся отдельно и ортогональна статусу:\n`operation=create|update|delete|null` плюс `error_code`/`error_message` при отказе.\nПродаваемость и наличие в каталоге Kaspi — отдельные булевы поля `sellable` и\n`in_kaspi_catalog` (см. схему ответа), выводить их из `status` не нужно.\n\n⛔ Фильтр `batch_id` удалён вместе с агрегатом партий и **отклоняется явно**\n(`422 catalog_batch_filter_removed`): молчаливый игнор вернул бы весь каталог вместо\nпозиций партии. Подтверждайте свой набор targeted-запросом по `external_refs[]`.\n\n**Призраки НЕ отдаются никогда:** строки `state='void'` (позиции, которых никогда\nне было в Kaspi) исключаются во всех режимах и не являются допустимым фильтром.\n\n**Лимиты targeted (явная ошибка вместо тихого усечения):** суммарное число значений\nпо `ntins[]`+`barcodes[]`+`ids[]`+`external_refs[]` ≤200, и число найденных СТРОК\nсоответствий ≤1000. Превышение любого → `422` с `error_code: catalog_match_overflow`.\n\nРежимы 2–4 отдают **meta-обёртку** `{data, links, meta}` (в keyset `meta` курсорная:\n`next_cursor`/`prev_cursor`, без `total`). Только для организаций с каталогом\n(иначе `400`/`404`). Даты — UTC `+00:00`.\n",
        "operationId": "listCatalogItems",
        "parameters": [
          {
            "name": "statuses[]",
            "in": "query",
            "description": "Фильтр по производному статусу позиции. Не передан → отдаётся только `active` (default) во всех режимах, включая targeted. Значения взаимоисключающие и покрывают весь каталог. Позиции, которых никогда не было в Kaspi, не отдаются ни при каком фильтре.",
            "schema": {
              "type": "array",
              "items": {
                "type": "string",
                "enum": [
                  "active",
                  "pending",
                  "deleting",
                  "deleted",
                  "failed"
                ]
              }
            }
          },
          {
            "name": "ntins[]",
            "in": "query",
            "description": "Targeted: НТИН'ы (≤200).",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            }
          },
          {
            "name": "barcodes[]",
            "in": "query",
            "description": "Targeted: штрихкоды (≤200).",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            }
          },
          {
            "name": "ids[]",
            "in": "query",
            "description": "Targeted: ID товаров (≤200).",
            "schema": {
              "type": "array",
              "items": {
                "type": "integer"
              }
            }
          },
          {
            "name": "external_refs[]",
            "in": "query",
            "description": "Targeted: клиентские ссылки external_ref (≤200).",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            }
          },
          {
            "name": "updated_after",
            "in": "query",
            "description": "Incremental: только изменённое после даты (вкл. удалённые).",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "description": "Keyset: курсор пагинации (из meta.next_cursor).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "search",
            "in": "query",
            "description": "Поиск по названию.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "barcode",
            "in": "query",
            "description": "Точный фильтр по штрихкоду.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "first_char",
            "in": "query",
            "description": "Фильтр по первой букве названия.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "without_ntin",
            "in": "query",
            "description": "`true` → только позиции без НТИН (`ntin` = null), независимо от наличия штрихкода — шире, чем поле ответа `ntin_missing` (то требует непустой `barcode`). Удобно считать «сколько осталось доделать» по `meta.total`. Компонуется со всеми режимами и фильтрами (`statuses[]`, `search` и т.д.).",
            "schema": {
              "type": "boolean",
              "default": false
            }
          },
          {
            "name": "source",
            "in": "query",
            "description": "`own` → только позиции, заведённые вами. По умолчанию параметр НЕ передаётся и список содержит ВЕСЬ каталог мерчанта — включая заведённое им самим и другими его интеграциями; чьё что, видно в поле `source` каждой позиции. Так и задумано: каталог принадлежит мерчанту, а «пустой» список толкнул бы вас залить всё заново и породить дубли. Других значений параметр не принимает.",
            "schema": {
              "type": "string",
              "enum": [
                "own"
              ]
            }
          },
          {
            "name": "per_page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 1
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Товары каталога (форма зависит от режима)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CatalogItem"
                      }
                    },
                    "links": {
                      "$ref": "#/components/schemas/PaginationLinks"
                    },
                    "meta": {
                      "$ref": "#/components/schemas/PaginationMeta"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Каталог не включён для организации"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Верифицированная организация не найдена"
          },
          "422": {
            "description": "Targeted-режим: суммарно значений >200 ИЛИ строк соответствий >1000 (`catalog_match_overflow`); неизвестное значение `statuses[]`; удалённый фильтр `batch_id` (`catalog_batch_filter_removed`).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "catalog_match_overflow"
                    },
                    "error_code": {
                      "$ref": "#/components/schemas/ErrorCode"
                    },
                    "message": {
                      "type": "string",
                      "example": "Слишком много значений для точечного запроса: 250 (лимит 200 суммарно по ntins/barcodes/ids/external_refs). Разбейте запрос на несколько батчей и повторите."
                    }
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "Catalog"
        ],
        "summary": "Создать товары каталога",
        "description": "Создаёт 1–100 товаров пакетно. Ответ всегда `202` — «принято»: в песочнице позиция\nвозвращается уже активной, в бою она получает статус `pending` и уезжает в Kaspi\nфоновой обработкой. `external_ref` — клиентская ссылка (например код 1С) для\nпоследующего точечного чтения (`?external_refs[]=`).\n\n**Валидация per-item lenient.** Каждая позиция валидируется отдельно: валидные\nобрабатываются штатно (`data[]`), невалидные попадают в `rejected[]` (см. ниже) и\nНЕ роняют весь запрос. Одна битая позиция больше не даёт `422` на весь батч. `422`\nостаётся ТОЛЬКО за структурными ошибками ЗАПРОСА: `items` отсутствует / не массив /\nпуст / больше лимита (100). Ключ `rejected[]` присутствует в ответе ВСЕГДА (пустой\n`[]`, когда все позиции валидны). Если ВСЕ позиции невалидны — ответ всё равно\nуспешный (`202`) с пустым `data[]` и полным `rejected[]`.\n\n**Match-and-merge работает и в песочнице** — это тот же обход решений, что в бою, с\nтеми же маркерами; отличается только доставка: без обращения к Kaspi и синхронно.\nПозиция возвращается сразу активной, переиздание удалённого по `external_ref`\nактивируется мгновенно (без фазы `pending`).\n\n**Match-and-merge (идемпотентная заливка).** Если позиция совпадает с уже\nсуществующим товаром, вместо дубля/ошибки возвращается ЖИВОЙ id существующего\nтовара с маркером `matched_existing: true` (id стабилен). Ярусы матча (по\nприоритету): (1) совпал `external_ref` → полный update имени+цены + дозаполнение\nпустых `ntin`/`gtin`/`barcode`; (2) совпал `barcode` ИЛИ `ntin` И совпало имя →\nupdate цены + дозаполнение пустых; (3) совпал `barcode` ИЛИ `ntin`, но имя другое\n(например разные размеры под одним штрихкодом — Kaspi считает это одним товаром) →\nматч БЕЗ перезаписи имени/цены, маркер `name_differs: true` (правьте имя явным\n`PATCH /catalog/{id}`). `external_ref` существующего товара НИКОГДА не\nперетирается. Повторная заливка того же каталога идемпотентна: все позиции\nвернутся `matched_existing: true`, новые строки не создаются. Дозаполненные поля\nуезжают в Kaspi асинхронно. **Используйте `external_ref` как ключ маппинга** (не\nbarcode/имя): на один barcode у мерчанта может быть несколько товаров.\n\n**Переиздание снятого / операция матча по `external_ref`:** если `external_ref`\nуказывает на снятый товар — он автоматически переиздаётся: ТА ЖЕ строка (тот же\n`id`) получает `operation: create` и заново отправляется в Kaspi. ⚠️ До подтверждения\nKaspi она читается как `status: pending` — она создаётся заново, и в каталоге Kaspi\nеё пока нет, но в счёт её ставить уже можно (`sellable=true`, как у любой позиции,\nсоздание которой в работе); вид намерения виден в `operation`. ⚠️ Брошенное создание\n(`status: failed`) продажу блокирует — `sellable=false`.\n\n**Что сделано с позицией — поле `outcome`.** `matched_existing: true` говорит только\n«мы нашли вашу строку» и НЕ говорит, открылась ли по ней работа. Что именно\nпроизошло, читайте в `outcome`: `created` — заведена новая строка; `matched` —\nсматчилась существующая, правка применена или поставлена в очередь; `reissued` —\nпереиздана снятая позиция; `unchanged` — строка уже совпадает с каталогом Kaspi,\nработа не нужна; `revived` — брошенное создание открыто заново; `not_started` —\nброшенное создание найдено, но работа НЕ открыта. Поле аддитивное: в остальных\nответах каталога оно `null`.\n\n**Повторная заливка брошенного создания (`status: failed`).** Она открывает работу\nзаново (`outcome: revived`, `status` снова `pending`) в двух случаях: прошлый отказ\nне был отказом Kaspi по существу позиции (проблемы связи и авторизации, недоставленная\nпартия) — либо вы прислали позицию с изменённым наименованием, ценой, штрихкодом,\nНТИН или GTIN, то есть исправили то, из-за чего она отбилась. Дословный повтор того же\nтела работу НЕ открывает: ответ придёт с `outcome: not_started`, прежними\n`status: failed`, `error_code` и `failed_at`, и Kaspi не будет позван.\nЧтобы дожать такую позицию принудительно, используйте `PATCH /catalog/{id}` (он же\n«Повторить» в кабинете): он исполняет сохранённую `operation` всегда, независимо от\nпричины отказа. ⚠️ Если вы шлёте `Idempotency-Key`, повтор ОБЯЗАН идти с НОВЫМ ключом:\nточный повтор тела со старым ключом отвечает `200` + `idempotent_replay: true` и до\nматчинга не доходит вовсе — ни реанимации, ни отказа вы не увидите.\n",
        "operationId": "createCatalogItems",
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "description": "Ключ идемпотентности приёма (≤191). Точный повтор того же тела в той же\nорганизации отвечает `200` с `idempotent_replay: true` и не выполняется снова.\nТот же ключ с другим телом или на `POST /catalog/bulk-delete` отвечает\n`409 idempotency_key_conflict`. Альтернатива — body-поле `idempotency_key`.\n",
            "schema": {
              "type": "string",
              "maxLength": 191
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "items"
                ],
                "properties": {
                  "idempotency_key": {
                    "type": "string",
                    "maxLength": 191,
                    "nullable": true,
                    "description": "Ключ идемпотентности (эквивалент заголовка Idempotency-Key). Точный повтор того же тела → `200` с `idempotent_replay: true`. Пространство ключей ОБЩЕЕ с массовым удалением: другое тело или операция отдаёт `409 idempotency_key_conflict`."
                  },
                  "items": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 100,
                    "items": {
                      "type": "object",
                      "required": [
                        "name",
                        "selling_price",
                        "unit_id"
                      ],
                      "properties": {
                        "name": {
                          "type": "string",
                          "maxLength": 255,
                          "example": "Coffee Latte"
                        },
                        "selling_price": {
                          "type": "number",
                          "minimum": 0.01,
                          "example": 1800
                        },
                        "unit_id": {
                          "type": "integer",
                          "example": 1
                        },
                        "image_id": {
                          "type": "string",
                          "format": "uuid",
                          "nullable": true,
                          "description": "ID изображения из upload-image (exists)."
                        },
                        "barcode": {
                          "type": "string",
                          "nullable": true,
                          "maxLength": 32
                        },
                        "ntin": {
                          "type": "string",
                          "nullable": true,
                          "maxLength": 50,
                          "description": "НТИН кандидата Нацкаталога (из POST /catalog/scan)."
                        },
                        "gtin": {
                          "type": "string",
                          "nullable": true,
                          "maxLength": 50,
                          "description": "GTIN кандидата Нацкаталога (GS1)."
                        },
                        "external_ref": {
                          "type": "string",
                          "nullable": true,
                          "maxLength": 191,
                          "description": "Клиентская ссылка (1С), UNIQUE в пределах боевой/песочной оси организации."
                        },
                        "from_catalog": {
                          "type": "boolean",
                          "nullable": true,
                          "default": false
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Точный повтор Idempotency-Key: запрос не выполнен снова",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CatalogItem"
                      },
                      "example": []
                    },
                    "rejected": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/RejectedCatalogItem"
                      },
                      "example": []
                    },
                    "idempotent_replay": {
                      "type": "boolean",
                      "example": true
                    }
                  }
                }
              }
            }
          },
          "202": {
            "description": "Принято: валидные товары созданы (невалидные — в rejected[]). В бою status=pending, operation=create до фонового подтверждения; в песочнице операция уже закрыта (status=active, operation=null).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CatalogItem"
                      }
                    },
                    "rejected": {
                      "type": "array",
                      "description": "Позиции, не прошедшие per-item валидацию (присутствует всегда, может быть пустым).",
                      "items": {
                        "$ref": "#/components/schemas/RejectedCatalogItem"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Лимит каталога в тестовом режиме (sandbox) превышен",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "examples": {
                  "sandbox_catalog_limit": {
                    "value": {
                      "error": "sandbox_catalog_limit",
                      "message": "Лимит каталога в тестовом режиме — 1000 позиций. Для большего каталога подключите платный тариф."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TariffInactive"
          },
          "409": {
            "description": "`catalog_busy` — каталог занят другой операцией, повторите через несколько секунд.\n`idempotency_key_conflict` — этот `Idempotency-Key` уже занят другим телом либо\nдругой каталожной операцией. Пространство ключей приёма и массового удаления\nобщее; возьмите новый ключ. Повторять изменённый запрос с тем же ключом бесполезно.\n",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "catalog_busy"
                    },
                    "error_code": {
                      "$ref": "#/components/schemas/ErrorCode"
                    },
                    "message": {
                      "type": "string",
                      "example": "Каталог занят другой операцией, повторите запрос через несколько секунд."
                    }
                  }
                },
                "examples": {
                  "catalog_busy": {
                    "value": {
                      "error": "catalog_busy",
                      "message": "Каталог занят другой операцией, повторите запрос через несколько секунд."
                    }
                  },
                  "idempotency_key_conflict": {
                    "value": {
                      "error": "idempotency_key_conflict",
                      "error_code": "idempotency_key_conflict",
                      "message": "Этот Idempotency-Key уже использован с другим телом или для другой операции. Возьмите новый ключ."
                    }
                  }
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "502": {
            "description": "Ошибка Kaspi API"
          },
          "503": {
            "description": "Kaspi сессия истекла"
          }
        }
      }
    },
    "/catalog/bulk-delete": {
      "post": {
        "tags": [
          "Catalog"
        ],
        "summary": "Массовое удаление позиций каталога",
        "description": "Снимает с продажи много позиций одним запросом. **POST, а не DELETE** — тело у\n`DELETE` плохо поддержано HTTP-клиентами 1С.\n\nЦели задаются ровно одним списком: `ids[]` либо `external_refs[]`. Оба ключа уникальны в\nпределах боевой/песочной оси организации, поэтому одно значение = не больше одной позиции.\nЛимит — **200** значений, превышение → `422 catalog_match_overflow`.\n⛔ `barcodes[]` не принимается: штрихкод не уникален, и одно значение могло бы снести сотни позиций.\nFilter-режима нет: сервер не додумывает разрушающее множество по неполной заливке.\n\n⛔ **Интеграция снимает только СВОИ позиции.** Каталог у мерчанта общий, и то, что\nзавёл он сам или другая его интеграция, одним вашим запросом не сносится. Такие\nидентификаторы вернутся в поле `not_yours` и останутся на месте — во всех телах ответа,\nвключая `dry_run`. Ключа самого мерчанта и запросов из кабинета это не касается.\n\n⚠️ **Это фоновая операция на СУТКИ.** Позиции снимаются с продажи по одной, и во\nвремя массового удаления заливка каталога идёт медленнее.\nПорядок величин: 500 позиций — около двух часов, несколько тысяч — около\nсуток с небольшим; при параллельной заливке умножьте примерно вчетверо.\nОтвет `202` — это «принято в работу», а не «удалено». Общего хендла операции нет:\nостаток смотрите в `GET /catalog/queue`, строки — через targeted `GET /catalog`,\nотказы — в `GET /catalog/errors`; итог каждой строки присылает\n`catalog.item_processed`. ⛔ Не ставьте на этот запрос HTTP-таймаут в расчёте на завершение.\n\n⚠️ **Позиции, попавшие в очередь снятия, перестают приниматься в корзину сразу** —\nвключая уже напечатанные листы `POST /static-qr`, где эти позиции зашиты в\n`cart_items`. Скан такого листа не создаст счёт, пока позиция не вернётся в продажу,\nа переиздать напечатанный лист нельзя. Если под удаление попадают позиции из\nдействующих листов, верните их через `POST /catalog` либо перевыпустите листы.\n\n**Полная синхронизация из 1С:** залейте актуальное, постройте у себя явный список ушедших\n`ids`/`external_refs`, проверьте каждый чанк через `dry_run`, затем поставьте его в очередь с уникальным\n`Idempotency-Key`. Необязательный `expected_count` из ответа разведки защитит от\nизменения множества между проверкой и запуском. Подробный рецепт — раздел «Массовое удаление» в документации каталога.\n\n⚠️ Позицию, попавшую в очередь удаления по ошибке, можно **вернуть**: пришлите её\nобычным `POST /catalog` — удаление отменится, и если товар ещё не снят в Kaspi, он\nпросто останется на месте. Если снятие уже прошло, позиция заводится заново: по\n`external_ref` вернётся та же строка (тот же `id`), а у позиции без `external_ref`,\nштрихкода и НТИН в каталоге появится новая строка.\n\n⛔ Возврат неоднозначен только если у позиции нет `external_ref`, а её штрихкод\nсовпадает с ДРУГИМ живым товаром: присланная позиция сольётся с живым товаром, а\nприговорённая уйдёт по вашему плану. Возврат по `external_ref` однозначен всегда.\nОтдельного отказа «снятие уже отправлено» больше нет: новое намерение принимается,\nа фон при необходимости переиздаёт позицию после подтверждения удаления.\n",
        "operationId": "bulkDeleteCatalogItems",
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 191
            },
            "description": "Точный повтор того же тела отвечает 200 с idempotent_replay=true и не удаляет снова. Другое тело либо ключ, занятый POST /catalog, → 409."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "ids": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    },
                    "description": "Режим списка: id позиций из GET /catalog."
                  },
                  "external_refs": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "maxLength": 191
                    },
                    "description": "Режим списка: клиентские ссылки (1С)."
                  },
                  "expected_count": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Необязательная сверка с would_delete из dry_run. Расхождение → 409, ничего не меняется."
                  },
                  "dry_run": {
                    "type": "boolean",
                    "description": "true — только посчитать и показать образец, ничего не менять и не расходовать Idempotency-Key."
                  },
                  "idempotency_key": {
                    "type": "string",
                    "maxLength": 191
                  }
                }
              },
              "examples": {
                "targeted": {
                  "summary": "Список идентификаторов",
                  "value": {
                    "ids": [
                      101,
                      102,
                      103
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Четыре РАЗНЫХ тела под одним кодом — различайте по наличию ключей:\n`dry_run: true` — разведка; `idempotent_replay: true` — точный повтор;\n`queued: 0` — удалять нечего; `deleted` — песочница (удалено синхронно).\n",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "type": "object",
                      "title": "DryRun",
                      "properties": {
                        "dry_run": {
                          "type": "boolean",
                          "example": true
                        },
                        "would_delete": {
                          "type": "integer",
                          "example": 412
                        },
                        "sample": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "id": {
                                "type": "integer"
                              },
                              "external_ref": {
                                "type": "string",
                                "nullable": true
                              },
                              "name": {
                                "type": "string"
                              }
                            }
                          }
                        },
                        "already_queued": {
                          "type": "integer",
                          "description": "⚠️ Здесь ЧИСЛО (историческая форма). В остальных телах — массив id."
                        },
                        "already_queued_count": {
                          "type": "integer"
                        },
                        "not_yours": {
                          "type": "array",
                          "items": {
                            "type": "integer"
                          },
                          "description": "Позиции запроса, которые НЕ заведены вашей интеграцией: их завёл сам мерчант, они приехали из каталога Kaspi либо их завела другая его интеграция. Удалены они не будут. Пусто, если запрос идёт ключом самого мерчанта или из кабинета — у них чужих позиций не бывает."
                        }
                      }
                    },
                    {
                      "type": "object",
                      "title": "IdempotentReplay",
                      "properties": {
                        "message": {
                          "type": "string",
                          "example": "Catalog request already accepted"
                        },
                        "idempotent_replay": {
                          "type": "boolean",
                          "example": true
                        }
                      }
                    },
                    {
                      "type": "object",
                      "title": "NothingToDelete",
                      "properties": {
                        "message": {
                          "type": "string",
                          "example": "Nothing to delete"
                        },
                        "queued": {
                          "type": "integer",
                          "example": 0
                        },
                        "already_queued": {
                          "type": "array",
                          "items": {
                            "type": "integer"
                          }
                        },
                        "already_queued_count": {
                          "type": "integer"
                        },
                        "not_yours": {
                          "type": "array",
                          "items": {
                            "type": "integer"
                          },
                          "description": "Позиции запроса, которые НЕ заведены вашей интеграцией: их завёл сам мерчант, они приехали из каталога Kaspi либо их завела другая его интеграция. Удалены они не будут. Пусто, если запрос идёт ключом самого мерчанта или из кабинета — у них чужих позиций не бывает."
                        }
                      }
                    },
                    {
                      "type": "object",
                      "title": "SandboxDeleted",
                      "properties": {
                        "message": {
                          "type": "string",
                          "example": "Catalog items deleted"
                        },
                        "deleted": {
                          "type": "integer"
                        },
                        "already_queued": {
                          "type": "array",
                          "items": {
                            "type": "integer"
                          }
                        },
                        "already_queued_count": {
                          "type": "integer"
                        },
                        "not_yours": {
                          "type": "array",
                          "items": {
                            "type": "integer"
                          },
                          "description": "Позиции запроса, которые НЕ заведены вашей интеграцией: их завёл сам мерчант, они приехали из каталога Kaspi либо их завела другая его интеграция. Удалены они не будут. Пусто, если запрос идёт ключом самого мерчанта или из кабинета — у них чужих позиций не бывает."
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "202": {
            "description": "Позиции приняты в очередь удаления",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "queued": {
                      "type": "integer",
                      "description": "Сколько позиций принято в очередь удаления этим запросом (не больше 200 — столько значений принимает список).",
                      "example": 180
                    },
                    "buried": {
                      "type": "integer",
                      "description": "Сколько недоставленных призрачных строк закрыто локально.",
                      "example": 0
                    },
                    "already_queued": {
                      "type": "array",
                      "items": {
                        "type": "integer"
                      },
                      "description": "Позиции, которые уже стояли в очереди удаления — их не переклеймливаем. ⚠️ Список ОБРЕЗАН до 200 элементов (это образец, а не полный перечень); полное число всегда в `already_queued_count`."
                    },
                    "already_queued_count": {
                      "type": "integer",
                      "description": "Сколько позиций уже стояло в очереди удаления — без обрезки."
                    },
                    "not_yours": {
                      "type": "array",
                      "items": {
                        "type": "integer"
                      },
                      "description": "Позиции запроса, которые НЕ заведены вашей интеграцией: их завёл сам мерчант, они приехали из каталога Kaspi либо их завела другая его интеграция. Удалены они не будут. Пусто, если запрос идёт ключом самого мерчанта или из кабинета — у них чужих позиций не бывает."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "`catalog_not_supported` — у организации не включён каталог. ⚠️ Именно 400, а не 422:\nэто предусловие организации, а не ошибка тела запроса.\n",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "catalog_not_supported"
                    },
                    "error_code": {
                      "$ref": "#/components/schemas/ErrorCode"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`tariff_inactive` — тариф не активен.\n`catalog_delete_owner_key_required` — ключ выпущен не владельцем организации.\n",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "catalog_delete_owner_key_required"
                    },
                    "error_code": {
                      "$ref": "#/components/schemas/ErrorCode"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Организация не найдена либо не верифицирована. ⚠️ Тело исторически без\n`error_code`: `{\"error\": \"Verified organization not found\"}`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "Verified organization not found"
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "`idempotency_key_conflict` — ключ занят другим телом либо другой каталожной операцией.\n`catalog_multi_tradepoint` — у организации несколько торговых точек.\n`catalog_busy` — каталог занят другой операцией, повторите через несколько секунд.\n`catalog_bulk_delete_mismatch` — необязательный `expected_count` не совпал с\nфактическим числом строк; повторите `dry_run`. У этого отказа в теле дополнительно\nприходят `expected_count` (что прислали) и `actual_count` (сколько строк нашлось);\nне удалено ничего.\n",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "catalog_busy"
                    },
                    "error_code": {
                      "$ref": "#/components/schemas/ErrorCode"
                    },
                    "message": {
                      "type": "string"
                    },
                    "expected_count": {
                      "type": "integer",
                      "description": "Только у catalog_bulk_delete_mismatch: значение из запроса.",
                      "example": 120
                    },
                    "actual_count": {
                      "type": "integer",
                      "description": "Только у catalog_bulk_delete_mismatch: сколько строк реально попало бы под удаление.",
                      "example": 118
                    }
                  }
                }
              }
            }
          },
          "422": {
            "description": "`catalog_delete_scope_required` — не задан ни `ids[]`, ни `external_refs[]`, либо заданы оба списка.\n`catalog_match_overflow` — слишком много значений в списке.\n",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "catalog_delete_scope_required"
                    },
                    "error_code": {
                      "$ref": "#/components/schemas/ErrorCode"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimitWindow"
          }
        }
      }
    },
    "/catalog/upload-image": {
      "post": {
        "tags": [
          "Catalog"
        ],
        "summary": "Загрузить изображение товара",
        "description": "Загружает изображение товара. **Только JPEG и PNG**, максимум 6 МБ, стороны\n64…6000 px, площадь до 12 Мпикс.\nТип определяется по содержимому файла, а не по имени и не по `Content-Type`:\nфайл с расширением `.png`, но иным содержимым, отклоняется (`422 invalid_file_type`).\n\nИзображение **перекодируется на нашей стороне** (приводится к JPEG ≤512×512), поэтому\nбайты на выходе не совпадают с загруженными. Дедупликация — по MD5 **результата**,\nвнутри организации: повторная загрузка того же изображения вернёт прежний `image_id`.\n\n⚠️ Для изображений, загруженных до 01.08.2026, дедуп мог считаться по исходному файлу.\nПовторная загрузка такого файла один раз не совпадёт и создаст новый `image_id`; прежний\nпродолжает работать, чистить ничего не нужно. Дедупликацию не стоит считать контрактом:\nпри обновлении обработчика изображений хеш может однократно измениться.\n\nЛимиты: **60/мин + 2000/сутки** на ключ.\n\n⚠️ Ранее принимались также gif/webp/bmp/svg — сужено 01.08.2026 (см. changelog).\n",
        "operationId": "uploadCatalogImage",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "image"
                ],
                "properties": {
                  "image": {
                    "type": "string",
                    "format": "binary",
                    "description": "Файл изображения: JPEG или PNG, макс 6144 KB (6 МБ), стороны 64…6000 px, площадь ≤12 Мпикс."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Изображение загружено",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "image_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TariffInactive"
          },
          "413": {
            "description": "Файл больше 6 МБ (`error_code: file_too_large`). Повтор без уменьшения файла бессмысленен."
          },
          "422": {
            "description": "`invalid_file_type` — содержимое не JPEG/PNG; `image_rejected` — габариты вне\nдопустимого (стороны 64…6000 px, площадь ≤12 Мпикс) либо файл повреждён;\n`validation_error` — поле `image` отсутствует.\n"
          },
          "429": {
            "description": "Превышен лимит загрузок (60/мин или 2000/сутки на ключ)."
          },
          "500": {
            "description": "`image_processing_unavailable` — обработка изображений временно недоступна,\nповторите позже. Изображение при этом **не сохранено** — `image_id` не выдан,\nповторять запрос безопасно.\n"
          }
        }
      }
    },
    "/catalog/scan": {
      "post": {
        "tags": [
          "Catalog"
        ],
        "summary": "Поиск товара в Нацкаталоге по штрихкоду",
        "description": "Резолвит штрихкод в Нацкаталоге Kaspi (синхронно, на сессии кассира). Один штрихкод\nможет вернуть несколько кандидатов (общий gtin, разные ntin). Пустой `data[]` и/или\n`scan_result.code != \"ok\"` = товар не найден (**это не ошибка**, `200`; создавайте\nтовар обычным путём без ntin/gtin). Лимиты: **30/мин + 2000/сутки** на ключ.\nПри троттле Kaspi возвращается `429 kaspi_throttled`. Дождитесь времени из\n`retry_after_seconds` (заголовок `Retry-After`) и повторите.\n",
        "operationId": "scanCatalogBarcode",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "input"
                ],
                "properties": {
                  "input": {
                    "type": "string",
                    "maxLength": 64,
                    "description": "Штрихкод (ручной ввод или сканер).",
                    "example": "4607015232646"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Результат резолва (пустой data[] = не найдено, не ошибка)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "integer",
                            "example": 1118196
                          },
                          "name": {
                            "type": "string",
                            "example": "ВАФЛИ ЯШКИНО ОРЕХОВЫЕ 300Г"
                          },
                          "ntin": {
                            "type": "string",
                            "nullable": true,
                            "example": "0200009461097"
                          },
                          "gtin": {
                            "type": "string",
                            "nullable": true,
                            "example": "4607015232646"
                          },
                          "barcode": {
                            "type": "string",
                            "nullable": true,
                            "example": "4607015232646"
                          },
                          "unit_id": {
                            "type": "integer",
                            "nullable": true
                          },
                          "image_link": {
                            "type": "string",
                            "nullable": true
                          }
                        }
                      }
                    },
                    "normalized_barcode": {
                      "type": "string",
                      "example": "4607015232646",
                      "description": "Точное эхо input; имя legacy, нормализация не обещается."
                    },
                    "scan_result": {
                      "type": "object",
                      "properties": {
                        "code": {
                          "type": "string",
                          "example": "ok"
                        },
                        "message": {
                          "type": "string",
                          "nullable": true
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "kaspi_session_expired — сессия кассира истекла, нужна переавторизация",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TariffInactive"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "description": "kaspi_throttled — Kaspi троттлит сессию. Тело содержит retry_after_seconds, заголовок Retry-After.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "503": {
            "description": "kaspi_scan_unavailable — Нацкаталог временно недоступен, повторите позже"
          }
        }
      }
    },
    "/catalog/{id}": {
      "patch": {
        "tags": [
          "Catalog"
        ],
        "summary": "Обновить товар каталога",
        "description": "Обновляет товар. Все поля опциональны — применяются только присланные (`filled`).\n`external_ref` при обновлении **не принимается**. ⚠️ `ntin`/`gtin` затирают\nидентичность Нацкаталога безвозвратно (её нельзя восстановить синком) — отправляйте\nтолько при реальном изменении. Код ответа зависит от ПОЗИЦИИ, а не от режима\nорганизации: правка позиции песочницы применена уже в момент ответа (`200`), правка\nбоевой позиции принята в работу (`202`). В production правка доставляется в Kaspi\nуже после ответа, поэтому доставка может занять больше минуты — закладывайте это в\nсценарий и не считайте `202` подтверждением. До подтверждения сохраняются `operation=update` и всё\nнамерение картинки, включая `image_id`/удаление; исход проверяйте через targeted\n`GET /catalog`, `GET /catalog/queue`, `GET /catalog/errors` или per-item webhook.\n\n⚠️ **`PATCH` по позиции с `operation=delete` ОТМЕНЯЕТ удаление** — запрос означает\n«позиция мне нужна» и атомарно заменяет намерение на `create` либо `update`.\nУже отправленное снятие не создаёт отдельного окна отказа: фон приводит позицию к\nправде Kaspi, а при состоявшемся снятии заново создаёт её с тем же локальным `id`.\n\n⛔ **Если позиция уже снята (`status: deleted`), `PATCH` по ней вернёт `404`:**\nвыборка идёт только по живым позициям, и снятая для неё не существует. Заведите её\nзаново обычным `POST /catalog`: по `external_ref` вернётся та же строка (тот же\n`id`); у позиции без `external_ref` в каталоге появится новая строка с новым `id`,\nа если её штрихкод совпадает с другим живым товаром — присланная позиция сольётся\nс ним. Пересоздание есть только у `POST /catalog`, у `PATCH` его нет.\n",
        "operationId": "updateCatalogItem",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 255
                  },
                  "selling_price": {
                    "type": "number",
                    "minimum": 0.01
                  },
                  "unit_id": {
                    "type": "integer"
                  },
                  "image_id": {
                    "type": "string",
                    "format": "uuid",
                    "description": "Новый ID изображения (exists)."
                  },
                  "is_image_deleted": {
                    "type": "boolean",
                    "description": "true — удалить изображение."
                  },
                  "barcode": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 32
                  },
                  "ntin": {
                    "type": "string",
                    "maxLength": 50,
                    "description": "⚠️ Затирает идентичность Нацкаталога — только при реальном изменении."
                  },
                  "gtin": {
                    "type": "string",
                    "maxLength": 50,
                    "description": "⚠️ То же предупреждение, что и для ntin."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Sandbox: товар обновлён синхронно",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": {
                      "type": "string",
                      "example": "Catalog item updated"
                    },
                    "catalog_item_id": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "202": {
            "description": "Production: обновление поставлено в очередь",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": {
                      "type": "string",
                      "example": "Catalog item update queued"
                    },
                    "catalog_item_id": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TariffInactive"
          },
          "404": {
            "description": "Товар не найден. ⚠️ Сюда же приходит уже СНЯТАЯ позиция (`status: deleted`):\nвыборка идёт по живым позициям. Заводите её заново через `POST /catalog`.\n"
          },
          "409": {
            "description": "Позиция заведена ДРУГОЙ интеграцией этой организации (`error_code:\ncatalog_item_foreign_channel`). Позиция при этом видна: в списке она приходит с\n`source: other`, а `POST /catalog` при совпадении `external_ref`/`barcode`/`ntin`\nвозвращает её `id` — поэтому отказ именно `409`, а не `404`. Изменить и снять её\nможно только со стороны той интеграции либо из кабинета мерчанта. Позиций без\nинтеграции-автора (`source: shared`) это не касается.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "delete": {
        "tags": [
          "Catalog"
        ],
        "summary": "Удалить товар каталога",
        "description": "Код ответа зависит от ПОЗИЦИИ, а не от режима организации. Позиция песочницы снята уже\nв момент ответа (`200`) и переходит в `status=deleted`. Боевая позиция принята в\nработу (`202`): она становится `status=deleting` с `operation=delete`, а после\nподтверждения Kaspi переходит в `deleted` и операция закрывается.\n\n⚠️ Снятие в обоих случаях **логическое**: позиция остаётся доступной чтением\n(`GET /catalog?statuses[]=deleted`) и восстановима повторной заливкой по тому же\n`external_ref` — вернётся ТА ЖЕ позиция с тем же `id`.\n\n⚠️ **Ответ приходит раньше, чем товар исчезает из кассы.** Снятие доставляется в\nKaspi уже после ответа и при загруженной очереди займёт дольше минуты. Проверяйте\nрезультат чтением `GET /catalog?statuses[]=deleted`, а не сразу после ответа.\n\n⚠️ После временного сбоя позиция остаётся `status=deleting`; сервис вернётся к ней\nсам. Если попытки исчерпаны, позиция читается как `status=failed` с сохранённым\n`operation=delete`, `error_code`/`failed_at` — и по-прежнему не продаётся\n(`sellable=false`).\n\n⚠️ Неоднозначная торговая точка у одиночного удаления больше не даёт синхронный\n`409`: запрос принят с `202`, а на строке появляется\n`error_code=catalog_multi_tradepoint`. Массовая ручка сохраняет синхронный preflight\nи `409`, потому что обязана отклонить весь разрушительный набор до клейма.\n",
        "operationId": "deleteCatalogItem",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Позиция песочницы: снята синхронно"
          },
          "202": {
            "description": "Боевая позиция: снятие поставлено в очередь",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": {
                      "type": "string",
                      "example": "Catalog item deletion queued"
                    },
                    "catalog_item_id": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TariffInactive"
          },
          "404": {
            "description": "Товар не найден"
          },
          "409": {
            "description": "Позиция заведена ДРУГОЙ интеграцией этой организации (`error_code:\ncatalog_item_foreign_channel`). Позиция при этом видна: в списке она приходит с\n`source: other`, а `POST /catalog` при совпадении `external_ref`/`barcode`/`ntin`\nвозвращает её `id` — поэтому отказ именно `409`, а не `404`. Изменить и снять её\nможно только со стороны той интеграции либо из кабинета мерчанта. Позиций без\nинтеграции-автора (`source: shared`) это не касается.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/clients/check": {
      "post": {
        "tags": [
          "Clients"
        ],
        "summary": "Проверить номер в Kaspi",
        "description": "Проверяет, зарегистрирован ли номер в Kaspi, и возвращает имя клиента в формате\nKaspi «Имя Ф.» (полная фамилия не отдаётся). Удобно перед созданием счёта/подписки.\n\nНомер нормализуется к `8XXXXXXXXXX` (77.../87.../+77... с пробелами/дефисами).\n\n**Лимиты:** 60/мин + 10 000/сутки на ключ; 200/мин + 20 000/сутки на организацию;\n10/мин на одного кассира. При троттле Kaspi — `429 scope=kaspi_throttle`.\nДождитесь времени из `retry_after_seconds` (заголовок `Retry-After`) и повторите.\n\n⚠️ **Запрещён массовый перебор** (enumeration). При злоупотреблении ключ\nдеактивируется без предупреждения.\nSandbox: `87770000001` → true/\"Иван И.\", `87770000002` → false, иначе false.\n",
        "operationId": "checkClient",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "phone"
                ],
                "properties": {
                  "phone": {
                    "type": "string",
                    "maxLength": 32,
                    "description": "Телефон клиента (после нормализации — 11 цифр).",
                    "example": "77001234567"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Номер проверен",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "phone": {
                      "type": "string",
                      "description": "Нормализованный номер (8XXXXXXXXXX).",
                      "example": "87001234567"
                    },
                    "has_kaspi": {
                      "type": "boolean",
                      "example": true
                    },
                    "client_name": {
                      "type": "string",
                      "nullable": true,
                      "description": "Формат «Имя Ф.»; null если has_kaspi=false.",
                      "example": "Иван И."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TariffInactive"
          },
          "422": {
            "description": "Телефон пустой или не нормализуется к 11 цифрам",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "Validation failed"
                    },
                    "field": {
                      "type": "string",
                      "example": "phone"
                    },
                    "message": {
                      "type": "string",
                      "example": "Phone must be normalizable to 8XXXXXXXXXX (11 digits)."
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "Превышен лимит. Два РАЗНЫХ источника, тела похожи, но различаются полем `scope`:\n\n* **наш лимитер** — `scope` одно из `minute`, `day`, `org_minute`, `org_day`,\n  `cashier_minute`; список открыт, неизвестное значение обрабатывайте общей веткой.\n  ⚠️ Часть окон СУТОЧНЫЕ (`*_day`) — ориентируйтесь на `retry_after`, а не на\n  «подожду минуту»;\n* **троттл Kaspi** — `scope: kaspi_throttle` (сработал circuit-breaker).\n\nОба тела несут `retry_after` (секунды) и заголовок `Retry-After`.\n⚠️ Поля `error_code` здесь нет намеренно: единый поминутный код сюда не подходит —\nокна разные.\n",
            "headers": {
              "Retry-After": {
                "description": "Секунд до повторной попытки.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "Too many requests"
                    },
                    "message": {
                      "type": "string",
                      "example": "Lookup rate limit exceeded."
                    },
                    "retry_after": {
                      "type": "integer",
                      "example": 42
                    },
                    "scope": {
                      "type": "string",
                      "example": "minute"
                    }
                  }
                },
                "examples": {
                  "our_limiter": {
                    "value": {
                      "error": "Too many requests",
                      "message": "Lookup rate limit exceeded.",
                      "retry_after": 42,
                      "scope": "day"
                    }
                  },
                  "kaspi_throttle": {
                    "value": {
                      "error": "Too many requests",
                      "message": "Lookup throttled, retry later.",
                      "retry_after": 120,
                      "scope": "kaspi_throttle"
                    }
                  }
                }
              }
            }
          },
          "503": {
            "description": "Kaspi session unavailable — нет активной сессии кассира",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "Kaspi session unavailable"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/subscriptions": {
      "get": {
        "tags": [
          "Subscriptions"
        ],
        "summary": "Список подписок",
        "description": "Подписки организации. Пагинация — **meta-обёртка** `{data, links, meta}`\n(`meta.current_page`/`total`/`per_page`/`last_page`/`from`/`to`). Фильтры — точное\nсовпадение (не LIKE), между собой соединяются по «И». Сортировка фиксированная:\n`created_at DESC`. Даты — UTC `+00:00`.\n\nКаждый фильтр принимает **одно значение или массив**: `status=active` и\n`status[]=active&status[]=paused` — законные формы, массив означает «любое из».\n⚠️ Неизвестное значение `status` отдаёт `422` — список допустимых значений объявлен\nздесь же.\n`phone_number` нормализуется: `+7 (700) 123-45-67`, `77001234567` и `87001234567`\nнаходят одну и ту же подписку.\n",
        "operationId": "listSubscriptions",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "description": "Точный фильтр по статусу. Одно значение либо массив (`status[]=`).\n",
            "schema": {
              "oneOf": [
                {
                  "type": "string",
                  "enum": [
                    "active",
                    "paused",
                    "cancelled",
                    "expired"
                  ]
                },
                {
                  "type": "array",
                  "items": {
                    "type": "string",
                    "enum": [
                      "active",
                      "paused",
                      "cancelled",
                      "expired"
                    ]
                  }
                }
              ]
            }
          },
          {
            "name": "external_subscriber_id",
            "in": "query",
            "description": "Одно значение либо массив.",
            "schema": {
              "oneOf": [
                {
                  "type": "string"
                },
                {
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                }
              ]
            }
          },
          {
            "name": "phone_number",
            "in": "query",
            "description": "Номер в любой форме — приводится к каноническому `8XXXXXXXXXX`.\nОдно значение либо массив.\n",
            "schema": {
              "oneOf": [
                {
                  "type": "string"
                },
                {
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                }
              ]
            }
          },
          {
            "name": "per_page",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 20
            }
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 1
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Список подписок",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Subscription"
                      }
                    },
                    "links": {
                      "$ref": "#/components/schemas/PaginationLinks"
                    },
                    "meta": {
                      "$ref": "#/components/schemas/PaginationMeta"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "post": {
        "tags": [
          "Subscriptions"
        ],
        "summary": "Создать подписку",
        "description": "Создаёт рекуррентную подписку. Сумму задают одним из двух способов:\n**`amount`** (100–1 000 000) либо **`cart_items`** — тогда сумма считается сервером\nпо актуальным ценам каталога, а `amount` из тела игнорируется. Передать нужно\nровно что-то одно: без корзины `amount` обязателен, отказ приходит по полю `amount`.\nОрганизация **с каталогом** может вести подписку и просто на сумму, без `cart_items`.\nОбратное неверно: у организации **без каталога** `cart_items` дают `422` с\n`error_code: catalog_not_supported`.\n`bill_immediately=true` — первый счёт сразу; иначе по расписанию в `next_billing_at`.\n\n⛔ **Сумма списания — только целые тенге.** Списание выставляется счётом на номер\nтелефона, поэтому дробная сумма (и голая `amount`, и итог `cart_items` после скидок)\nуходит в статус `error` с `error_code: amount_must_be_whole_tenge` — вебхуком\n`invoice.status_changed`, и так при КАЖДОМ списании. Проверьте суммы действующих\nподписок и цены каталожных позиций, из которых считается итог.\n\n⛔ **Позиция корзины с `operation=delete` отбивается `422`** — здесь и в\n`PUT /subscriptions/{id}`; текст в `errors[\"cart_items.N.catalog_item_id\"]`.\n⚠️ На очередном СПИСАНИИ тот же гейт даёт не ошибку, а отсрочку: если позиция\nуходит в снятие у уже работающей подписки, списание откладывается до следующей\nпопытки списания — счётчик неудач не растёт и подписка не уходит в grace period\nиз-за временного состояния. Верните позицию обычным `POST /catalog`.\n\n⚠️ **Отложенное списание ничем не сигнализируется:** счёт за период не создаётся,\nвебхука нет, `next_billing_at` не двигается. Списание пройдёт автоматически первой\nже попыткой после того, как позиция вернётся в продажу — если деньги нужны раньше,\nверните позицию сами.\n",
        "operationId": "createSubscription",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateSubscriptionRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Подписка создана",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": {
                      "type": "string",
                      "example": "Subscription created"
                    },
                    "subscription": {
                      "$ref": "#/components/schemas/Subscription"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "organization_required / sandbox_subscription_limit",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Организация не верифицирована; либо `tariff_inactive` — подписка мерчанта на\nApiPay не активна (грейса нет, блок сразу после `expires_at`; тело несёт\n`expires_at`). Остановить уже существующую подписку (`pause`/`cancel`) можно\nи без действующего тарифа.\n"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/subscriptions/{id}": {
      "get": {
        "tags": [
          "Subscriptions"
        ],
        "summary": "Получить подписку",
        "description": "Подписка со статистикой (`stats`) и последним платежом (`last_payment`).\n\n⚠️ Тело обёрнуто в `subscription` — так же, как у `POST /subscriptions`,\n`PUT`, `pause`, `resume` и `cancel`. Читайте `body.subscription.id`, а не `body.id`.\n",
        "operationId": "getSubscription",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Данные подписки",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "subscription": {
                      "allOf": [
                        {
                          "$ref": "#/components/schemas/Subscription"
                        },
                        {
                          "type": "object",
                          "properties": {
                            "last_payment": {
                              "type": "object",
                              "nullable": true,
                              "properties": {
                                "amount": {
                                  "type": "string",
                                  "example": "5000.00"
                                },
                                "paid_at": {
                                  "type": "string",
                                  "format": "date-time",
                                  "nullable": true
                                },
                                "status": {
                                  "type": "string",
                                  "example": "paid"
                                }
                              }
                            },
                            "stats": {
                              "type": "object",
                              "properties": {
                                "total_payments": {
                                  "type": "integer",
                                  "example": 6
                                },
                                "successful_payments": {
                                  "type": "integer",
                                  "example": 5
                                },
                                "failed_payments": {
                                  "type": "integer",
                                  "example": 0
                                },
                                "total_collected": {
                                  "type": "string",
                                  "example": "25000.00"
                                }
                              }
                            }
                          }
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Подписка не найдена"
          }
        }
      },
      "put": {
        "tags": [
          "Subscriptions"
        ],
        "summary": "Обновить подписку",
        "description": "Все поля опциональны. `cart_items` на не-каталог организации → `422` с\n`error_code: catalog_not_supported`. При передаче `cart_items` сумма пересчитывается\nпо актуальным ценам каталога, а `amount` из тела игнорируется.\n\n**Снять корзину** и вернуть подписку на фиксированную сумму — передать\n`\"cart_items\": null` **вместе с `amount`**. Сумма при этом обязательна: иначе подписка\nосталась бы с числом, посчитанным по уже неактуальному составу. ⚠️ `null` и отсутствие\nполя — разные намерения: если `cart_items` не передан вовсе, корзина не трогается.\n\n⛔ Позиция корзины с `operation=delete` отбивается `422` так же, как при создании —\nтекст в `errors[\"cart_items.N.catalog_item_id\"]`.\n",
        "operationId": "updateSubscription",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "amount": {
                    "type": "number",
                    "minimum": 100,
                    "maximum": 1000000,
                    "description": "Обязателен, если этим же запросом снимают корзину (cart_items=null)."
                  },
                  "billing_day": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 28,
                    "description": "День списания. У `monthly`, `quarterly`, `yearly` — число месяца (1–28). У `weekly` и `biweekly` — ДЕНЬ НЕДЕЛИ: 1 — понедельник … 7 — воскресенье, и значения больше 7 на этих периодах отклоняются (раньше они принимались и молча игнорировались). У `daily` не используется. Взаимоисключающе с `billing_day_from_end`."
                  },
                  "billing_day_from_end": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 1,
                    "nullable": true,
                    "description": "Опора от конца месяца: 0 — последний день, 1 — предпоследний. Доступна только для `monthly`, `quarterly`, `yearly`. Взаимоисключающе с `billing_day`."
                  },
                  "billing_time": {
                    "type": "string",
                    "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$",
                    "nullable": true,
                    "description": "Время списания по Алматы в формате ЧЧ:ММ. Допустимое окно — 06:00–22:00. По умолчанию 13:00."
                  },
                  "total_cycles": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 600,
                    "nullable": true,
                    "description": "Сколько ОПЛАЧЕННЫХ списаний сделать за всю жизнь подписки. Пусто — бессрочно. Неоплаченная попытка цикл не расходует."
                  },
                  "description": {
                    "type": "string",
                    "maxLength": 255,
                    "description": "Описание подписки. Уходит в Kaspi комментарием к счёту списания. ⚠️ Kaspi показывает покупателю только ПЕРВЫЕ 60 СИМВОЛОВ, остальное отбрасывает молча. С 2026-09-05 изменённое описание длиннее 60 символов отклоняется (HTTP 422); до этой даты длинные описания принимаются как прежде. ⛔ Организации, зарегистрированные с 2026-08-26, живут на лимите 60 сразу. Проверяется только ИЗМЕНЁННОЕ описание: правка других полей у подписки со старым длинным описанием проходит как раньше. Без описания счёт списания уходит с текстом «Оплата подписки №{id}», в песочнице — «Оплата подписки №{id} (песочница)»."
                  },
                  "subscriber_name": {
                    "type": "string",
                    "maxLength": 255
                  },
                  "external_subscriber_id": {
                    "type": "string",
                    "maxLength": 255
                  },
                  "max_retry_attempts": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 10
                  },
                  "retry_interval_hours": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 168
                  },
                  "grace_period_days": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 30
                  },
                  "metadata": {
                    "type": "object"
                  },
                  "cart_items": {
                    "type": "array",
                    "nullable": true,
                    "minItems": 1,
                    "maxItems": 100,
                    "description": "Новый состав корзины. `null` — снять корзину и вести подписку на фиксированную сумму (тогда `amount` обязателен). Поле не передано — корзина не трогается.",
                    "items": {
                      "$ref": "#/components/schemas/CartItemSimple"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Подписка обновлена",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": {
                      "type": "string",
                      "example": "Subscription updated"
                    },
                    "subscription": {
                      "$ref": "#/components/schemas/Subscription"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TariffInactive"
          },
          "404": {
            "description": "Подписка не найдена"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/subscriptions/{id}/pause": {
      "post": {
        "tags": [
          "Subscriptions"
        ],
        "summary": "Приостановить подписку",
        "description": "Приостанавливает active-подписку (останавливает биллинг).",
        "operationId": "pauseSubscription",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Подписка приостановлена",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SubscriptionActionResponse"
                }
              }
            }
          },
          "400": {
            "description": "Нельзя приостановить (подписка не active)"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Подписка не найдена"
          }
        }
      }
    },
    "/subscriptions/{id}/resume": {
      "post": {
        "tags": [
          "Subscriptions"
        ],
        "summary": "Возобновить подписку",
        "description": "Возобновляет paused-подписку (next_billing_at пересчитывается от текущего момента, пропущенные периоды не доначисляются).",
        "operationId": "resumeSubscription",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Подписка возобновлена",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SubscriptionActionResponse"
                }
              }
            }
          },
          "400": {
            "description": "Нельзя возобновить (подписка не paused)"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TariffInactive"
          },
          "404": {
            "description": "Подписка не найдена"
          }
        }
      }
    },
    "/subscriptions/{id}/cancel": {
      "post": {
        "tags": [
          "Subscriptions"
        ],
        "summary": "Отменить подписку",
        "description": "Отменяет active/paused-подписку безвозвратно (реактивации нет — создавайте новую).",
        "operationId": "cancelSubscription",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Подписка отменена",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SubscriptionActionResponse"
                }
              }
            }
          },
          "400": {
            "description": "Нельзя отменить (неверный статус)"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Подписка не найдена"
          }
        }
      }
    },
    "/subscriptions/{id}/invoices": {
      "get": {
        "tags": [
          "Subscriptions"
        ],
        "summary": "Счета подписки",
        "description": "Счета, созданные подпиской. **Нестандартный конверт** `{data, meta}` — `meta`\nсодержит `current_page`/`total`/`per_page` (без `last_page`). Даты — UTC `+00:00`.\n",
        "operationId": "listSubscriptionInvoices",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "per_page",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 20
            }
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 1
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Счета подписки",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/SubscriptionInvoice"
                      }
                    },
                    "meta": {
                      "type": "object",
                      "properties": {
                        "current_page": {
                          "type": "integer",
                          "example": 1
                        },
                        "total": {
                          "type": "integer",
                          "example": 3
                        },
                        "per_page": {
                          "type": "integer",
                          "example": 20
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Подписка не найдена"
          }
        }
      }
    },
    "/subscriptions/{subscription}/simulate-invoice": {
      "post": {
        "tags": [
          "Sandbox"
        ],
        "summary": "Создать sandbox-счёт подписки",
        "description": "**Только sandbox.** Создаёт один sandbox-счёт для активной sandbox-подписки и шлёт\nвебхук. Кулдаун 30 секунд.\n\nУ подписки с корзиной сумма и состав считаются ТЕМ ЖЕ путём, что боевое списание —\nпо актуальным ценам каталога, а не по хранимому `amount`. Поэтому переоценка позиции\nменяет сумму следующей симуляции, а непригодная позиция даёт `422`.\n",
        "operationId": "simulateSubscriptionInvoice",
        "parameters": [
          {
            "$ref": "#/components/parameters/SubscriptionPathId"
          }
        ],
        "responses": {
          "201": {
            "description": "Sandbox-счёт создан",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": {
                      "type": "string",
                      "example": "Sandbox invoice created for subscription"
                    },
                    "invoice": {
                      "$ref": "#/components/schemas/Invoice"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "subscription_not_active / sandbox_invoice_limit / organization_required",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "not_sandbox — только для sandbox-подписок",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "404": {
            "description": "Подписка не найдена"
          },
          "422": {
            "description": "cart_unavailable — позиция корзины непригодна к продаже или снимается",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "rate_limited — не прошёл кулдаун 30с",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/subscriptions/{subscription}/start-simulation": {
      "post": {
        "tags": [
          "Sandbox"
        ],
        "summary": "Запустить авто-симуляцию подписки",
        "description": "**Только sandbox.** Автогенерация sandbox-счетов с интервалом. Макс. 3 одновременных симуляции на организацию.",
        "operationId": "startSubscriptionSimulation",
        "parameters": [
          {
            "$ref": "#/components/parameters/SubscriptionPathId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "interval_minutes"
                ],
                "properties": {
                  "interval_minutes": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 60
                  },
                  "max_invoices": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 20,
                    "default": 5
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Симуляция запущена",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": {
                      "type": "string",
                      "example": "Simulation started"
                    },
                    "subscription": {
                      "$ref": "#/components/schemas/Subscription"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "subscription_not_active / simulation_limit / organization_required",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "not_sandbox",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "404": {
            "description": "Подписка не найдена"
          },
          "409": {
            "description": "simulation_already_active",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/subscriptions/{subscription}/stop-simulation": {
      "post": {
        "tags": [
          "Sandbox"
        ],
        "summary": "Остановить авто-симуляцию подписки",
        "description": "**Только sandbox.** Останавливает авто-симуляцию.",
        "operationId": "stopSubscriptionSimulation",
        "parameters": [
          {
            "$ref": "#/components/parameters/SubscriptionPathId"
          }
        ],
        "responses": {
          "200": {
            "description": "Симуляция остановлена",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": {
                      "type": "string",
                      "example": "Simulation stopped"
                    },
                    "subscription": {
                      "$ref": "#/components/schemas/Subscription"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "simulation_not_active / organization_required",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "not_sandbox",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "404": {
            "description": "Подписка не найдена"
          }
        }
      }
    },
    "/cashbox/summary": {
      "get": {
        "tags": [
          "Cashbox"
        ],
        "summary": "Сводка по наличным за день",
        "description": "Кассовая сводка по наличным за календарный день (зона Asia/Almaty). Данные\nчитаются с кассы Kaspi и кэшируются примерно на 60 секунд. Все суммы — строки\n`\"N.NN\"` в тенге. Флаг `available_cashbox_actions=false` означает «Kaspi запретил операции\nна кассе» — на чтении не роняется, отдаётся в данных, чтобы UI выключил\nтумблеры/закрытие.\n",
        "operationId": "getCashboxSummary",
        "parameters": [
          {
            "name": "date",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "`Y-m-d` или `Y-m-d H:i` (Almaty). По умолчанию — сегодня. Будущая дата → 422."
          },
          {
            "name": "kaspi_connection_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "nullable": true
            },
            "description": "Касса; по умолчанию primary-коннекшн организации."
          }
        ],
        "responses": {
          "200": {
            "description": "Сводка по наличным",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CashboxDaySummary"
                }
              }
            }
          },
          "400": {
            "description": "organization_required"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "tariff_inactive / cashbox_disabled — кассовые операции для организации недоступны (в песочнице этот отказ не приходит)"
          },
          "409": {
            "description": "kaspi_session_not_configured / rfo_missing"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "description": "Превышен отдельный лимит кассовых запросов (30/мин)"
          },
          "503": {
            "description": "cashbox_unavailable — касса Kaspi временно недоступна"
          }
        }
      }
    },
    "/cashbox/reconciliation": {
      "get": {
        "tags": [
          "Cashbox"
        ],
        "summary": "Сверка наших счетов с кассой Kaspi",
        "description": "Сверка по одной смене. Показывает ОБЕ цифры рядом — нашу выручку по счетам (`ours`:\nпродажи, возвраты и их разность) и цифру кассы Kaspi (`kaspi`) — и структурные причины, по которым\nих **нельзя приравнять** (`discrepancies`). ⛔ Это НЕ доказательство равенства: касса\nKaspi и счета ApiPay — разные леджеры (Kaspi видит в том числе наличные и офлайн-продажи,\nа наша половина — оплаты по счетам, которые числятся в ApiPay), а итог смены приходит единой суммой,\nгде эти части не выделены. Поэтому разницу мы не вычисляем: в ответе только обе цифры и\nпричины расхождения. Активной сессии кассира ручка не требует — она работает по смене,\nуже полученной через `GET /cashbox/shifts`. Смены, которую ещё не листали, для сверки\nне существует.\n",
        "operationId": "getCashboxReconciliation",
        "parameters": [
          {
            "name": "shift_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "kaspi_shift_id из GET /cashbox/shifts."
          },
          {
            "name": "kaspi_connection_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "nullable": true
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Результат сверки",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CashboxReconciliation"
                }
              }
            }
          },
          "400": {
            "description": "organization_required"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "tariff_inactive"
          },
          "404": {
            "description": "cashbox_shift_not_found — смена с таким id недоступна (не листали GET /cashbox/shifts). Организация без кассы Kaspi (ОФД) приходит сюда же: смен ей не отдаёт и сам листинг"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "description": "Превышен отдельный лимит кассовых запросов (30/мин)"
          },
          "503": {
            "description": "cashbox_unavailable"
          }
        }
      }
    },
    "/cashbox/shifts": {
      "get": {
        "tags": [
          "Cashbox"
        ],
        "summary": "Список кассовых смен",
        "description": "Смены за окно `[date_from..date_to]` (глубина ≤ 31 дня). Данные читаются с кассы\nKaspi и кэшируются примерно на 60 секунд. Суммы — строки `\"N.NN\"`;\n`total_income_raw` — исходное форматирование Kaspi (`\"89 000 ₸\"`), только для показа.\n",
        "operationId": "listCashboxShifts",
        "parameters": [
          {
            "name": "date_from",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "`Y-m-d`/`Y-m-d H:i` (Almaty)."
          },
          {
            "name": "date_to",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "≥ date_from; окно ≤ 31 дня, иначе 422."
          },
          {
            "name": "kaspi_connection_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "nullable": true
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Список смен",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CashboxShiftList"
                }
              }
            }
          },
          "400": {
            "description": "organization_required"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "tariff_inactive / cashbox_disabled"
          },
          "409": {
            "description": "kaspi_session_not_configured / cashbox_kkm_unknown"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "description": "Превышен отдельный лимит кассовых запросов (30/мин)"
          },
          "503": {
            "description": "cashbox_unavailable"
          }
        }
      }
    },
    "/cashbox/shifts/close": {
      "post": {
        "tags": [
          "Cashbox"
        ],
        "summary": "Закрыть смену (async)",
        "description": "Ставит закрытие смены в очередь и сразу отвечает `202` со `status: pending`. Итог —\nчерез `GET /cashbox/operations/{id}` (поле `poll_url` в ответе) или вебхук\n`cashbox.shift_closed` / `cashbox.shift_close_failed`.\n\n**Идемпотентность:** `client_operation_id` уникален на организацию — повтор с тем же\nключом даёт `409 cashbox_duplicate_operation` (с `operation_id`/`status` уже принятой\nоперации). ⛔ Ключ **не освобождается даже у `failed`**: повторное закрытие безопасно,\nно повтор ТЕМ ЖЕ ключом — дубль; повторяйте\nзакрытие **новым** `client_operation_id`. «Уже закрыта» трактуется как успех\n(`completed`) — целевое состояние достигнуто.\n",
        "operationId": "closeCashboxShift",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "client_operation_id",
                  "shift_number"
                ],
                "properties": {
                  "client_operation_id": {
                    "type": "string",
                    "minLength": 8,
                    "maxLength": 191,
                    "pattern": "^[A-Za-z0-9._:-]+$",
                    "description": "Ключ идемпотентности (уникален на организацию)."
                  },
                  "shift_number": {
                    "type": "integer",
                    "description": "Номер смены Kaspi для закрытия."
                  },
                  "kaspi_connection_id": {
                    "type": "integer",
                    "nullable": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Закрытие принято в обработку",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer"
                    },
                    "status": {
                      "type": "string",
                      "example": "pending"
                    },
                    "client_operation_id": {
                      "type": "string"
                    },
                    "poll_url": {
                      "type": "string",
                      "description": "GET-адрес статуса операции (…/cashbox/operations/{id})."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "organization_required"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "tariff_inactive / cashbox_disabled"
          },
          "409": {
            "description": "kaspi_session_not_configured / cashbox_kkm_unknown / cashbox_duplicate_operation"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "description": "Превышен отдельный лимит кассовых запросов (30/мин)"
          }
        }
      }
    },
    "/cashbox/operations/{id}": {
      "get": {
        "tags": [
          "Cashbox"
        ],
        "summary": "Статус кассовой операции (поллинг)",
        "description": "Читает данные ApiPay, к кассе Kaspi не обращается: отдельный лимит кассовых\nзапросов не расходует и остаётся доступной при неактивном тарифе — уже принятую\nоперацию всегда можно довести до терминального статуса. Чужой `id` → `404`\n(scope по организации, non-enumeration). `resolution.safe_to_retry=true` только при\n`status=failed` — повторяйте закрытие новым `client_operation_id`.\n",
        "operationId": "getCashboxOperation",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Статус операции",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CashboxOperation"
                }
              }
            }
          },
          "400": {
            "description": "organization_required"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "cashbox_operation_not_found (в т.ч. чужой id)"
          }
        }
      }
    },
    "/cashbox/shifts/{shift}/report": {
      "get": {
        "tags": [
          "Cashbox"
        ],
        "summary": "Ссылка на PDF-отчёт по смене",
        "description": "Возвращает временную подписанную ссылку (`url`) на PDF-отчёт по смене и её срок\n(`expires_at`, TTL ~15 мин). Файл скачивается по этой ссылке напрямую, ключ API\nдля скачивания не нужен.\n\n⚠️ Ссылка сама по себе даёт доступ к отчёту: пока не истёк `expires_at`, файл\nскачает любой, кто её получил. Не публикуйте её, не кладите в логи и не передавайте\nпо открытым каналам — при необходимости запросите новую.\n",
        "operationId": "getCashboxShiftReport",
        "parameters": [
          {
            "name": "shift",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "kaspi_shift_id."
          }
        ],
        "responses": {
          "200": {
            "description": "Подписанная ссылка на отчёт",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "url": {
                      "type": "string"
                    },
                    "expires_at": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "organization_required"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "tariff_inactive / cashbox_disabled"
          },
          "409": {
            "description": "kaspi_session_not_configured"
          },
          "429": {
            "description": "Превышен отдельный лимит кассовых запросов (30/мин)"
          },
          "503": {
            "description": "cashbox_report_unavailable — не удалось получить отчёт"
          }
        }
      }
    },
    "/cashbox/settings": {
      "get": {
        "tags": [
          "Cashbox"
        ],
        "summary": "Текущие тумблеры кассы",
        "description": "Состояние обоих тумблеров. Значения tri-state: `null` = значение неизвестно\n(`null` — это НЕ «выключено»).\n\nОбычно ответ отдаётся из сохранённого состояния. Если по тумблеру состояния ещё\nнет, оно один раз дочитывается с кассы и сохраняется — дальше запрос снова\nбесплатный. Дочитывание не переключает тумблер и ничего не меняет на кассе; если\nоно не удалось (касса недоступна, у точки нет кассира или номера кассы), ответ\nостаётся `200` со значением `null`.\n",
        "operationId": "getCashboxSettings",
        "responses": {
          "200": {
            "description": "Состояние тумблеров",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "auto_close_shift": {
                      "type": "boolean",
                      "nullable": true
                    },
                    "auto_withdrawal": {
                      "type": "boolean",
                      "nullable": true
                    },
                    "kaspi_connection_id": {
                      "type": "integer",
                      "nullable": true
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "organization_required"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "tariff_inactive / cashbox_disabled"
          },
          "429": {
            "description": "Превышен отдельный лимит кассовых запросов (30/мин)"
          }
        }
      }
    },
    "/cashbox/settings/auto-close": {
      "put": {
        "tags": [
          "Cashbox"
        ],
        "summary": "Тумблер автозакрытия смены",
        "description": "Синхронное переключение. Идемпотентно: если ЖИВОЕ значение в Kaspi уже равно\nзапрошенному — `changed:false` без обращения к кассе.\n\n⛔ Только ключ, выпущенный ВЛАДЕЛЬЦЕМ организации: иначе\n`403 cashbox_settings_owner_key_required`. Чтение (`GET /cashbox/settings`)\nдоступно любому ключу организации.\n",
        "operationId": "setCashboxAutoClose",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CashboxToggleRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Результат переключения",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CashboxToggleResult"
                }
              }
            }
          },
          "400": {
            "description": "organization_required"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "cashbox_settings_owner_key_required / tariff_inactive / cashbox_disabled"
          },
          "409": {
            "description": "kaspi_session_not_configured / rfo_missing"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "description": "Превышен отдельный лимит кассовых запросов (30/мин)"
          },
          "503": {
            "description": "cashbox_toggle_in_progress — переключение уже выполняется, повторите позже / cashbox_toggle_unavailable — текущее значение на кассе проверить не удалось, переключение не выполнено"
          }
        }
      }
    },
    "/cashbox/settings/auto-withdrawal": {
      "put": {
        "tags": [
          "Cashbox"
        ],
        "summary": "Тумблер автоизъятия наличных",
        "description": "Автоизъятие наличных после закрытия смены. Поведение идентично `auto-close`\n(синхронный write, лок, идемпотентность по живому значению, ключ владельца\nорганизации).\n",
        "operationId": "setCashboxAutoWithdrawal",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CashboxToggleRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Результат переключения",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CashboxToggleResult"
                }
              }
            }
          },
          "400": {
            "description": "organization_required"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "cashbox_settings_owner_key_required / tariff_inactive / cashbox_disabled"
          },
          "409": {
            "description": "kaspi_session_not_configured / rfo_missing"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "description": "Превышен отдельный лимит кассовых запросов (30/мин)"
          },
          "503": {
            "description": "cashbox_toggle_in_progress — переключение уже выполняется / cashbox_toggle_unavailable — текущее значение на кассе проверить не удалось"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key",
        "description": "API-ключ из личного кабинета ApiPay.kz."
      }
    },
    "parameters": {
      "ConnectionId": {
        "name": "connection",
        "in": "path",
        "required": true,
        "description": "ID кассира.",
        "schema": {
          "type": "integer"
        }
      },
      "SubscriptionPathId": {
        "name": "subscription",
        "in": "path",
        "required": true,
        "description": "ID подписки.",
        "schema": {
          "type": "integer"
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "X-API-Key отсутствует или невалиден",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "error": {
                  "type": "string",
                  "example": "Invalid API key"
                },
                "message": {
                  "type": "string",
                  "example": "The provided API key is invalid or inactive"
                }
              }
            }
          }
        }
      },
      "ValidationError": {
        "description": "Ошибка валидации",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ValidationErrorBody"
            }
          }
        }
      },
      "TariffInactive": {
        "description": "`tariff_inactive` — подписка мерчанта на ApiPay не активна, платные операции закрыты.\nЛечится оплатой тарифа в кабинете apipay.kz; грейс-периода нет — блокировка наступает\nсразу после `expires_at`. Состояние видно заранее в `GET /tariff` и\n`GET /account/health` → `tariff.status`. Чтение (`GET`) продолжает работать,\nзакрываются только действия.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/TariffInactiveResponse"
            }
          }
        }
      },
      "KaspiSessionInvalid": {
        "description": "Сессия Kaspi недействительна или истекла",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "error": {
                  "type": "string",
                  "example": "kaspi_session_invalid"
                },
                "message": {
                  "type": "string",
                  "example": "Kaspi session is invalid or expired. Please contact support."
                }
              }
            }
          }
        }
      },
      "OrganizationArchived": {
        "description": "`organization_archived` — организация, к которой привязан ключ, отправлена в архив.\nОтказ приходит на ЛЮБУЮ операцию этого ключа. Ключ при этом остаётся активным, и\nперевыпуск ничего не меняет: доступ возвращает владелец аккаунта. Что именно\nслучилось с организацией, ответ не раскрывает намеренно.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "organization_archived",
              "error_code": "organization_archived",
              "message": "Организация недоступна — обратитесь к владельцу аккаунта."
            }
          }
        }
      },
      "InvoiceCreationUnavailable": {
        "description": "Создание счёта временно недоступно. `error` / `error_code`:\n* `kaspi_session_invalid` — сессия кассира Kaspi истекла (переподключите кассира);\n* `invoices_disabled` — приём новых счетов приостановлен (технические работы). Счёт\n  НЕ создан — повторите позже. Чтение счетов, отмена, возврат и статусы продолжают\n  работать. Стоп может быть ТОЧЕЧНЫМ по кассиру: 503 отдаётся, если приостановлена\n  запись через кассира, на которого адресован счёт (`kaspi_connection_id` — явный\n  или primary по умолчанию); счета на других кассиров организации создаются штатно.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "examples": {
              "kaspi_session_invalid": {
                "value": {
                  "error": "kaspi_session_invalid",
                  "message": "Kaspi session is invalid or expired. Please contact support."
                }
              },
              "invoices_disabled": {
                "value": {
                  "error": "invoices_disabled",
                  "error_code": "invoices_disabled",
                  "message": "Invoice creation is temporarily unavailable due to maintenance. Please retry later."
                }
              }
            }
          }
        }
      },
      "EntranceAuthDisabled": {
        "description": "`entrance_auth_disabled` — подключение/переавторизация кассира временно недоступны\n(технические работы). Повторите позже; уже подключённые кассиры продолжают работать.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "examples": {
              "entrance_auth_disabled": {
                "value": {
                  "error": "entrance_auth_disabled",
                  "message": "Cashier connection is temporarily unavailable"
                }
              }
            }
          }
        }
      },
      "CashierAuthUnavailable": {
        "description": "`entrance_auth_disabled` — подключение кассира временно недоступно (техработы).\n`kaspi_busy` — Kaspi троттлит анти-абузом; сессия закрыта, после паузы нужен\nновый `init`. Отличается от `EntranceAuthDisabled` наличием второй причины —\nиспользуется на шагах, где Kaspi может ответить троттлом (`send-phone`, `verify-otp`).\n",
        "headers": {
          "Retry-After": {
            "description": "Секунд до повторной попытки (только для `kaspi_busy`).",
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "examples": {
              "entrance_auth_disabled": {
                "value": {
                  "error": "entrance_auth_disabled",
                  "message": "Cashier connection is temporarily unavailable"
                }
              },
              "kaspi_busy": {
                "value": {
                  "error": "kaspi_busy",
                  "message": "Kaspi is busy — retry shortly"
                }
              }
            }
          }
        }
      },
      "RateLimitCap": {
        "description": "Превышен антифрод/триал/тарифный лимит на создание счетов. `error_code` — один из\n`trial_daily_limit`, `tariff_limit_reached` (лимит оплаченного тарифа; `meta.mode`\nразличает суточный потолок и бюджет 30-дневного блока), `outstanding_recipient_limit`,\n`outstanding_org_limit`, `recipient_fanout_exceeded`, `kyc_daily_limit_reached`\n(мерчант без одобренной анкеты — боевые счета закрыты, `meta.limit=0`; песочница\nработает и анкеты не требует).\n",
        "headers": {
          "Retry-After": {
            "description": "Секунд до повторной попытки.",
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "error": {
                  "type": "string",
                  "example": "outstanding_recipient_limit"
                },
                "error_code": {
                  "$ref": "#/components/schemas/ErrorCode"
                },
                "message": {
                  "type": "string",
                  "example": "Слишком много неоплаченных счетов на этот номер."
                },
                "retry_after_seconds": {
                  "type": "integer",
                  "example": 60
                },
                "meta": {
                  "type": "object",
                  "nullable": true,
                  "description": "Присутствует у `tariff_limit_reached` и `kyc_daily_limit_reached`. ⚠️ У `trial_daily_limit`, `outstanding_*` и `recipient_fanout_exceeded` поля НЕТ — один компонент описывает две разные формы.",
                  "properties": {
                    "mode": {
                      "type": "string",
                      "enum": [
                        "daily",
                        "monthly"
                      ],
                      "example": "daily"
                    },
                    "limit": {
                      "type": "integer",
                      "example": 100
                    },
                    "used": {
                      "type": "integer",
                      "example": 100
                    },
                    "reset_at": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              }
            }
          }
        }
      },
      "RateLimitWindow": {
        "description": "Превышен ПОМИНУТНЫЙ лимит запросов (`error_code: request_rate_limited`).\n\n⚠️ Поле `message` намеренно осталось прежним — `\"Too Many Attempts.\"`: интеграции,\nразбиравшие строку, продолжают работать. Всё машиночитаемое добавлено рядом.\n\n⚠️ `limit`/`X-RateLimit-Limit` — это бакет, где осталось меньше всего, а не лимит\nконкретной ручки (к запросу применяется несколько лимитеров сразу).\n",
        "headers": {
          "Retry-After": {
            "description": "Секунд до повторной попытки.",
            "schema": {
              "type": "integer"
            }
          },
          "X-RateLimit-Limit": {
            "description": "Потолок самого дефицитного бакета этого запроса.",
            "schema": {
              "type": "integer"
            }
          },
          "X-RateLimit-Remaining": {
            "description": "Остаток в нём (на 429 — всегда 0).",
            "schema": {
              "type": "integer"
            }
          },
          "X-RateLimit-Reset": {
            "description": "Unix-время обнуления окна.",
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "message": {
                  "type": "string",
                  "example": "Too Many Attempts."
                },
                "error": {
                  "type": "string",
                  "example": "request_rate_limited"
                },
                "error_code": {
                  "$ref": "#/components/schemas/ErrorCode"
                },
                "limit": {
                  "type": "integer",
                  "example": 200
                },
                "remaining": {
                  "type": "integer",
                  "example": 0
                },
                "reset_at": {
                  "type": "string",
                  "format": "date-time",
                  "nullable": true
                },
                "retry_after_seconds": {
                  "type": "integer",
                  "example": 37
                }
              }
            }
          }
        }
      },
      "KycRejected": {
        "description": "Мерчант отклонён на KYC-модерации — выставление счетов заблокировано полностью\n(`error_code=kyc_rejected`). Разблокировка — только вручную (обращение в поддержку).\n",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "error": {
                  "type": "string",
                  "example": "kyc_rejected"
                },
                "error_code": {
                  "$ref": "#/components/schemas/ErrorCode"
                },
                "message": {
                  "type": "string",
                  "example": "Выставление счетов ограничено. Обратитесь в поддержку."
                }
              }
            }
          }
        }
      },
      "CashierManagementDisabled": {
        "description": "У ключа нет флага can_manage_cashiers",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "error": {
                  "type": "string",
                  "example": "cashier_management_disabled"
                },
                "message": {
                  "type": "string",
                  "example": "This API key cannot manage cashiers"
                }
              }
            }
          }
        }
      },
      "ConnectionNotFound": {
        "description": "Кассир не найден (или принадлежит другой организации — non-enumeration)",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "error": {
                  "type": "string",
                  "example": "Connection not found"
                }
              }
            }
          }
        }
      }
    },
    "schemas": {
      "ErrorResponse": {
        "type": "object",
        "description": "Конверт бизнес-ошибки.",
        "properties": {
          "error": {
            "type": "string",
            "description": "Slug причины (дублирует error_code для обратной совместимости)."
          },
          "error_code": {
            "$ref": "#/components/schemas/ErrorCode"
          },
          "message": {
            "type": "string",
            "description": "Человекочитаемое описание (может локализоваться — не парсить)."
          }
        }
      },
      "ValidationErrorBody": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string",
            "example": "Validation failed"
          },
          "errors": {
            "type": "object",
            "additionalProperties": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "example": {
              "phone_number": [
                "Phone number must be in format 8XXXXXXXXXX (11 digits starting with 8)"
              ]
            }
          }
        }
      },
      "DuplicateIdempotencyResponse": {
        "type": "object",
        "description": "Дубль external_order_id_idempotency.",
        "properties": {
          "error": {
            "type": "string",
            "example": "duplicate_idempotency_key"
          },
          "message": {
            "type": "string",
            "example": "Счёт с таким ключом идемпотентности уже создан"
          },
          "invoice_id": {
            "type": "integer",
            "example": 12345
          },
          "status": {
            "type": "string",
            "description": "Статус существующего счёта.",
            "example": "pending"
          }
        }
      },
      "SessionExpiredResponse": {
        "type": "object",
        "description": "Сессия кассира Kaspi мертва — счёт/чек не создан. Отказ приходит синхронно. Лечится ТОЛЬКО\nпереавторизацией кассира в кабинете apipay.kz — ретрай не поможет. Состояние видно\nзаранее в `GET /account/health` → `connection.needs_reauth`.\n",
        "properties": {
          "error_code": {
            "$ref": "#/components/schemas/ErrorCode"
          },
          "message": {
            "type": "string",
            "example": "Сессия кассира Kaspi истекла — переавторизуйте кассира в кабинете apipay.kz."
          }
        }
      },
      "TariffInactiveResponse": {
        "type": "object",
        "description": "Подписка мерчанта на ApiPay не активна — платные операции закрыты (счета, чеки,\nвозвраты, отмены, каталог, подписки, lookup клиента, ручная синхронизация). Лечится\nоплатой тарифа в кабинете apipay.kz; **грейс-периода нет** — блокировка наступает\nсразу после `expires_at`. Состояние видно заранее в `GET /tariff` и\n`GET /account/health` → `tariff.status`.\n\nЧтение (`GET`) не блокируется, как и оплата тарифа, управление аккаунтом/ключами и\nпереподключение кассира — иначе мерчант не смог бы разблокироваться.\n",
        "properties": {
          "success": {
            "type": "boolean",
            "example": false
          },
          "error": {
            "type": "string",
            "example": "tariff_inactive"
          },
          "error_code": {
            "$ref": "#/components/schemas/ErrorCode"
          },
          "message": {
            "type": "string",
            "example": "Подписка на ApiPay не активна — продлите тариф в кабинете apipay.kz."
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Дата окончания тарифа. `null` — тариф не заводился вовсе.\n\n⚠️ Интерпретировать только вместе с `reason`: при `reason: cancelled` дата лежит\nв БУДУЩЕМ (подписку отозвали досрочно, срок не переписывали), и текст «истекла N\nдней назад» на ней даёт отрицательное N.\n",
            "example": "2026-07-19T15:01:00+00:00"
          },
          "reason": {
            "type": "string",
            "nullable": true,
            "description": "Почему тариф неактивен:\n* `expired` — срок вышел (`expires_at` в прошлом);\n* `cancelled` — подписку отозвал администратор ApiPay (`expires_at` может быть в\n  будущем);\n* `none` — тариф не заводился ни разу (`expires_at` = `null`).\n",
            "enum": [
              "expired",
              "cancelled",
              "none"
            ],
            "example": "expired"
          }
        }
      },
      "ErrorCode": {
        "type": "string",
        "description": "Стабильный snake_case-код причины ошибки. Появляется в бизнес-ошибках API\n(`error`/`error_code`) и в объектах `invoice.*`/`refund.*` вебхуков (additive,\nтолько если non-null). Каталог значений — единый источник правды.\n",
        "enum": [
          "network_unavailable",
          "session_transient",
          "client_not_found",
          "kaspi_throttled",
          "refund_window_expired",
          "refund_rejected_by_kaspi",
          "refund_requires_buyer_confirmation",
          "refund_insufficient_funds",
          "invoice_already_paid",
          "invoice_already_cancelled",
          "invoice_not_found_in_kaspi",
          "amount_must_be_whole_tenge",
          "qr_cancel_unsupported",
          "catalog_requires_cart_items",
          "catalog_not_supported",
          "organization_not_configured",
          "cart_items_mismatch",
          "qr_render_failed",
          "kaspi_session_invalid",
          "kaspi_session_unavailable",
          "kaspi_session_expired",
          "tariff_inactive",
          "custom_tariff_locked",
          "invoices_disabled",
          "entrance_auth_disabled",
          "manager_throttled",
          "whatsapp_otp_throttled",
          "whatsapp_gateway_error",
          "subscription_payment_failed",
          "image_upload_failed",
          "catalog_item_not_found",
          "barcode_too_long",
          "catalog_item_duplicate",
          "catalog_match_overflow",
          "catalog_item_invalid",
          "catalog_delivery_incomplete",
          "catalog_create_unresolved",
          "catalog_create_blocked",
          "idempotency_key_conflict",
          "catalog_multi_tradepoint",
          "catalog_batch_filter_removed",
          "catalog_delete_scope_required",
          "catalog_delete_owner_key_required",
          "cashbox_settings_owner_key_required",
          "catalog_bulk_delete_mismatch",
          "catalog_busy",
          "request_rate_limited",
          "kaspi_error",
          "unknown_error",
          "outstanding_recipient_limit",
          "outstanding_org_limit",
          "recipient_fanout_exceeded",
          "trial_daily_limit",
          "tariff_limit_reached",
          "content_rejected",
          "field_too_long",
          "description_too_long",
          "kyc_daily_limit_reached",
          "kyc_rejected",
          "qr_refund_expired",
          "qr_refund_not_identified",
          "qr_refund_completed",
          "qr_refund_execution_in_progress",
          "qr_refund_execution_uncertain",
          "qr_refund_execution_result_unavailable",
          "qr_refund_execution_context_unavailable",
          "qr_refund_execution_disabled",
          "qr_refund_temporarily_unavailable",
          "qr_refund_link_limit_reached",
          "qr_refund_link_not_revocable",
          "qr_refund_link_revoked",
          "qr_refund_link_expired",
          "qr_return_scan_timeout",
          "qr_return_identity_timeout",
          "qr_return_not_found",
          "qr_refund_activation_in_progress",
          "qr_refund_activation_failed",
          "qr_refund_actor_no_longer_authorized",
          "organization_not_verified",
          "operation_not_returnable",
          "refund_amount_exceeds_available",
          "cashbox_disabled",
          "cashbox_kkm_unknown",
          "cashbox_no_open_shift",
          "cashbox_shift_already_closed",
          "cashbox_shift_not_found",
          "cashbox_operation_not_found",
          "cashbox_duplicate_operation",
          "cashbox_busy",
          "cashbox_operation_failed",
          "cashbox_unavailable",
          "cashbox_report_unavailable",
          "cashbox_toggle_in_progress",
          "cashbox_toggle_unavailable",
          "receipt_not_available_for_status",
          "receipt_unavailable",
          "receipt_rate_limited",
          "grant_not_granted",
          "grant_snapshot_unreadable",
          "channel_required",
          "organization_archived"
        ]
      },
      "QrRefundSession": {
        "type": "object",
        "description": "Снимок сессии QR-возврата. Kaspi наружу не течёт (нет QrReturnId/ExtTranId).",
        "properties": {
          "id": {
            "type": "integer",
            "example": 42
          },
          "status": {
            "type": "string",
            "enum": [
              "awaiting_customer",
              "activating",
              "awaiting_scan",
              "customer_identified",
              "executing",
              "completed",
              "execution_uncertain",
              "expired",
              "failed"
            ]
          },
          "execution_started_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Когда была взята денежная претензия. Заполнено с `executing` и далее."
          },
          "execution_uncertain_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Когда исход возврата был признан недоказанным. Заполнено только у\n`execution_uncertain`.\n"
          },
          "error_code": {
            "nullable": true,
            "allOf": [
              {
                "$ref": "#/components/schemas/ErrorCode"
              }
            ]
          },
          "error_message": {
            "type": "string",
            "nullable": true
          },
          "client_name": {
            "type": "string",
            "nullable": true,
            "example": "Иван И."
          },
          "qr_token_url": {
            "type": "string",
            "nullable": true,
            "description": "Возвратный QR немедленного старта. ⛔ У сессии, выпущенной как ссылка покупателю,\nздесь ВСЕГДА `null`: её цель отдаётся один раз на публичной странице. После\nдедлайна и на терминальном статусе поле обнуляется и у немедленного старта.\n"
          },
          "qr_image_url": {
            "type": "string",
            "nullable": true,
            "description": "Та же цель картинкой; те же правила обнуления"
          },
          "link_expires_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Срок НЕАКТИВИРОВАННОЙ ссылки (24 часа с выпуска). Не путать с `expires_at`: тот\nпоявляется только после нажатия покупателя и означает окно скана. Не продлевается\nничем — ни открытием страницы, ни перезагрузкой, ни неудачной попыткой.\n"
          },
          "scan_started_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Начало окна скана. Единственный авторитет дедлайна."
          },
          "provider_requested_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Момент выхода запроса в сеть. У боевой сессии равен `scan_started_at`; у песочной\nвсегда `null` — сеть там не звалась.\n"
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Конец окна скана. Не более 90 секунд от `scan_started_at`"
          },
          "identified_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "completed_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "refunded_amount": {
            "type": "string",
            "nullable": true,
            "example": "500.00"
          },
          "receipt_url": {
            "type": "string",
            "nullable": true
          },
          "poll_interval_seconds": {
            "type": "integer",
            "nullable": true,
            "description": "Действующий интервал опроса"
          },
          "scan_wait_timeout_seconds": {
            "type": "integer",
            "nullable": true,
            "description": "ДЕЙСТВУЮЩЕЕ окно скана в секундах — уже прижатое потолком, а не подсказка\nпровайдера. ⚠️ Не более 90; раньше здесь могло оказаться около пяти минут.\n"
          }
        }
      },
      "QrRefundLink": {
        "type": "object",
        "description": "Ответ на выпуск ссылки «Возврат ApiPay». ⛔ `customer_url` отдаётся РОВНО ОДИН РАЗ:\nв базе только необратимый хеш, и восстановить адрес нельзя ничем.\n",
        "properties": {
          "id": {
            "type": "integer",
            "example": 42
          },
          "status": {
            "type": "string",
            "enum": [
              "awaiting_customer"
            ]
          },
          "customer_url": {
            "type": "string",
            "description": "Одноразовая ссылка для покупателя. ⛔ Предъявительская: кто её открыл, тот может\nподтвердить возврат. Не публиковать, не класть в аналитику и в отчёты об ошибках.\n",
            "example": "https://qr.apipay.kz/refund/0PxK5tG2mQ8vY1nB4wR7sL3dF6hJ9cA0eU2iO5pT8xE"
          },
          "link_expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "Срок неактивированной ссылки"
          }
        }
      },
      "QrRefundOperation": {
        "type": "object",
        "properties": {
          "ref": {
            "type": "string",
            "description": "Непрозрачный operation_ref (в details/execute)"
          },
          "amount": {
            "type": "number",
            "example": 500
          },
          "date": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "returnable": {
            "type": "string",
            "enum": [
              "none",
              "partial",
              "full"
            ]
          },
          "source_type": {
            "type": "string",
            "nullable": true,
            "example": "GOLD"
          },
          "sale_type": {
            "type": "string",
            "nullable": true,
            "example": "Remote"
          },
          "client_name": {
            "type": "string",
            "nullable": true,
            "example": "Иван И."
          }
        }
      },
      "QrRefundOperationList": {
        "type": "object",
        "properties": {
          "client_name": {
            "type": "string",
            "nullable": true
          },
          "operations": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/QrRefundOperation"
            }
          },
          "has_more": {
            "type": "boolean"
          },
          "next_cursor": {
            "type": "string",
            "nullable": true
          },
          "remaining_count": {
            "type": "integer"
          }
        }
      },
      "QrRefundOperationDetails": {
        "type": "object",
        "properties": {
          "ref": {
            "type": "string"
          },
          "amount": {
            "type": "number"
          },
          "date": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "returnable": {
            "type": "string",
            "enum": [
              "none",
              "partial",
              "full"
            ]
          },
          "available_for_refund": {
            "type": "number",
            "example": 500
          },
          "already_refunded": {
            "type": "number",
            "example": 0
          },
          "receipt_url": {
            "type": "string",
            "nullable": true
          },
          "items": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "ref": {
                  "type": "string",
                  "description": "Непрозрачный item-ref (в execute.items[].ref)"
                },
                "name": {
                  "type": "string",
                  "nullable": true
                },
                "price": {
                  "type": "number"
                },
                "count": {
                  "type": "number"
                },
                "available_for_refund": {
                  "type": "number"
                }
              }
            }
          }
        }
      },
      "ExecuteQrRefundRequest": {
        "type": "object",
        "required": [
          "operation_ref"
        ],
        "properties": {
          "operation_ref": {
            "type": "string"
          },
          "amount": {
            "type": "number",
            "nullable": true,
            "description": "Частичный возврат суммой (взаимоисключимо с items)"
          },
          "items": {
            "type": "array",
            "nullable": true,
            "description": "Частичный возврат по позициям (взаимоисключимо с amount)",
            "items": {
              "type": "object",
              "required": [
                "ref"
              ],
              "properties": {
                "ref": {
                  "type": "string"
                },
                "amount": {
                  "type": "number",
                  "nullable": true,
                  "description": "По умолчанию — вся доступная сумма позиции"
                }
              }
            }
          },
          "simulate": {
            "type": "object",
            "nullable": true,
            "description": "**Только песочница** (боевая → `403 not_sandbox`). Форсирует исход возврата.\n",
            "required": [
              "status"
            ],
            "properties": {
              "status": {
                "type": "string",
                "enum": [
                  "completed",
                  "failed"
                ]
              },
              "error_code": {
                "type": "string",
                "nullable": true,
                "enum": [
                  "operation_not_returnable",
                  "refund_amount_exceeds_available",
                  "qr_refund_expired",
                  "refund_insufficient_funds",
                  "kaspi_error"
                ]
              }
            }
          }
        }
      },
      "QrRefundExecuted": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "status": {
            "type": "string",
            "example": "completed"
          },
          "refunded_amount": {
            "type": "string",
            "example": "500.00"
          },
          "receipt_url": {
            "type": "string",
            "nullable": true
          },
          "client_name": {
            "type": "string",
            "nullable": true
          },
          "completed_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "QrRefundExecutionAccepted": {
        "type": "object",
        "description": "Тело ответа `202` у execute. ⛔ Это НЕ ошибка запроса: он принят, единственная\nденежная попытка потрачена, а исход не доказан — Kaspi мог возврат применить.\nПовторять `execute` нельзя.\n\nУ `qr_refund_execution_uncertain` присутствуют и поля снимка сессии (она в статусе\n`execution_uncertain`). У `qr_refund_execution_result_unavailable` их НЕТ намеренно:\nсостояние сессии в этот момент достоверно неизвестно, и выдумывать его нельзя.\n",
        "allOf": [
          {
            "$ref": "#/components/schemas/QrRefundSession"
          },
          {
            "type": "object",
            "properties": {
              "error": {
                "type": "string",
                "description": "Дублирует error_code (общий конверт ошибок)"
              },
              "error_code": {
                "$ref": "#/components/schemas/ErrorCode"
              },
              "message": {
                "type": "string"
              }
            },
            "required": [
              "error",
              "error_code",
              "message"
            ]
          }
        ]
      },
      "WebhookQrRefund": {
        "type": "object",
        "description": "Payload событий qr_refund.identified/completed/expired/execution_uncertain.",
        "properties": {
          "event": {
            "type": "string",
            "enum": [
              "qr_refund.identified",
              "qr_refund.completed",
              "qr_refund.expired",
              "qr_refund.failed",
              "qr_refund.execution_uncertain"
            ]
          },
          "qr_refund": {
            "type": "object",
            "properties": {
              "id": {
                "type": "integer"
              },
              "status": {
                "type": "string",
                "enum": [
                  "customer_identified",
                  "completed",
                  "expired",
                  "failed",
                  "execution_uncertain"
                ]
              },
              "client_name": {
                "type": "string",
                "nullable": true,
                "example": "Иван И."
              },
              "expires_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              },
              "refunded_amount": {
                "type": "string",
                "nullable": true,
                "description": "Только при completed"
              },
              "receipt_url": {
                "type": "string",
                "nullable": true,
                "description": "Только при completed"
              },
              "error_code": {
                "$ref": "#/components/schemas/ErrorCode"
              },
              "error_message": {
                "type": "string",
                "nullable": true,
                "description": "Закреплён за событием",
                "не читается с живой сессии": null
              },
              "is_sandbox": {
                "type": "boolean"
              }
            }
          },
          "source": {
            "type": "string",
            "nullable": true
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "InvoiceStatus": {
        "type": "string",
        "description": "Статус счёта. Статуса refunded НЕ существует — полный возврат оставляет paid + is_fully_refunded=true.",
        "enum": [
          "processing",
          "pending",
          "cancelling",
          "paid",
          "cancelled",
          "expired",
          "error",
          "partially_refunded"
        ]
      },
      "PaginationLinks": {
        "type": "object",
        "description": "Ссылки пагинации (meta-обёртка).",
        "properties": {
          "first": {
            "type": "string",
            "nullable": true
          },
          "last": {
            "type": "string",
            "nullable": true
          },
          "prev": {
            "type": "string",
            "nullable": true
          },
          "next": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "PaginationMeta": {
        "type": "object",
        "description": "Метаданные пагинации (offset-режим). В keyset-режиме (`?cursor=`) вместо\n`total`/`from`/`to` приходят `next_cursor`/`prev_cursor`.\n",
        "properties": {
          "current_page": {
            "type": "integer",
            "example": 1
          },
          "from": {
            "type": "integer",
            "nullable": true,
            "example": 1
          },
          "last_page": {
            "type": "integer",
            "example": 2
          },
          "path": {
            "type": "string"
          },
          "per_page": {
            "type": "integer",
            "example": 50
          },
          "to": {
            "type": "integer",
            "nullable": true,
            "example": 50
          },
          "total": {
            "type": "integer",
            "example": 75
          }
        }
      },
      "CartItem": {
        "type": "object",
        "description": "Позиция корзины (счета/QR).",
        "required": [
          "catalog_item_id",
          "count"
        ],
        "properties": {
          "catalog_item_id": {
            "type": "integer",
            "description": "ID товара из каталога (поле `id` из `GET /catalog`). Должен принадлежать организации, иметь цену и НЕ находиться в снятии. ⛔ Позиция с `operation=delete` в корзину не принимается: при массовом удалении она стоит с этим намерением долго (порядок величин — в описании `POST /catalog/bulk-delete`), и счёт, выставленный в это окно, стал бы фискальным документом на товар, которого к моменту оплаты уже нет. Отказ приходит в `errors[\"cart_items.N.catalog_item_id\"]` с текстом `Catalog item is being deleted. Send it again via POST /catalog to cancel the deletion and use it in invoices.` — позиция восстановима: пришлите её обычным `POST /catalog`, удаление отменится, и она снова станет доступна для счетов. Позиция с `status=deleted` даёт отдельный текст (`Catalog item has been deleted.`). ⛔ Позиция, создание которой брошено (`status=failed`, `operation=create`), тоже не принимается: в каталоге Kaspi её нет. Текст — `Catalog item was not created in Kaspi. Update it via PATCH /api/v1/catalog/{id} to retry, then use it in invoices.` ⚠️ Здесь работает именно `PATCH`: повторный `POST /catalog` брошенное создание не возобновляет. ⚠️ Форма отказа зависит от поверхности: `POST /invoices`, `/invoices/qr`, `/static-qr` и создание/обновление подписки отдают `422`, а `POST /invoices/bulk` остаётся `201` и возвращает позицию в `invoices[]` как `failed` с `error_code: catalog_item_not_found`."
          },
          "count": {
            "type": "integer",
            "minimum": 1
          },
          "price": {
            "type": "number",
            "nullable": true,
            "minimum": 0.01,
            "maximum": 99999999.99,
            "description": "Кастомная цена за единицу (заменяет каталожную)."
          }
        }
      },
      "CartItemSimple": {
        "type": "object",
        "description": "Позиция корзины для подписки (без кастомной цены).",
        "required": [
          "catalog_item_id",
          "count"
        ],
        "properties": {
          "catalog_item_id": {
            "type": "integer",
            "description": "Те же правила, что у `CartItem.catalog_item_id`: позиция с `operation=delete` в корзину не принимается (`422`). Если позиция уходит в снятие у уже работающей подписки, гейт очередного списания не объявляет ошибку, а ОТКЛАДЫВАЕТ попытку списания: счётчик неудач не растёт и подписка не уходит в grace period. ⚠️ Наружу это никак не сигнализируется — ни счёта, ни вебхука, ни сдвига `next_billing_at`."
          },
          "count": {
            "type": "integer",
            "minimum": 1
          }
        }
      },
      "CreateInvoiceRequest": {
        "type": "object",
        "required": [
          "phone_number"
        ],
        "properties": {
          "phone_number": {
            "type": "string",
            "pattern": "^8\\d{10}$",
            "example": "87001234567"
          },
          "amount": {
            "type": "number",
            "minimum": 1,
            "multipleOf": 1,
            "maximum": 99999999.99,
            "description": "Сумма в тенге, только целая. Обязательна, если нет cart_items. Игнорируется при наличии cart_items — там проверяется итог корзины после скидок. Дробная сумма → `422 amount_must_be_whole_tenge`.",
            "example": 5000
          },
          "description": {
            "type": "string",
            "maxLength": 500,
            "description": "Описание счёта. Уходит в Kaspi как комментарий к платежу.\n\n⚠️ Kaspi показывает покупателю только **первые 60 символов** — всё, что длиннее,\nон отбрасывает молча, не сообщая об этом ни вам, ни нам. Поэтому с **2026-09-05**\nописание длиннее 60 символов будет отклоняться с `error_code: description_too_long`\n(HTTP 422). До этой даты длинные описания принимаются как прежде.\n\n⛔ Организации, зарегистрированные с 26.08.2026, живут на лимите 60 сразу.\n"
          },
          "internal_comment": {
            "type": "string",
            "maxLength": 255,
            "nullable": true,
            "description": "Внутренняя заметка мерчанта («кто это / что это»). **В Kaspi не передаётся,\nплательщик её не видит** и в чек она не попадает. Возвращается в `GET /invoices`,\n`GET /invoices/{id}`, в выгрузке кабинета и в вебхуках `invoice.status_changed`\nи `invoice.qr_scanned` (только если non-null). Ищется подстрокой через `search`.\nРедактируется после создания — `PATCH /invoices/{id}`, в любом статусе.\n"
          },
          "external_order_id": {
            "type": "string",
            "maxLength": 255
          },
          "external_order_id_idempotency": {
            "type": "string",
            "maxLength": 191,
            "description": "Ключ идемпотентности (уникален в пределах организации). Дубль → 409. Исключение —\nперевыставление: если прежний счёт с этим ключом в статусе expired/cancelled/error,\nсоздаётся новый. Пусто/null → выключено.\n"
          },
          "kaspi_connection_id": {
            "type": "integer",
            "description": "Кассир (default — primary). Обязателен, если >1 активной connection без primary (иначе 422 connection_ambiguous)."
          },
          "cart_items": {
            "type": "array",
            "minItems": 1,
            "maxItems": 100,
            "description": "Корзина. Для этого эндпоинта необязательна и организации с каталогом — счёт можно выставить одной суммой в amount. Организация без каталога с cart_items получит 422.",
            "items": {
              "$ref": "#/components/schemas/CartItem"
            }
          },
          "discount_percentage": {
            "type": "number",
            "minimum": 1,
            "maximum": 99,
            "description": "Глобальная скидка на весь чек (%)."
          }
        }
      },
      "CreateQrInvoiceRequest": {
        "type": "object",
        "description": "Тело зависит от организации — amount (без каталога) либо cart_items (с каталогом).",
        "properties": {
          "amount": {
            "type": "number",
            "minimum": 0.01,
            "maximum": 99999999.99,
            "example": 5000
          },
          "description": {
            "type": "string",
            "maxLength": 100,
            "description": "Наименование позиции в QR-чеке Kaspi (макс 100 — ограничение Kaspi)."
          },
          "internal_comment": {
            "type": "string",
            "maxLength": 255,
            "nullable": true,
            "description": "Внутренняя заметка мерчанта; в Kaspi не уходит и плательщику не видна.\nОграничение `description` в 100 символов (лимит Kaspi на наименование позиции\nв чеке) на это поле **не распространяется** — потолок 255, как в `POST /invoices`.\n"
          },
          "external_order_id": {
            "type": "string",
            "maxLength": 255
          },
          "external_order_id_idempotency": {
            "type": "string",
            "maxLength": 191
          },
          "kaspi_connection_id": {
            "type": "integer"
          },
          "simulate": {
            "type": "string",
            "enum": [
              "paid",
              "cancelled",
              "expired"
            ],
            "description": "Только sandbox — сразу перевести созданный QR-счёт в статус."
          },
          "cart_items": {
            "type": "array",
            "minItems": 1,
            "maxItems": 100,
            "items": {
              "$ref": "#/components/schemas/CartItem"
            }
          },
          "discount_percentage": {
            "type": "number",
            "minimum": 1,
            "maximum": 99
          }
        }
      },
      "BulkInvoiceItem": {
        "type": "object",
        "description": "Элемент пакетного создания (те же правила, что POST /invoices, но без kaspi_connection_id — он общий на пачку).",
        "required": [
          "phone_number"
        ],
        "properties": {
          "phone_number": {
            "type": "string",
            "pattern": "^8\\d{10}$",
            "example": "87001234567"
          },
          "amount": {
            "type": "number",
            "nullable": true,
            "minimum": 1,
            "multipleOf": 1,
            "maximum": 99999999.99,
            "description": "Только целые тенге; дробная → позиция failed с amount_must_be_whole_tenge."
          },
          "description": {
            "type": "string",
            "maxLength": 500,
            "description": "С 2026-09-05 — не длиннее 60 символов (организациям, зарегистрированным с 26.08.2026, — уже сейчас): Kaspi показывает покупателю только первые 60. Превышение приходит построчным отказом в `invoices[]` с `error_code: description_too_long`, ответ остаётся `201`."
          },
          "internal_comment": {
            "type": "string",
            "maxLength": 255,
            "nullable": true,
            "description": "Внутренняя заметка мерчанта; в Kaspi не уходит, плательщику не видна."
          },
          "external_order_id": {
            "type": "string",
            "maxLength": 255
          },
          "external_order_id_idempotency": {
            "type": "string",
            "maxLength": 191
          },
          "cart_items": {
            "type": "array",
            "maxItems": 100,
            "items": {
              "$ref": "#/components/schemas/CartItem"
            }
          },
          "discount_percentage": {
            "type": "number",
            "minimum": 1,
            "maximum": 99
          }
        }
      },
      "UpdateInvoiceInternalCommentRequest": {
        "type": "object",
        "required": [
          "internal_comment"
        ],
        "properties": {
          "internal_comment": {
            "type": "string",
            "maxLength": 255,
            "nullable": true,
            "description": "Новая заметка. `null` или пустая строка стирают её. Ключ обязателен: тело без него → 422."
          }
        }
      },
      "BulkInvoiceResult": {
        "type": "object",
        "description": "Результат по одной позиции батча. Набор полей зависит от `status`:\ncreated → id/amount/external_order_id/phone; duplicate → invoice_id/existing_status;\nfailed → error_code/message (напр. amount_or_cart_required, catalog_not_supported).\n",
        "properties": {
          "index": {
            "type": "integer",
            "description": "Индекс позиции в исходном массиве.",
            "example": 0
          },
          "status": {
            "type": "string",
            "enum": [
              "created",
              "duplicate",
              "failed"
            ]
          },
          "id": {
            "type": "integer",
            "description": "Только status=created."
          },
          "amount": {
            "type": "string",
            "description": "Только status=created."
          },
          "external_order_id": {
            "type": "string",
            "nullable": true,
            "description": "Только status=created."
          },
          "phone": {
            "type": "string",
            "description": "Только status=created."
          },
          "invoice_id": {
            "type": "integer",
            "description": "Только status=duplicate."
          },
          "existing_status": {
            "type": "string",
            "description": "Только status=duplicate."
          },
          "error_code": {
            "type": "string",
            "description": "Только status=failed."
          },
          "message": {
            "type": "string",
            "description": "Только status=failed."
          }
        }
      },
      "InvoiceCreated": {
        "type": "object",
        "description": "Ответ на создание счёта (status=processing). Условные поля появляются только при наличии значения.",
        "properties": {
          "id": {
            "type": "integer",
            "example": 42
          },
          "amount": {
            "type": "string",
            "example": "5000.00"
          },
          "status": {
            "type": "string",
            "example": "processing"
          },
          "paid_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "null при создании."
          },
          "phone": {
            "type": "string",
            "example": "87001234567"
          },
          "is_imported": {
            "type": "boolean",
            "description": "`true` — продажа подтянута из истории Kaspi (проведена в приложении Kaspi Pay мимо ApiPay). Та же ось, что у фильтра `origin` в `GET /invoices`.",
            "example": false
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-01-10T12:00:00+00:00"
          },
          "kaspi_source_type": {
            "type": "string",
            "description": "Только если non-null."
          },
          "kaspi_sale_type": {
            "type": "string",
            "description": "Только если non-null."
          },
          "subtotal": {
            "type": "string",
            "description": "Только при наличии скидки/корзины (вместе с discount_sum)."
          },
          "discount_sum": {
            "type": "string"
          },
          "discount_percentage": {
            "type": "string",
            "description": "Только если передан."
          },
          "error_message": {
            "type": "string",
            "description": "Только при status=error."
          }
        }
      },
      "QrInvoiceCreated": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "example": 1
          },
          "amount": {
            "type": "string",
            "example": "5000.00"
          },
          "status": {
            "type": "string",
            "example": "pending"
          },
          "paid_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "phone": {
            "type": "string",
            "nullable": true,
            "description": "null для QR-счёта без номера."
          },
          "is_imported": {
            "type": "boolean",
            "description": "`true` — продажа подтянута из истории Kaspi (проведена в приложении Kaspi Pay мимо ApiPay). Та же ось, что у фильтра `origin` в `GET /invoices`.",
            "example": false
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-01-10T12:00:00+00:00"
          },
          "is_qr_token": {
            "type": "boolean",
            "example": true
          },
          "qr_token_url": {
            "type": "string",
            "description": "Ссылка Kaspi для QR.",
            "example": "https://qr.kaspi.kz/..."
          },
          "qr_image_url": {
            "type": "string",
            "nullable": true,
            "description": "PNG QR на нашем хранилище. Живёт до `qr_expires_at + 60 сек`, дальше `404` — перевыпустите QR, а не перезагружайте картинку."
          },
          "qr_expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "Крайний момент, когда QR ещё можно ОТСКАНИРОВАТЬ. Длительность окна задаёт Kaspi — считайте её из этого поля, не константой. Начатая до него оплата завершается и позже. Терминальный статус диктует Kaspi — ждите вебхук.",
            "example": "2026-01-10T12:03:00+00:00"
          }
        }
      },
      "CreateStaticQrRequest": {
        "type": "object",
        "description": "Данные будущего счёта. Укажите `amount` **либо** `cart_items` (для мерчантов с\nкаталогом — `cart_items`). `description` ≤ 100 символов (лимит Kaspi на имя позиции),\nа для листов с оплатой по номеру телефона — ещё и лимит описания счёта (см. поле).\nПозиции каталога и `kaspi_connection_id` проверяются на принадлежность вашей\nорганизации **сразу** — до печати листа (`422`, см. операцию).\n",
        "properties": {
          "amount": {
            "type": "number",
            "description": "Сумма сделки. Обязателен, если нет cart_items.",
            "example": 5000
          },
          "cart_items": {
            "type": "array",
            "description": "Позиции из синхронизированного каталога (для мерчантов с каталогом).",
            "items": {
              "type": "object",
              "required": [
                "catalog_item_id",
                "count"
              ],
              "properties": {
                "catalog_item_id": {
                  "type": "integer",
                  "example": 42
                },
                "count": {
                  "type": "integer",
                  "minimum": 1,
                  "example": 1
                },
                "price": {
                  "type": "number",
                  "nullable": true,
                  "example": 5000
                }
              }
            }
          },
          "discount_percentage": {
            "type": "number",
            "nullable": true,
            "minimum": 1,
            "maximum": 99
          },
          "description": {
            "type": "string",
            "maxLength": 100,
            "nullable": true,
            "example": "Заказ №1024",
            "description": "Наименование позиции в чеке Kaspi — до 100 символов (предел Kaspi).\n\n⚠️ Лист должен принимать оплату **по номеру телефона** (запасной путь есть на\nстранице каждого листа), поэтому описание обязано укладываться ещё и в лимит\nописания счёта: с 2026-09-05 — 60 символов, у организаций, зарегистрированных\nс 26.08.2026, — уже сейчас. Слишком длинное описание отбивается при выпуске\nлиста с `error_code: description_too_long`, а не ломает телефонный путь молча.\n⛔ Изменить описание у выпущенного листа нельзя — только `DELETE` и новый лист.\n"
          },
          "external_order_id": {
            "type": "string",
            "maxLength": 255,
            "nullable": true,
            "description": "Ссылка на сделку в вашей системе (придёт в вебхуке)."
          },
          "single_use": {
            "type": "boolean",
            "default": true,
            "description": "true — одна сделка (замок «Оплачено» после оплаты)."
          },
          "kaspi_connection_id": {
            "type": "integer",
            "nullable": true,
            "description": "Кассир/точка (по умолчанию primary)."
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Срок годности сделки (в будущем)."
          }
        }
      },
      "StaticQrCreated": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "example": 10
          },
          "token": {
            "type": "string",
            "description": "Неугадываемый токен — кодируется в QR (в URL печатного листа).",
            "example": "9f1c…"
          },
          "short_code": {
            "type": "string",
            "description": "8 символов для ручного ввода на qr.apipay.kz.",
            "example": "K7M9P2Q4"
          },
          "print_url": {
            "type": "string",
            "description": "URL, зашитый в QR-код листа.",
            "example": "https://qr.apipay.kz/9f1c…"
          },
          "manual_url": {
            "type": "string",
            "description": "Куда вводить short_code, если скан не сработал.",
            "example": "https://qr.apipay.kz"
          },
          "qr_image_url": {
            "type": "string",
            "nullable": true,
            "description": "Готовый PNG печатного QR (непротухающий)."
          },
          "amount": {
            "type": "string",
            "nullable": true,
            "example": "5000.00"
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "external_order_id": {
            "type": "string",
            "nullable": true
          },
          "single_use": {
            "type": "boolean",
            "example": true
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "paid",
              "disabled"
            ],
            "example": "active"
          },
          "is_sandbox": {
            "type": "boolean",
            "example": false
          },
          "scan_count": {
            "type": "integer",
            "example": 0
          },
          "paid": {
            "type": "boolean",
            "description": "Есть ли оплаченный связанный счёт. Частично возвращённый счёт (partially_refunded) тоже считается оплаченным: деньги по сделке получены, частичный возврат её не открывает заново.",
            "example": false
          },
          "paid_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-07-26T12:00:00+05:00"
          }
        }
      },
      "InvoiceItem": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "example": 1
          },
          "invoice_id": {
            "type": "integer",
            "example": 42
          },
          "catalog_item_id": {
            "type": "integer",
            "nullable": true,
            "description": "null если товар удалён."
          },
          "name": {
            "type": "string",
            "example": "Coffee Latte"
          },
          "price": {
            "type": "string",
            "example": "1800.00"
          },
          "count": {
            "type": "integer",
            "example": 2
          },
          "unit_id": {
            "type": "integer",
            "nullable": true,
            "example": 1
          },
          "discount": {
            "type": "string",
            "nullable": true
          },
          "barcode": {
            "type": "string",
            "nullable": true,
            "description": "Нацкаталог-поля позиции (если были в корзине)."
          },
          "ntin": {
            "type": "string",
            "nullable": true
          },
          "gtin": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "FiscalReceiptListItem": {
        "type": "object",
        "description": "Элемент истории чеков (GET /receipts) — та же форма, что отдаёт GET /receipts/{id}.\nСуммы — строки (decimal). `raw_request`/`raw_response` наружу не отдаются.\n",
        "properties": {
          "id": {
            "type": "integer",
            "example": 42
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "issued",
              "failed"
            ]
          },
          "payment_type": {
            "type": "integer",
            "enum": [
              3,
              5
            ]
          },
          "invoice_id": {
            "type": "integer",
            "nullable": true,
            "description": "Счёт, к которому привязан чек (если есть)."
          },
          "client_operation_id": {
            "type": "string",
            "nullable": true
          },
          "total_price": {
            "type": "string",
            "nullable": true,
            "example": "1500.00"
          },
          "received_amt": {
            "type": "string",
            "nullable": true,
            "example": "2000.00"
          },
          "fpd": {
            "type": "string",
            "nullable": true
          },
          "operation_id": {
            "type": "string",
            "nullable": true
          },
          "operation_time": {
            "type": "string",
            "nullable": true
          },
          "shift_number": {
            "type": "integer",
            "nullable": true
          },
          "link": {
            "type": "string",
            "nullable": true,
            "description": "Ссылка на чек на receipt.kaspi.kz."
          },
          "error_code": {
            "type": "string",
            "nullable": true
          },
          "error_message": {
            "type": "string",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "InvoiceListItem": {
        "type": "object",
        "description": "Элемент списка счетов (сериализация модели, даты UTC +00:00).",
        "properties": {
          "id": {
            "type": "integer",
            "example": 1
          },
          "amount": {
            "type": "string",
            "example": "5000.00"
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "external_order_id": {
            "type": "string",
            "nullable": true,
            "example": "order-123"
          },
          "status": {
            "$ref": "#/components/schemas/InvoiceStatus"
          },
          "kaspi_invoice_id": {
            "type": "string",
            "nullable": true,
            "example": "13234689513"
          },
          "kaspi_qr_link": {
            "type": "string",
            "nullable": true,
            "description": "Ссылка на оплату этого счёта по QR — нарисуйте из неё QR-код покупателю. Вычисляется из kaspi_invoice_id, не хранится. `null`, пока Kaspi не присвоил идентификатор (статус `processing`), и всегда `null` в песочнице. Не путать с `qr_token_url` — то отдельный механизм QR-token счетов.",
            "example": "https://kaspi.kz/qr/pay?tranId=QR13234689513"
          },
          "phone": {
            "type": "string",
            "example": "87001234567"
          },
          "client_name": {
            "type": "string",
            "nullable": true,
            "example": "Иван Иванов"
          },
          "internal_comment": {
            "type": "string",
            "nullable": true,
            "description": "Внутренняя заметка мерчанта; в Kaspi не уходит, плательщику не видна.",
            "example": "Айгуль, самовывоз"
          },
          "is_sandbox": {
            "type": "boolean",
            "example": false
          },
          "is_recurring": {
            "type": "boolean",
            "description": "Счёт создан подпиской.",
            "example": false
          },
          "is_imported": {
            "type": "boolean",
            "description": "`true` — продажа подтянута из истории Kaspi (проведена в приложении Kaspi Pay мимо ApiPay). Та же ось, что у фильтра `origin` в `GET /invoices`.",
            "example": false
          },
          "subtotal": {
            "type": "string",
            "nullable": true
          },
          "discount_sum": {
            "type": "string",
            "nullable": true
          },
          "discount_percentage": {
            "type": "string",
            "nullable": true
          },
          "total_refunded": {
            "type": "string",
            "example": "0.00"
          },
          "is_fully_refunded": {
            "type": "boolean",
            "example": false
          },
          "error_message": {
            "type": "string",
            "nullable": true
          },
          "error_code": {
            "$ref": "#/components/schemas/ErrorCode"
          },
          "paid_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-01-10T12:00:00+00:00"
          },
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/InvoiceItem"
            }
          }
        }
      },
      "Invoice": {
        "type": "object",
        "description": "Полный объект счёта (GET /invoices/{id}). Даты UTC +00:00.",
        "properties": {
          "id": {
            "type": "integer",
            "example": 42
          },
          "amount": {
            "type": "string",
            "example": "15000.00"
          },
          "phone_number": {
            "type": "string",
            "example": "87001234567"
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "external_order_id": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "$ref": "#/components/schemas/InvoiceStatus"
          },
          "client_name": {
            "type": "string",
            "nullable": true,
            "example": "Иван Иванов"
          },
          "internal_comment": {
            "type": "string",
            "nullable": true,
            "description": "Внутренняя заметка мерчанта (правится через PATCH /invoices/{id})."
          },
          "is_sandbox": {
            "type": "boolean",
            "example": false
          },
          "is_imported": {
            "type": "boolean",
            "description": "`true` — продажа подтянута из истории Kaspi (проведена в приложении Kaspi Pay мимо ApiPay). Та же ось, что у фильтра `origin` в `GET /invoices`.",
            "example": false
          },
          "total_refunded": {
            "type": "string",
            "example": "0.00"
          },
          "is_fully_refunded": {
            "type": "boolean",
            "example": false
          },
          "kaspi_invoice_id": {
            "type": "string",
            "nullable": true,
            "example": "13234689513"
          },
          "kaspi_qr_link": {
            "type": "string",
            "nullable": true,
            "description": "Ссылка на оплату этого счёта по QR — нарисуйте из неё QR-код покупателю. Вычисляется из kaspi_invoice_id, не хранится. `null`, пока Kaspi не присвоил идентификатор (статус `processing`), и всегда `null` в песочнице. Не путать с `qr_token_url` — то отдельный механизм QR-token счетов.",
            "example": "https://kaspi.kz/qr/pay?tranId=QR13234689513"
          },
          "error_message": {
            "type": "string",
            "nullable": true
          },
          "error_code": {
            "$ref": "#/components/schemas/ErrorCode"
          },
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/InvoiceItem"
            }
          },
          "subtotal": {
            "type": "string",
            "description": "Только при наличии скидки."
          },
          "discount_sum": {
            "type": "string",
            "description": "Только при наличии скидки."
          },
          "discount_percentage": {
            "type": "string",
            "description": "Только если передан."
          },
          "paid_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-01-10T12:00:00+00:00"
          }
        }
      },
      "InvoiceReceipt": {
        "type": "object",
        "description": "Ссылки на чек Kaspi по оплаченному счёту (GET /invoices/{id}/receipt).",
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "ready"
            ],
            "example": "ready"
          },
          "receipt_link": {
            "type": "string",
            "nullable": true,
            "description": "Фискальная форма чека. Секрета не несёт.",
            "example": "https://receipt.kaspi.kz/web/fiscal?i=000000000000&f=000000000000&s=10&t=2026-01-10%2012%3A00%3A00.000000"
          },
          "download_link": {
            "type": "string",
            "nullable": true,
            "description": "Прямая ссылка на PDF. ⚠️ Содержит секретный `hash` — не публикуйте и не логируйте.",
            "example": "https://receipt.kaspi.kz/api/v3/receipt/download?extTranId=QR00000000000&sale_date=…&hash=…&locale=ru"
          },
          "share_link": {
            "type": "string",
            "nullable": true,
            "description": "Ссылка для покупателя. ⚠️ Содержит тот же секретный `hash`.",
            "example": "https://receipt.kaspi.kz/web?extTranId=QR00000000000&hash=…"
          },
          "sale_date": {
            "type": "string",
            "nullable": true,
            "description": "Время продажи в том виде, как его отдал Kaspi (микросекунды значащие).",
            "example": "2026-01-10 12:00:00.000000"
          },
          "fetched_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Когда чек был получен от Kaspi."
          }
        }
      },
      "InvoiceStats": {
        "type": "object",
        "properties": {
          "total_invoices": {
            "type": "integer",
            "example": 100
          },
          "paid_invoices": {
            "type": "integer",
            "example": 75
          },
          "pending_invoices": {
            "type": "integer",
            "description": "Включает processing.",
            "example": 10
          },
          "cancelled_invoices": {
            "type": "integer",
            "example": 10
          },
          "expired_invoices": {
            "type": "integer",
            "example": 5
          },
          "total_amount": {
            "type": "number",
            "example": 500000
          },
          "paid_amount": {
            "type": "number",
            "description": "«Собрано» за период (аналога total_collected нет).",
            "example": 375000
          },
          "pending_amount": {
            "type": "number",
            "example": 50000
          },
          "cancelled_amount": {
            "type": "number",
            "example": 50000
          },
          "expired_amount": {
            "type": "number",
            "example": 25000
          },
          "conversion_rate": {
            "type": "number",
            "description": "paid/total*100, округл. до 2 знаков (0 если счетов нет).",
            "example": 75
          },
          "period": {
            "type": "object",
            "description": "Присутствует, когда диапазон резолвится. Эхо фактически применённых границ\nв зоне мерчанта (ISO-8601 с офсетом `+05:00`).\n",
            "properties": {
              "start": {
                "type": "string",
                "example": "2026-08-03T11:00:00+05:00"
              },
              "end": {
                "type": "string",
                "example": "2026-08-04T03:00:00+05:00"
              }
            }
          }
        }
      },
      "CreateRefundRequest": {
        "type": "object",
        "properties": {
          "amount": {
            "type": "number",
            "minimum": 0.01,
            "maximum": 99999999.99,
            "description": "Сумма возврата. Без неё — полный возврат."
          },
          "reason": {
            "type": "string",
            "maxLength": 500
          },
          "return_items": {
            "type": "array",
            "minItems": 1,
            "maxItems": 100,
            "description": "Позиционный возврат — на позицию РОВНО одно из count/amount.",
            "items": {
              "type": "object",
              "required": [
                "catalog_item_id"
              ],
              "properties": {
                "catalog_item_id": {
                  "type": "integer"
                },
                "count": {
                  "type": "integer",
                  "minimum": 1,
                  "description": "Целые штуки. Взаимоисключимо с amount."
                },
                "amount": {
                  "type": "number",
                  "minimum": 0.01,
                  "maximum": 9999999.99,
                  "description": "Произвольная сумма по позиции. Взаимоисключимо с count."
                }
              }
            }
          }
        }
      },
      "RefundShort": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "example": 5
          },
          "invoice_id": {
            "type": "integer",
            "example": 42
          },
          "amount": {
            "type": "string",
            "example": "5000.00"
          },
          "reason": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "processing",
              "completed",
              "failed"
            ],
            "example": "pending"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-01-10T12:00:00+00:00"
          }
        }
      },
      "RefundItem": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "refund_id": {
            "type": "integer"
          },
          "invoice_item_id": {
            "type": "integer"
          },
          "catalog_item_id": {
            "type": "integer",
            "nullable": true
          },
          "name": {
            "type": "string"
          },
          "price": {
            "type": "string"
          },
          "count": {
            "type": "integer",
            "description": "0 при возврате по сумме (return_items[].amount)."
          },
          "amount": {
            "type": "string"
          }
        }
      },
      "Refund": {
        "type": "object",
        "description": "Возврат (в списке возвратов счёта). Даты UTC +00:00.",
        "properties": {
          "id": {
            "type": "integer",
            "example": 5
          },
          "invoice_id": {
            "type": "integer",
            "example": 42
          },
          "amount": {
            "type": "string",
            "example": "5000.00"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "processing",
              "completed",
              "failed"
            ],
            "example": "completed"
          },
          "reason": {
            "type": "string",
            "nullable": true
          },
          "kaspi_refund_id": {
            "type": "string",
            "nullable": true,
            "description": "null пока не проведён / при неудаче.",
            "example": "1126827352"
          },
          "error_message": {
            "type": "string",
            "nullable": true,
            "description": "Текст причины (для failed). В вебхуке этого поля нет."
          },
          "error_code": {
            "$ref": "#/components/schemas/ErrorCode"
          },
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/RefundItem"
            }
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "RefundWithInvoice": {
        "type": "object",
        "description": "Возврат в общем списке /refunds (со ссылкой на счёт).",
        "properties": {
          "id": {
            "type": "integer",
            "example": 5
          },
          "invoice_id": {
            "type": "integer",
            "example": 42
          },
          "amount": {
            "type": "string",
            "example": "5000.00"
          },
          "kaspi_refund_id": {
            "type": "string",
            "nullable": true,
            "example": "REF-123"
          },
          "kaspi_status": {
            "type": "string",
            "nullable": true,
            "example": "completed"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "processing",
              "completed",
              "failed"
            ],
            "example": "completed"
          },
          "reason": {
            "type": "string",
            "nullable": true
          },
          "initiated_by": {
            "type": "string",
            "description": "api / dashboard / system.",
            "example": "api"
          },
          "error_message": {
            "type": "string",
            "nullable": true
          },
          "error_code": {
            "$ref": "#/components/schemas/ErrorCode"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "invoice": {
            "type": "object",
            "properties": {
              "id": {
                "type": "integer",
                "example": 42
              },
              "external_order_id": {
                "type": "string",
                "nullable": true
              },
              "amount": {
                "type": "string",
                "example": "15000.00"
              },
              "status": {
                "type": "string",
                "example": "paid"
              },
              "kaspi_invoice_id": {
                "type": "string",
                "nullable": true
              }
            }
          },
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/RefundItem"
            }
          }
        }
      },
      "WebhookLogEntry": {
        "type": "object",
        "description": "Запись лога доставки вебхука (GET /webhook-logs). Логи хранятся 14 дней.",
        "properties": {
          "id": {
            "type": "integer",
            "example": 1
          },
          "api_key_id": {
            "type": "integer",
            "nullable": true,
            "example": 1
          },
          "api_key_name": {
            "type": "string",
            "nullable": true,
            "example": "Production"
          },
          "invoice_id": {
            "type": "integer",
            "nullable": true,
            "example": 42
          },
          "event": {
            "type": "string",
            "nullable": true,
            "description": "Событие вебхука (invoice.status_changed / invoice.qr_scanned / invoice.refunded / ...).",
            "example": "invoice.status_changed"
          },
          "url": {
            "type": "string",
            "example": "https://example.com/webhook"
          },
          "request_body": {
            "type": "string",
            "description": "Полный отправленный payload (JSON-строка)."
          },
          "response_body": {
            "type": "string",
            "nullable": true,
            "description": "Ответ приёмника (обрезан до 4096 байт)."
          },
          "response_status": {
            "type": "integer",
            "nullable": true,
            "example": 200
          },
          "status": {
            "type": "string",
            "enum": [
              "success",
              "failed"
            ],
            "description": "Итог доставки этой попытки."
          },
          "response_time_ms": {
            "type": "integer",
            "nullable": true,
            "example": 150
          },
          "error_message": {
            "type": "string",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "retry_of": {
            "type": "integer",
            "nullable": true,
            "description": "ID исходного лога",
            "если это повторная отправка.": null
          }
        }
      },
      "CatalogWebhookLogEntry": {
        "type": "object",
        "description": "Запись лога доставки вебхука catalog.item_processed (GET /catalog/webhook-logs). Логи хранятся 3 дня.",
        "properties": {
          "id": {
            "type": "integer",
            "example": 1
          },
          "api_key_id": {
            "type": "integer",
            "nullable": true,
            "example": 1
          },
          "api_key_name": {
            "type": "string",
            "nullable": true,
            "example": "Production"
          },
          "catalog_item_id": {
            "type": "integer",
            "nullable": true,
            "example": 12345
          },
          "event": {
            "type": "string",
            "example": "catalog.item_processed"
          },
          "url": {
            "type": "string",
            "example": "https://example.com/webhook"
          },
          "request_body": {
            "type": "string",
            "description": "Полный отправленный payload (JSON-строка)."
          },
          "response_body": {
            "type": "string",
            "nullable": true,
            "description": "Ответ приёмника (обрезан до 1000 байт)."
          },
          "response_status": {
            "type": "integer",
            "nullable": true,
            "example": 200
          },
          "status": {
            "type": "string",
            "enum": [
              "success",
              "failed"
            ],
            "description": "Итог доставки этой попытки."
          },
          "response_time_ms": {
            "type": "integer",
            "nullable": true,
            "example": 150
          },
          "error_message": {
            "type": "string",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "CatalogQueueItem": {
        "type": "object",
        "description": "Слим-строка pending-очереди приёма каталога (GET /catalog/queue).",
        "properties": {
          "id": {
            "type": "integer",
            "example": 123
          },
          "external_ref": {
            "type": "string",
            "nullable": true,
            "example": "1C-000123",
            "description": "Клиентская ссылка 1С (ключ маппинга)."
          },
          "name": {
            "type": "string",
            "example": "Фильтр масляный"
          },
          "queued_at": {
            "type": "string",
            "format": "date-time",
            "description": "Момент постановки в очередь (created_at), Asia/Almaty +05:00.",
            "example": "2026-07-11T10:00:00+05:00"
          },
          "source": {
            "type": "string",
            "enum": [
              "own",
              "shared",
              "other"
            ],
            "example": "own",
            "description": "Кто завёл позицию, ГЛАЗАМИ спрашивающего: `own` — вы; `shared` — сам мерчант (кабинет) либо позиция приехала из каталога Kaspi; `other` — другая интеграция этого мерчанта. Имя соседней интеграции не отдаётся. `other` можно читать, но не изменять и не удалять (`409 catalog_item_foreign_channel`). Для мерчантских ключей и сессии кабинета значение всегда `own`."
          }
        }
      },
      "CatalogQueueBlock": {
        "type": "object",
        "description": "Сводка состояния очереди приёма каталога. `eta_minutes` — целое ТОЛЬКО при\n`state=draining` и непустой своей очереди, иначе null. `ahead_in_cashier_queue` —\nчисло pending кассира (всех его организаций) до конца своей очереди включительно.\n",
        "properties": {
          "state": {
            "type": "string",
            "enum": [
              "draining",
              "paused_throttle",
              "paused_hold",
              "not_connected",
              "sandbox"
            ],
            "description": "draining — очередь двигается; paused_throttle — пауза из-за троттла Kaspi\n(см. throttle_retry_in_seconds); paused_hold — пауза-накопление (hold);\nnot_connected — у организации нет подключённого кассира; sandbox — sandbox-режим.\n",
            "example": "draining"
          },
          "ahead_in_cashier_queue": {
            "type": "integer",
            "example": 2200
          },
          "eta_minutes": {
            "type": "integer",
            "nullable": true,
            "example": 11
          },
          "throttle_retry_in_seconds": {
            "type": "integer",
            "nullable": true,
            "description": "Секунды до остывания троттла (только при state=paused_throttle).",
            "example": null
          }
        }
      },
      "CatalogErrorItem": {
        "type": "object",
        "description": "Отказанная операция каталога (GET /catalog/errors). `operation` показывает, что\nименно отказало; `error_message` обезличен: сырые ответы Kaspi заменяются\ngeneric-текстом, для распознанных слагов остаётся actionable.\n",
        "properties": {
          "id": {
            "type": "integer",
            "example": 456
          },
          "external_ref": {
            "type": "string",
            "nullable": true,
            "example": "1C-000456"
          },
          "name": {
            "type": "string",
            "example": "Фильтр воздушный"
          },
          "barcode": {
            "type": "string",
            "nullable": true,
            "example": "4600000000001"
          },
          "ntin": {
            "type": "string",
            "nullable": true,
            "example": "00000000000001"
          },
          "operation": {
            "type": "string",
            "enum": [
              "create",
              "update",
              "delete"
            ],
            "example": "update"
          },
          "error_code": {
            "type": "string",
            "nullable": true,
            "example": "catalog_item_duplicate"
          },
          "error_message": {
            "type": "string",
            "description": "Обезличенный клиентский текст (без сырых ответов Kaspi).",
            "example": "Такая позиция уже есть в каталоге."
          },
          "queued_at": {
            "type": "string",
            "format": "date-time",
            "description": "created_at, +05:00.",
            "example": "2026-07-11T10:00:00+05:00"
          },
          "failed_at": {
            "type": "string",
            "format": "date-time",
            "description": "Момент отказа операции, ISO 8601.",
            "example": "2026-07-11T10:03:00+05:00"
          },
          "source": {
            "type": "string",
            "enum": [
              "own",
              "shared",
              "other"
            ],
            "example": "own",
            "description": "Кто завёл позицию, ГЛАЗАМИ спрашивающего: `own` — вы; `shared` — сам мерчант (кабинет) либо позиция приехала из каталога Kaspi; `other` — другая интеграция этого мерчанта. Имя соседней интеграции не отдаётся. `other` можно читать, но не изменять и не удалять (`409 catalog_item_foreign_channel`). Для мерчантских ключей и сессии кабинета значение всегда `own`."
          }
        }
      },
      "WebhookCatalogItemProcessed": {
        "type": "object",
        "description": "Payload вебхука catalog.item_processed. Таймстамп UTC +00:00. Сверяйте по\ncatalog_item.external_ref (ключ маппинга 1С, UNIQUE в пределах оси организации).\n",
        "properties": {
          "event": {
            "type": "string",
            "example": "catalog.item_processed"
          },
          "catalog_item": {
            "type": "object",
            "properties": {
              "id": {
                "type": "integer",
                "example": 12345
              },
              "external_ref": {
                "type": "string",
                "nullable": true,
                "description": "Клиентская ссылка 1С (ключ сверки, UNIQUE в пределах боевой/песочной оси организации).",
                "example": "1c-товар-0042"
              },
              "kaspi_item_id": {
                "type": "string",
                "nullable": true,
                "example": "MP-987654"
              },
              "name": {
                "type": "string",
                "example": "Кофе зерновой 1 кг"
              },
              "barcode": {
                "type": "string",
                "nullable": true,
                "example": "4870000000001"
              },
              "ntin": {
                "type": "string",
                "nullable": true,
                "example": "07622300000001"
              },
              "gtin": {
                "type": "string",
                "nullable": true,
                "example": "07622300000001"
              },
              "ntin_missing": {
                "type": "boolean",
                "description": "true = есть barcode, нет НТИН → позиция не попадёт в фискальный чек (notify-only).",
                "example": false
              },
              "status": {
                "type": "string",
                "enum": [
                  "active",
                  "pending",
                  "deleting",
                  "deleted",
                  "failed"
                ],
                "example": "active",
                "description": "Тот же производный статус, что у GET /catalog — словарь один на обе поверхности. Вид открытой операции дополнительно виден в operation."
              },
              "sellable": {
                "type": "boolean",
                "description": "Можно ли ставить позицию в счёт: снимаемую позицию корзина отбивает. Позиция, создание которой в работе, продаётся — счёт по ней уйдёт разовой продажей до подтверждения Kaspi; брошенное создание (status=failed) не продаётся.",
                "example": true
              },
              "in_kaspi_catalog": {
                "type": "boolean",
                "description": "Уйдёт ли позиция как каталожный товар с маркировкой Нацкаталога. false = счёт создастся, но позиция уедет быстрой продажей и маркировки в фискальном чеке не будет. ⚠️ В песочнице всегда false (синтетическая идентичность тестовой оси) — о боевой позиции это ничего не говорит.",
                "example": true
              },
              "operation": {
                "type": "string",
                "nullable": true,
                "enum": [
                  "create",
                  "update",
                  "delete"
                ],
                "description": "Отказанная операция; null после успешного завершения.",
                "example": null
              },
              "error_code": {
                "type": "string",
                "nullable": true,
                "description": "snake_case-код отказа operation.",
                "example": null
              },
              "error_message": {
                "type": "string",
                "nullable": true
              },
              "failed_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              }
            }
          },
          "timestamp": {
            "type": "string",
            "format": "date-time",
            "example": "2026-07-09T14:37:00+00:00"
          }
        }
      },
      "WebhookReceiptEvent": {
        "type": "object",
        "description": "Payload вебхуков receipt.issued / receipt.failed. Таймстамп UTC +00:00.\nДедуп по `(event, receipt.id)`. При failed — детали в `receipt.error_code`.\n",
        "properties": {
          "event": {
            "type": "string",
            "enum": [
              "receipt.issued",
              "receipt.failed"
            ],
            "example": "receipt.issued"
          },
          "receipt": {
            "type": "object",
            "properties": {
              "id": {
                "type": "integer",
                "example": 4210
              },
              "client_operation_id": {
                "type": "string",
                "nullable": true,
                "example": "pos-cash-0042"
              },
              "payment_type": {
                "type": "integer",
                "enum": [
                  3,
                  5
                ],
                "example": 3
              },
              "status": {
                "type": "string",
                "enum": [
                  "issued",
                  "failed"
                ],
                "example": "issued"
              },
              "fpd": {
                "type": "string",
                "nullable": true,
                "example": "000000000000"
              },
              "operation_id": {
                "type": "string",
                "nullable": true,
                "example": "KKM00000000"
              },
              "link": {
                "type": "string",
                "nullable": true,
                "description": "Публичная страница чека (`receipt.kaspi.kz/web/fiscal`) — открывается в обычном браузере.",
                "example": "https://receipt.kaspi.kz/web/fiscal?i=000000000000&f=000000000000&s=10&t=2026-07-12%2015%3A25%3A43"
              },
              "shift_number": {
                "type": "integer",
                "nullable": true,
                "example": 106
              },
              "total_price": {
                "type": "string",
                "nullable": true,
                "example": "10.00"
              },
              "error_code": {
                "type": "string",
                "nullable": true,
                "example": null
              },
              "error_message": {
                "type": "string",
                "nullable": true
              }
            }
          },
          "timestamp": {
            "type": "string",
            "format": "date-time",
            "example": "2026-07-12T16:25:43+00:00"
          }
        }
      },
      "TariffSnapshot": {
        "type": "object",
        "description": "Снимок подписки мерчанта на ApiPay. Даты Asia/Almaty +05:00.",
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "none",
              "trial",
              "active",
              "frozen",
              "expired"
            ],
            "description": "`frozen` — организация приостановлена владельцем, и часы тарифа на это время\nостановлены: срок не расходуется, а при возвращении мерчанту возвращается\nнеиспользованный остаток — столько дней, сколько оставалось на момент остановки.\nЗначение перекрывает `expired` — не показывайте «тариф истёк» и не предлагайте\nоплату. Сохранённый срок читайте в `freeze.preserved_days`: `expires_at` и\n`days_remaining` при паузе описывают прежнюю дату и могут быть в прошлом.\n",
            "example": "active"
          },
          "tier": {
            "type": "string",
            "nullable": true,
            "example": "business"
          },
          "tier_label": {
            "type": "string",
            "description": "Имя тарифа так, как оно продано ЭТОМУ мерчанту. Обычно совпадает с именем из\nкаталога (`Business`), но у мерчанта с индивидуальными условиями это его\nдоговорное имя. Витринное поле: switch-логику стройте по `tier`.\n",
            "example": "Business"
          },
          "daily_limit": {
            "type": "integer",
            "nullable": true,
            "description": "Суточный лимит счетов, действующий у ЭТОГО мерчанта (у индивидуальных условий\nон отличается от каталожного). `null` зарезервирован под тариф без лимита,\nкоторого сейчас в каталоге нет.\n",
            "example": 300
          },
          "is_custom": {
            "type": "boolean",
            "description": "true — у организации индивидуальные условия (свой лимит и своя цена).\n⛔ `tier` при этом остаётся ОБЫЧНЫМ идентификатором (`pro`), значения `custom`\nне существует. Смена тарифа таким мерчантом отбивается `409 custom_tariff_locked`\n— переход оформляет поддержка.\n",
            "example": false
          },
          "is_trial": {
            "type": "boolean",
            "example": false
          },
          "started_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "example": "2026-01-01T00:00:00+05:00"
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "days_remaining": {
            "type": "integer",
            "example": 20
          },
          "auto_renew": {
            "type": "boolean",
            "example": false
          },
          "freeze": {
            "type": "object",
            "description": "Состояние паузы тарифа. Блок присутствует всегда; у обычной организации\n`is_frozen: false`.\n",
            "properties": {
              "is_frozen": {
                "type": "boolean",
                "example": false
              },
              "frozen_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true,
                "description": "Момент остановки часов либо null."
              },
              "paused_days": {
                "type": "integer",
                "description": "Сколько дней часы уже стоят.",
                "example": 0
              },
              "preserved_days": {
                "type": "integer",
                "description": "Сколько дней срока сохранено на момент остановки часов.",
                "example": 0
              },
              "max_freeze_days": {
                "type": "integer",
                "description": "Предел, дольше которого часы не стоят — дальше срок идёт снова.",
                "example": 90
              },
              "freezes_left": {
                "type": "integer",
                "description": "Сколько остановок ещё доступно в текущем оплаченном периоде.",
                "example": 2
              }
            }
          },
          "last_payment": {
            "type": "object",
            "nullable": true,
            "properties": {
              "amount": {
                "type": "integer",
                "example": 71250
              },
              "paid_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              },
              "period_months": {
                "type": "integer",
                "example": 3
              },
              "tier": {
                "type": "string",
                "example": "business"
              },
              "status": {
                "type": "string",
                "example": "paid"
              }
            }
          },
          "next_payment": {
            "type": "object",
            "properties": {
              "due_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true,
                "description": "null для expired."
              },
              "amount": {
                "type": "integer",
                "example": 71250
              },
              "tier": {
                "type": "string",
                "example": "business"
              }
            }
          }
        }
      },
      "AccountHealth": {
        "type": "object",
        "description": "Health аккаунта. Даты Asia/Almaty +05:00.",
        "properties": {
          "api": {
            "type": "object",
            "properties": {
              "status": {
                "type": "string",
                "example": "ok"
              }
            }
          },
          "connection": {
            "type": "object",
            "properties": {
              "kaspi_connected": {
                "type": "boolean",
                "example": true
              },
              "session_status": {
                "type": "string",
                "nullable": true,
                "enum": [
                  "active",
                  "expired",
                  "error"
                ],
                "description": "Здоровье Kaspi-сессии кассира — так детектится «слетела сессия» (поллинг, без вебхука).\n`active` — сессия жива (либо ещё ни разу не проверялась); `expired`/`error` — мертва,\nнужна переавторизация кассира: до неё создание счетов и чеков отдаёт\n`409 kaspi_session_expired`. `null` — активного кассира нет вовсе.\n",
                "example": "active"
              },
              "session_error_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true,
                "description": "Когда сессия была признана мёртвой (null, если жива)."
              },
              "needs_reauth": {
                "type": "boolean",
                "description": "true — сессию надо переподключить (эквивалент session_status ∈ {expired, error}).",
                "example": false
              },
              "last_used_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              }
            }
          },
          "tariff": {
            "type": "object",
            "properties": {
              "status": {
                "type": "string",
                "example": "active"
              },
              "expires_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              },
              "days_remaining": {
                "type": "integer",
                "example": 20
              }
            }
          },
          "invoicing": {
            "type": "object",
            "properties": {
              "accumulating": {
                "type": "boolean",
                "description": "true — счета копятся (кассир в hold), в Kaspi не уходят.",
                "example": false
              },
              "held_since": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              }
            }
          }
        }
      },
      "Connection": {
        "type": "object",
        "description": "Кассир организации. Поля `kaspi_user_id` и `cashier_phone` маскируются.",
        "properties": {
          "id": {
            "type": "integer",
            "example": 7
          },
          "label": {
            "type": "string",
            "nullable": true,
            "description": "Отображаемое имя кассира: ручное имя (`POST`/`PUT` `label`), если задано, иначе имя по умолчанию. Ручное имя сохраняется при переавторизации кассира.",
            "example": "Касса №1"
          },
          "kaspi_user_id": {
            "type": "string",
            "nullable": true,
            "description": "Маскированный идентификатор. `null` у отключённого кассира (после `auth/logout`).",
            "example": "770****4567"
          },
          "session_mode": {
            "type": "string",
            "description": "self / external.",
            "example": "self"
          },
          "status": {
            "type": "string",
            "example": "active"
          },
          "is_primary": {
            "type": "boolean",
            "example": true
          },
          "cashier_phone": {
            "type": "string",
            "nullable": true,
            "description": "Маскированный телефон кассира."
          },
          "last_used_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "example": "2026-01-10T12:00:00+00:00"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-01-01T09:00:00+00:00"
          }
        }
      },
      "CatalogItem": {
        "type": "object",
        "description": "Товар каталога. Даты UTC +00:00.",
        "properties": {
          "id": {
            "type": "integer",
            "example": 1
          },
          "kaspi_item_id": {
            "type": "string",
            "nullable": true
          },
          "name": {
            "type": "string",
            "example": "Coffee Latte"
          },
          "unit_id": {
            "type": "integer",
            "nullable": true,
            "example": 1
          },
          "selling_price": {
            "type": "number",
            "example": 1800
          },
          "date_added": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "image_url": {
            "type": "string",
            "nullable": true
          },
          "first_char": {
            "type": "string",
            "nullable": true
          },
          "nds_percentage": {
            "type": "number",
            "nullable": true
          },
          "barcode": {
            "type": "string",
            "nullable": true,
            "example": "4901234567890"
          },
          "ntin": {
            "type": "string",
            "nullable": true,
            "description": "НТИН Нацкаталога."
          },
          "gtin": {
            "type": "string",
            "nullable": true,
            "description": "GTIN (листинг Kaspi его не отдаёт, sync не затирает)."
          },
          "unified_goods_id": {
            "type": "string",
            "nullable": true,
            "description": "Нацкаталог master-good id."
          },
          "external_ref": {
            "type": "string",
            "nullable": true,
            "description": "Клиентская ссылка (1С)."
          },
          "ntin_missing": {
            "type": "boolean",
            "description": "true, если у товара есть barcode, но нет НТИН → не попадёт в фискальный чек как маркированный. Notify-only: дорезолвите НТИН через POST /catalog/scan + PATCH. Присутствует во всех ответах каталога."
          },
          "source": {
            "type": "string",
            "enum": [
              "own",
              "shared",
              "other"
            ],
            "example": "own",
            "description": "Кто завёл позицию, ГЛАЗАМИ спрашивающего: `own` — вы; `shared` — сам мерчант (кабинет) либо позиция приехала из каталога Kaspi; `other` — другая интеграция этого мерчанта. Имя соседней интеграции не отдаётся. `other` можно читать, но не изменять и не удалять (`409 catalog_item_foreign_channel`). Для мерчантских ключей и сессии кабинета значение всегда `own`."
          },
          "matched_existing": {
            "type": "boolean",
            "description": "Только в ответе POST /catalog: позиция сматчена с уже существующим товаром (match-and-merge) — вернулся его живой id, новая строка не создавалась. В остальных ответах — false. ⚠️ Сматчиться можно и с ЧУЖОЙ позицией (`source: other`) — тогда ни имя, ни цена не изменятся."
          },
          "name_differs": {
            "type": "boolean",
            "description": "Только в ответе POST /catalog при matched_existing=true по ярусу 3 (совпал barcode/НТИН, но наименование другое): имя существующего товара НЕ перезаписано. Правьте имя явным PATCH /catalog/{id}."
          },
          "outcome": {
            "type": "string",
            "nullable": true,
            "enum": [
              "created",
              "matched",
              "reissued",
              "unchanged",
              "revived",
              "not_started"
            ],
            "example": "created",
            "description": "Что сделано с позицией. Только в ответе POST /catalog, в остальных ответах каталога null. `created` — заведена новая строка; `matched` — сматчилась существующая, правка применена или поставлена в очередь; `reissued` — переиздана снятая позиция; `unchanged` — строка уже совпадает с каталогом Kaspi, работа не нужна; `revived` — брошенное создание открыто заново; `not_started` — брошенное создание найдено, но работа НЕ открыта (дословный повтор уже отбитого), чините PATCH /catalog/{id}. ⚠️ Читайте именно это поле: matched_existing говорит лишь «строка найдена» и не отличает открытую работу от её отсутствия.\n"
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "pending",
              "deleting",
              "deleted",
              "failed"
            ],
            "example": "active",
            "description": "Производный статус позиции (вычисляется из её состояния, не хранится): `active` — в каталоге, незакрытых create/delete нет; `pending` — создаётся, в каталоге Kaspi её сейчас нет; `deleting` — снимается; `deleted` — снята; `failed` — операция над позицией брошена после исчерпания попыток. Значения взаимоисключающие, приоритет веток фиксирован (failed → pending → deleting → deleted → active): открытая операция проверяется выше состояния позиции, поэтому снятая позиция с открытым повторным созданием читается как `pending`, а не как `deleted`. Вид самой операции — в `operation`."
          },
          "sellable": {
            "type": "boolean",
            "description": "Примут ли позицию в корзину счёта / QR / подписки. `false` у снятой позиции и у позиции с открытым намерением удаления — даже если попытки удаления прекращены; повторный `POST /catalog` или `PATCH` возвращает её в продажу. Позиция, создание которой в работе (`status=pending`), продаётся: счёт по ней уйдёт разовой продажей, пока Kaspi не подтвердит создание — см. `in_kaspi_catalog`. ⚠️ `false` и у позиции, создание которой БРОШЕНО (`status=failed`, `operation=create`): её создание в каталоге Kaspi не подтверждено и подтверждено уже не будет. Надёжно возвращает такую позицию в продажу только `PATCH /catalog/{id}`: повторный `POST /catalog` возобновляет брошенное создание не всегда — условия перечислены в описании `POST /catalog`."
          },
          "in_kaspi_catalog": {
            "type": "boolean",
            "description": "Есть ли у позиции боевая идентичность номенклатуры Kaspi. `false` — счёт создастся, но позиция уедет разовой продажей: маркировка Нацкаталога в фискальный чек по ней не проводится. ⚠️ Это НЕ то же самое, что `sellable`: позиция может продаваться без каталожной идентичности и наоборот. ⚠️ **В песочнице флаг всегда `false`** — песочные позиции носят синтетическую идентичность номенклатуры, которой в Kaspi не существует. Это свойство тестового контура, а не предсказание для боевой позиции: маркировку проверяют только на боевой оси организации, `false` в песочнице поломкой не является."
          },
          "operation": {
            "type": "string",
            "nullable": true,
            "enum": [
              "create",
              "update",
              "delete"
            ],
            "description": "Единственное не закрытое намерение над позицией. null — намерения нет; create/update/delete — заявленная операция. При отказе значение сохраняется вместе с error_code, error_message и failed_at. ⛔ Любая позиция с operation=delete не принимается в корзину, даже если попытки удаления прекращены; повторный POST/PATCH заменяет намерение и возвращает позицию в работу."
          },
          "error_message": {
            "type": "string",
            "nullable": true
          },
          "error_code": {
            "$ref": "#/components/schemas/ErrorCode"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "synced_at": {
            "type": "string",
            "format": "date-time",
            "description": "= updated_at."
          }
        }
      },
      "RejectedCatalogItem": {
        "type": "object",
        "description": "Позиция POST /catalog, не прошедшая per-item валидацию. Возвращается в массиве\n`rejected[]` ответа `202`, не роняя запрос. Валидные позиции того же запроса\nобрабатываются штатно (`data[]`).\n",
        "required": [
          "index",
          "error_code",
          "error_message"
        ],
        "properties": {
          "index": {
            "type": "integer",
            "description": "Позиция в исходном массиве items (0-based).",
            "example": 2
          },
          "error_code": {
            "$ref": "#/components/schemas/ErrorCode"
          },
          "error_message": {
            "type": "string",
            "description": "Человекочитаемое «что случилось → что сделать».",
            "example": "Позиция не прошла валидацию: The selling price must be at least 0.01. Исправьте указанные поля и отправьте позицию повторно."
          },
          "errors": {
            "type": "object",
            "nullable": true,
            "description": "Карта поле → сообщения (только для error_code=catalog_item_invalid). Для barcode_too_long отсутствует.",
            "additionalProperties": {
              "type": "array",
              "items": {
                "type": "string"
              }
            }
          },
          "name": {
            "type": "string",
            "nullable": true,
            "description": "Echo name из исходной позиции (если был)."
          },
          "barcode": {
            "type": "string",
            "nullable": true,
            "description": "Echo barcode из исходной позиции (если был)."
          },
          "external_ref": {
            "type": "string",
            "nullable": true,
            "description": "Echo external_ref из исходной позиции (если был)."
          }
        }
      },
      "CreateSubscriptionRequest": {
        "type": "object",
        "required": [
          "phone_number",
          "billing_period"
        ],
        "properties": {
          "phone_number": {
            "type": "string",
            "pattern": "^8\\d{10}$",
            "example": "87001234567"
          },
          "billing_period": {
            "type": "string",
            "enum": [
              "daily",
              "weekly",
              "biweekly",
              "monthly",
              "quarterly",
              "yearly"
            ]
          },
          "amount": {
            "type": "number",
            "minimum": 100,
            "maximum": 1000000,
            "description": "Обязателен, когда cart_items не передан. С корзиной игнорируется — сумму считает сервер."
          },
          "billing_day": {
            "type": "integer",
            "minimum": 1,
            "maximum": 28,
            "description": "День списания. У `monthly`, `quarterly`, `yearly` — число месяца (1–28). У `weekly` и `biweekly` — ДЕНЬ НЕДЕЛИ: 1 — понедельник … 7 — воскресенье, и значения больше 7 на этих периодах отклоняются (раньше они принимались и молча игнорировались). У `daily` не используется. Взаимоисключающе с `billing_day_from_end`."
          },
          "billing_day_from_end": {
            "type": "integer",
            "minimum": 0,
            "maximum": 1,
            "nullable": true,
            "description": "Опора от конца месяца: 0 — последний день, 1 — предпоследний. Доступна только для `monthly`, `quarterly`, `yearly`. Взаимоисключающе с `billing_day`."
          },
          "billing_time": {
            "type": "string",
            "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$",
            "nullable": true,
            "description": "Время списания по Алматы в формате ЧЧ:ММ. Допустимое окно — 06:00–22:00. По умолчанию 13:00."
          },
          "first_billing_at": {
            "type": "string",
            "format": "date",
            "description": "Дата первого списания по календарю Алматы. Несовместима с bill_immediately. Без неё первое списание — started_at плюс период."
          },
          "total_cycles": {
            "type": "integer",
            "minimum": 1,
            "maximum": 600,
            "nullable": true,
            "description": "Сколько ОПЛАЧЕННЫХ списаний сделать за всю жизнь подписки. Пусто — бессрочно. Неоплаченная попытка цикл не расходует."
          },
          "description": {
            "type": "string",
            "maxLength": 255
          },
          "subscriber_name": {
            "type": "string",
            "maxLength": 255
          },
          "external_subscriber_id": {
            "type": "string",
            "maxLength": 255
          },
          "started_at": {
            "type": "string",
            "format": "date",
            "description": "По умолчанию сегодня."
          },
          "max_retry_attempts": {
            "type": "integer",
            "minimum": 1,
            "maximum": 10
          },
          "retry_interval_hours": {
            "type": "integer",
            "minimum": 1,
            "maximum": 168
          },
          "grace_period_days": {
            "type": "integer",
            "minimum": 1,
            "maximum": 30
          },
          "metadata": {
            "type": "object"
          },
          "cart_items": {
            "type": "array",
            "minItems": 1,
            "maxItems": 100,
            "description": "Опционален. Доступен только организации с каталогом; без каталога даёт 422 catalog_not_supported.",
            "items": {
              "$ref": "#/components/schemas/CartItemSimple"
            }
          },
          "bill_immediately": {
            "type": "boolean",
            "default": false
          }
        }
      },
      "Subscription": {
        "type": "object",
        "description": "Подписка. Даты UTC +00:00.",
        "properties": {
          "id": {
            "type": "integer",
            "example": 10
          },
          "subscriber_name": {
            "type": "string",
            "nullable": true,
            "example": "Иван Иванов"
          },
          "phone_number": {
            "type": "string",
            "example": "87001234567"
          },
          "external_subscriber_id": {
            "type": "string",
            "nullable": true,
            "example": "CLIENT-001"
          },
          "amount": {
            "type": "string",
            "example": "5000.00"
          },
          "cart_items": {
            "type": "array",
            "nullable": true,
            "items": {
              "type": "object"
            }
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "billing_period": {
            "type": "string",
            "example": "monthly"
          },
          "billing_period_label": {
            "type": "string",
            "example": "Ежемесячно"
          },
          "billing_day": {
            "type": "integer",
            "nullable": true,
            "example": 1
          },
          "billing_day_from_end": {
            "type": "integer",
            "nullable": true,
            "description": "0 — последний день месяца, 1 — предпоследний."
          },
          "billing_day_label": {
            "type": "string",
            "nullable": true
          },
          "billing_time": {
            "type": "string",
            "nullable": true,
            "example": "13:00",
            "description": "Время списания по Алматы."
          },
          "total_cycles": {
            "type": "integer",
            "nullable": true,
            "description": "Сколько оплат запланировано всего. Пусто — бессрочно."
          },
          "cycles_paid": {
            "type": "integer",
            "example": 0,
            "description": "Сколько оплат уже получено."
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "paused",
              "cancelled",
              "expired"
            ],
            "example": "active"
          },
          "status_label": {
            "type": "string",
            "example": "Активна"
          },
          "status_color": {
            "type": "string",
            "example": "green"
          },
          "started_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "next_billing_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "next_billing_in_days": {
            "type": "integer",
            "nullable": true,
            "description": "Дней до списания по календарю Алматы. Отрицательное — просрочено."
          },
          "next_billing_label": {
            "type": "string",
            "nullable": true,
            "example": "через 3 дня"
          },
          "paused_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "cancelled_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "failed_attempts": {
            "type": "integer",
            "example": 0
          },
          "max_retry_attempts": {
            "type": "integer",
            "example": 3
          },
          "retry_interval_hours": {
            "type": "integer",
            "example": 24
          },
          "grace_period_days": {
            "type": "integer",
            "example": 3
          },
          "in_grace_period": {
            "type": "boolean",
            "example": false
          },
          "is_sandbox": {
            "type": "boolean",
            "example": false
          },
          "metadata": {
            "type": "object",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-01-10T12:00:00+00:00"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "SubscriptionActionResponse": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string",
            "example": "Subscription paused"
          },
          "subscription": {
            "$ref": "#/components/schemas/Subscription"
          }
        }
      },
      "SubscriptionInvoice": {
        "type": "object",
        "description": "Счёт подписки — один расчётный период списания. Даты UTC +00:00.",
        "properties": {
          "id": {
            "type": "integer",
            "example": 1
          },
          "invoice_id": {
            "type": "integer",
            "nullable": true,
            "example": 42
          },
          "billing_period_start": {
            "type": "string",
            "format": "date",
            "example": "2026-02-01"
          },
          "billing_period_end": {
            "type": "string",
            "format": "date",
            "example": "2026-02-28"
          },
          "billing_period_label": {
            "type": "string",
            "example": "01.02.2026 — 28.02.2026"
          },
          "amount": {
            "type": "string",
            "nullable": true,
            "example": "5000.00"
          },
          "attempt_number": {
            "type": "integer",
            "example": 1
          },
          "status": {
            "type": "string",
            "example": "paid"
          },
          "status_label": {
            "type": "string",
            "example": "Оплачен"
          },
          "status_color": {
            "type": "string",
            "example": "green"
          },
          "paid_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "failure_reason": {
            "type": "string",
            "nullable": true
          },
          "invoice": {
            "type": "object",
            "description": "Присутствует, если связанный счёт существует.",
            "properties": {
              "id": {
                "type": "integer",
                "example": 42
              },
              "kaspi_invoice_id": {
                "type": "string",
                "nullable": true
              },
              "status": {
                "type": "string",
                "example": "paid"
              }
            }
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "WebhookInvoiceObject": {
        "type": "object",
        "description": "Объект invoice в вебхуке invoice.status_changed. Условные поля — только если non-null.",
        "properties": {
          "id": {
            "type": "integer",
            "example": 123
          },
          "external_order_id": {
            "type": "string",
            "nullable": true
          },
          "amount": {
            "type": "string",
            "example": "1000.00"
          },
          "status": {
            "$ref": "#/components/schemas/InvoiceStatus"
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "kaspi_invoice_id": {
            "type": "string",
            "nullable": true
          },
          "client_name": {
            "type": "string",
            "nullable": true
          },
          "client_phone": {
            "type": "string",
            "nullable": true,
            "description": "null для QR-счетов без номера."
          },
          "is_sandbox": {
            "type": "boolean"
          },
          "internal_comment": {
            "type": "string",
            "description": "Внутренняя заметка мерчанта. Только если non-null."
          },
          "kaspi_source_type": {
            "type": "string",
            "enum": [
              "GOLD",
              "RED",
              "LOAN",
              "BUSINESSACCOUNT",
              "BANKINTEGRATIONACCOUNT"
            ],
            "description": "Только если non-null (обычно после оплаты)."
          },
          "kaspi_sale_type": {
            "type": "string",
            "enum": [
              "Remote",
              "QR",
              "Static",
              "Restaurant"
            ],
            "description": "Только если non-null."
          },
          "subtotal": {
            "type": "string",
            "description": "Только со скидкой/корзиной (вместе с discount_sum)."
          },
          "discount_sum": {
            "type": "string"
          },
          "discount_percentage": {
            "type": "integer",
            "description": "Только если non-null."
          },
          "paid_at": {
            "type": "string",
            "format": "date-time",
            "description": "Только при status=paid."
          },
          "cancelled_at": {
            "type": "string",
            "format": "date-time",
            "description": "Только при status=cancelled."
          },
          "expired_at": {
            "type": "string",
            "format": "date-time",
            "description": "Только при status=expired."
          },
          "errored_at": {
            "type": "string",
            "format": "date-time",
            "description": "Только при status=error."
          },
          "error_message": {
            "type": "string",
            "nullable": true,
            "description": "При status=error всегда (может быть null); при cancelled — только non-null."
          },
          "error_code": {
            "$ref": "#/components/schemas/ErrorCode"
          }
        }
      },
      "WebhookInvoiceStatusChanged": {
        "type": "object",
        "properties": {
          "event": {
            "type": "string",
            "example": "invoice.status_changed"
          },
          "invoice": {
            "$ref": "#/components/schemas/WebhookInvoiceObject"
          },
          "source": {
            "type": "string",
            "nullable": true,
            "description": "Имя API-ключа создателя."
          },
          "timestamp": {
            "type": "string",
            "format": "date-time",
            "example": "2026-06-11T10:30:00+00:00"
          }
        }
      },
      "WebhookInvoiceQrScanned": {
        "type": "object",
        "properties": {
          "event": {
            "type": "string",
            "example": "invoice.qr_scanned"
          },
          "invoice": {
            "type": "object",
            "properties": {
              "id": {
                "type": "integer",
                "example": 108565
              },
              "external_order_id": {
                "type": "string",
                "nullable": true
              },
              "amount": {
                "type": "string",
                "example": "1500.00"
              },
              "status": {
                "type": "string",
                "example": "pending"
              },
              "qr_substate": {
                "type": "string",
                "example": "scanned"
              },
              "description": {
                "type": "string",
                "nullable": true
              },
              "kaspi_invoice_id": {
                "type": "string",
                "nullable": true
              },
              "client_name": {
                "type": "string",
                "nullable": true
              },
              "client_phone": {
                "type": "string",
                "nullable": true
              },
              "is_sandbox": {
                "type": "boolean"
              },
              "internal_comment": {
                "type": "string",
                "description": "Внутренняя заметка мерчанта. Только если non-null."
              }
            }
          },
          "source": {
            "type": "string",
            "nullable": true
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "WebhookInvoiceRefunded": {
        "type": "object",
        "properties": {
          "event": {
            "type": "string",
            "example": "invoice.refunded"
          },
          "refund": {
            "type": "object",
            "properties": {
              "id": {
                "type": "integer",
                "example": 55
              },
              "amount": {
                "type": "string",
                "example": "500.00"
              },
              "status": {
                "type": "string",
                "enum": [
                  "completed",
                  "failed"
                ]
              },
              "kaspi_refund_id": {
                "type": "string",
                "nullable": true
              },
              "reason": {
                "type": "string",
                "nullable": true
              },
              "created_at": {
                "type": "string",
                "format": "date-time"
              },
              "items": {
                "type": "array",
                "description": "Только для позиционного возврата.",
                "items": {
                  "type": "object",
                  "properties": {
                    "catalog_item_id": {
                      "type": "integer"
                    },
                    "name": {
                      "type": "string"
                    },
                    "price": {
                      "type": "string"
                    },
                    "count": {
                      "type": "integer",
                      "description": "0 при возврате по сумме."
                    },
                    "amount": {
                      "type": "string"
                    }
                  }
                }
              },
              "error_code": {
                "$ref": "#/components/schemas/ErrorCode"
              }
            }
          },
          "invoice": {
            "type": "object",
            "properties": {
              "id": {
                "type": "integer",
                "example": 123
              },
              "external_order_id": {
                "type": "string",
                "nullable": true
              },
              "amount": {
                "type": "string",
                "example": "1000.00"
              },
              "total_refunded": {
                "type": "string",
                "example": "500.00"
              },
              "available_for_refund": {
                "type": "number",
                "description": "Число, не строка.",
                "example": 500
              },
              "is_fully_refunded": {
                "type": "boolean",
                "example": false
              },
              "status": {
                "type": "string",
                "example": "partially_refunded"
              },
              "kaspi_invoice_id": {
                "type": "string",
                "nullable": true
              },
              "is_sandbox": {
                "type": "boolean"
              }
            }
          },
          "source": {
            "type": "string",
            "nullable": true
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "WebhookSubscriptionEvent": {
        "type": "object",
        "description": "Базовый конверт subscription.* вебхуков. Для некоторых событий доп. поля мержатся\nВ КОРЕНЬ (не вложены): payment_succeeded → invoice_id/amount/paid_at;\npayment_failed → invoice_id/amount/reason/attempt_number; grace_period_started →\ngrace_period_days/expires_at.\n",
        "properties": {
          "event": {
            "type": "string",
            "example": "subscription.payment_failed"
          },
          "subscription": {
            "type": "object",
            "properties": {
              "id": {
                "type": "integer",
                "example": 10
              },
              "external_subscriber_id": {
                "type": "string",
                "nullable": true
              },
              "phone_number": {
                "type": "string"
              },
              "subscriber_name": {
                "type": "string",
                "nullable": true
              },
              "amount": {
                "type": "string",
                "example": "5000.00"
              },
              "billing_period": {
                "type": "string",
                "example": "monthly"
              },
              "status": {
                "type": "string",
                "example": "active"
              },
              "next_billing_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              },
              "failed_attempts": {
                "type": "integer",
                "example": 2
              },
              "in_grace_period": {
                "type": "boolean",
                "example": false
              },
              "is_sandbox": {
                "type": "boolean",
                "example": false
              }
            }
          },
          "source": {
            "type": "string",
            "nullable": true
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "WebhookTest": {
        "type": "object",
        "description": "Тест-вебхук (фиктивный invoice-конверт со status=test).",
        "properties": {
          "event": {
            "type": "string",
            "example": "webhook.test"
          },
          "invoice": {
            "type": "object",
            "properties": {
              "id": {
                "type": "integer",
                "nullable": true,
                "example": null
              },
              "status": {
                "type": "string",
                "example": "test"
              },
              "external_order_id": {
                "type": "string",
                "example": "TEST-ORDER-123"
              },
              "amount": {
                "type": "string",
                "example": "1000.00"
              },
              "is_sandbox": {
                "type": "boolean",
                "example": true
              },
              "kaspi_source_type": {
                "type": "string",
                "example": "GOLD"
              },
              "kaspi_sale_type": {
                "type": "string",
                "example": "Remote"
              }
            }
          },
          "source": {
            "type": "string",
            "nullable": true
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "CashboxDaySummary": {
        "type": "object",
        "description": "Сводка по наличным за день (`GET /cashbox/summary`). Все суммы — строки `\"N.NN\"` тенге.\n",
        "properties": {
          "date": {
            "type": "string",
            "example": "2026-08-10"
          },
          "current_cash_balance": {
            "type": "string",
            "nullable": true,
            "example": "12500.00"
          },
          "replenishment_sum": {
            "type": "string",
            "nullable": true,
            "example": "0.00"
          },
          "withdrawal_sum": {
            "type": "string",
            "nullable": true,
            "example": "0.00"
          },
          "sale_cash_amt": {
            "type": "string",
            "nullable": true,
            "example": "8900.00"
          },
          "sale_return_cash_amt": {
            "type": "string",
            "nullable": true,
            "example": "0.00"
          },
          "cash_amount_on_opening": {
            "type": "string",
            "nullable": true,
            "example": "3600.00"
          },
          "sale_cash_cnt": {
            "type": "integer",
            "nullable": true,
            "example": 12
          },
          "sale_return_cash_cnt": {
            "type": "integer",
            "nullable": true,
            "example": 0
          },
          "auto_withdrawal": {
            "type": "boolean",
            "nullable": true,
            "example": false
          },
          "available_cashbox_actions": {
            "type": "boolean",
            "nullable": true,
            "description": "false = Kaspi запретил кассовые операции на этой кассе (UI выключает тумблеры/закрытие).",
            "example": true
          }
        }
      },
      "CashboxShiftList": {
        "type": "object",
        "description": "Список смен за окно (`GET /cashbox/shifts`).",
        "properties": {
          "auto_close_shift": {
            "type": "boolean",
            "example": false
          },
          "shifts": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "integer",
                  "description": "kaspi_shift_id",
                  "example": 5012
                },
                "shift_number": {
                  "type": "integer",
                  "example": 106
                },
                "start_date": {
                  "type": "string",
                  "nullable": true,
                  "example": "2026-08-10"
                },
                "is_current": {
                  "type": "boolean",
                  "example": true
                },
                "total_income": {
                  "type": "string",
                  "nullable": true,
                  "description": "Нормализованная сумма `\"N.NN\"`.",
                  "example": "89000.00"
                },
                "total_income_raw": {
                  "type": "string",
                  "nullable": true,
                  "description": "Исходное форматирование Kaspi — только для показа.",
                  "example": "89 000 ₸"
                },
                "transactions_count": {
                  "type": "integer",
                  "nullable": true,
                  "example": 12
                }
              }
            }
          }
        }
      },
      "CashboxReconciliation": {
        "type": "object",
        "description": "Сверка наших счетов с кассой Kaspi по одной смене (`GET /cashbox/reconciliation`).\n⛔ Показывает обе цифры и причины расхождения, но НЕ доказывает равенство и не\nсчитает разницу: итог смены — единая сумма, продажи наличными и продажи мимо ApiPay\nв ней не выделены, поэтому вычитать одну цифру из другой было бы неверно.\n",
        "properties": {
          "mode": {
            "type": "string",
            "enum": [
              "shift"
            ]
          },
          "is_sandbox": {
            "type": "boolean",
            "example": false
          },
          "window": {
            "type": "object",
            "properties": {
              "from": {
                "type": "string",
                "format": "date-time"
              },
              "to": {
                "type": "string",
                "format": "date-time"
              },
              "timezone": {
                "type": "string",
                "example": "Asia/Almaty"
              },
              "source": {
                "type": "string",
                "enum": [
                  "shift"
                ]
              }
            }
          },
          "ours": {
            "$ref": "#/components/schemas/CashboxInvoiceTotals"
          },
          "kaspi": {
            "type": "object",
            "description": "Итог смены по данным кассы Kaspi — единая сумма, продажи наличными и продажи мимо ApiPay в ней не выделены.",
            "properties": {
              "available": {
                "type": "boolean",
                "example": true
              },
              "source": {
                "type": "string",
                "enum": [
                  "shift"
                ]
              },
              "shift_id": {
                "type": "integer"
              },
              "shift_number": {
                "type": "integer"
              },
              "total_income": {
                "type": "string",
                "nullable": true,
                "description": "Итог смены."
              },
              "total_income_raw": {
                "type": "string",
                "nullable": true
              },
              "transactions_count": {
                "type": "integer",
                "nullable": true
              },
              "is_current": {
                "type": "boolean",
                "nullable": true
              },
              "non_cash_amount": {
                "type": "string",
                "nullable": true,
                "description": "Всегда null: итог смены не разделён на наличную и безналичную части.",
                "example": null
              },
              "snapshot": {
                "type": "object",
                "properties": {
                  "synced_at": {
                    "type": "string",
                    "format": "date-time",
                    "nullable": true
                  },
                  "stale": {
                    "type": "boolean",
                    "description": "Данные кассы получены больше 15 минут назад — обновите список смен."
                  }
                }
              }
            }
          },
          "discrepancies": {
            "type": "array",
            "description": "Структурные причины, по которым цифры нельзя приравнять (не дефекты).",
            "items": {
              "type": "object",
              "properties": {
                "code": {
                  "type": "string",
                  "enum": [
                    "shift_not_calendar_day",
                    "kaspi_income_includes_offline_sales",
                    "open_shift_moving_target",
                    "paid_at_timezone_boundary",
                    "invoices_without_connection",
                    "kaspi_snapshot_stale"
                  ]
                },
                "source": {
                  "type": "string",
                  "enum": [
                    "kaspi",
                    "ours",
                    "window"
                  ]
                },
                "message": {
                  "type": "string"
                },
                "amount": {
                  "type": "string",
                  "nullable": true,
                  "description": "Всегда null — причина неквантифицируема из одной суммы Kaspi."
                },
                "count": {
                  "type": "integer",
                  "description": "Только у invoices_without_connection."
                }
              }
            }
          }
        }
      },
      "CashboxInvoiceTotals": {
        "type": "object",
        "description": "НАША половина сверки — итоги по счетам за окно. Суммы — строки `\"N.NN\"`.\nОкно режется по `paid_at`, поэтому неоплаченных групп (отменённые, просроченные,\nв ожидании) здесь нет — у таких счетов даты оплаты не существует.\n",
        "properties": {
          "count": {
            "type": "integer"
          },
          "sales": {
            "type": "object",
            "description": "Принятые деньги: paid + partially_refunded.",
            "properties": {
              "count": {
                "type": "integer"
              },
              "amount": {
                "type": "string",
                "example": "89000.00"
              },
              "refunded_later": {
                "type": "string",
                "description": "Сколько из этих счетов вернули когда-либо (объясняет разницу, не прячет)."
              }
            }
          },
          "refunds": {
            "type": "object",
            "description": "Возвраты, СОВЕРШЁННЫЕ в этом окне (по времени операции). Без connection-фильтра — по всей организации.",
            "properties": {
              "count": {
                "type": "integer"
              },
              "amount": {
                "type": "string"
              }
            }
          },
          "net_amount": {
            "type": "string",
            "description": "sales − refunds — «итого» из терминала.",
            "example": "89000.00"
          },
          "coverage": {
            "type": "object",
            "properties": {
              "invoices_without_connection": {
                "type": "integer",
                "description": "Строк окна с пустым kaspi_connection_id (выпадают из sales при фильтре по кассе)."
              }
            }
          },
          "period": {
            "type": "object",
            "properties": {
              "from": {
                "type": "string",
                "nullable": true
              },
              "to": {
                "type": "string",
                "nullable": true
              },
              "field": {
                "type": "string",
                "example": "paid_at"
              }
            }
          }
        }
      },
      "CashboxOperation": {
        "type": "object",
        "description": "Статус кассовой операции (`GET /cashbox/operations/{id}`).",
        "properties": {
          "id": {
            "type": "integer"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "sending",
              "completed",
              "failed"
            ]
          },
          "operation_type": {
            "type": "string",
            "example": "close_shift"
          },
          "shift_number": {
            "type": "integer",
            "nullable": true
          },
          "error_code": {
            "type": "string",
            "nullable": true,
            "description": "Заполнен при failed (слаг cashbox_*)."
          },
          "error_message": {
            "type": "string",
            "nullable": true
          },
          "resolution": {
            "type": "object",
            "properties": {
              "safe_to_retry": {
                "type": "boolean",
                "description": "true только при status=failed — повторяйте НОВЫМ client_operation_id."
              }
            }
          }
        }
      },
      "CashboxToggleRequest": {
        "type": "object",
        "required": [
          "enabled"
        ],
        "properties": {
          "enabled": {
            "type": "boolean"
          },
          "kaspi_connection_id": {
            "type": "integer",
            "nullable": true
          }
        }
      },
      "CashboxToggleResult": {
        "type": "object",
        "properties": {
          "changed": {
            "type": "boolean",
            "description": "false = живое значение в Kaspi уже равнялось запрошенному (no-op)."
          },
          "new_value": {
            "type": "boolean"
          }
        }
      },
      "WebhookCashboxEvent": {
        "type": "object",
        "description": "Payload вебхуков cashbox.shift_closed / cashbox.shift_close_failed. Таймстамп UTC +00:00.\nДедуп по `(event, operation.id)`. При failed — причина в `operation.error_code`.\n",
        "properties": {
          "event": {
            "type": "string",
            "enum": [
              "cashbox.shift_closed",
              "cashbox.shift_close_failed"
            ],
            "example": "cashbox.shift_closed"
          },
          "operation": {
            "type": "object",
            "properties": {
              "id": {
                "type": "integer",
                "example": 812
              },
              "operation_type": {
                "type": "string",
                "example": "close_shift"
              },
              "status": {
                "type": "string",
                "enum": [
                  "completed",
                  "failed"
                ],
                "example": "completed"
              },
              "shift_number": {
                "type": "integer",
                "nullable": true,
                "example": 106
              },
              "error_code": {
                "type": "string",
                "nullable": true,
                "description": "Только при close_failed (слаг cashbox_*).",
                "example": null
              }
            }
          },
          "timestamp": {
            "type": "string",
            "format": "date-time",
            "example": "2026-08-10T16:25:43+00:00"
          }
        }
      }
    }
  },
  "x-webhooks": {
    "invoice.status_changed": {
      "example": {
        "event": "invoice.status_changed",
        "invoice": {
          "id": 42,
          "external_order_id": "order_123",
          "amount": "15000.00",
          "subtotal": "16500.00",
          "discount_sum": "1500.00",
          "discount_percentage": "10",
          "status": "paid",
          "description": "Оплата заказа",
          "kaspi_invoice_id": "13234689513",
          "client_name": "Иван Иванов",
          "client_phone": "87071234567",
          "is_sandbox": false,
          "kaspi_source_type": "GOLD",
          "kaspi_sale_type": "Remote",
          "paid_at": "2026-02-12T14:35:00+00:00"
        },
        "source": "My API Key",
        "timestamp": "2026-02-12T14:35:01+00:00"
      },
      "description": "Статус счёта изменился (например, счёт оплачен или истёк).",
      "fields": [
        {
          "name": "event",
          "type": "string",
          "nullable": false,
          "description": "Тип события — invoice.status_changed."
        },
        {
          "name": "invoice.id",
          "type": "integer",
          "nullable": false,
          "description": "Внутренний ID счёта в ApiPay."
        },
        {
          "name": "invoice.external_order_id",
          "type": "string",
          "nullable": true,
          "description": "Ваш внешний идентификатор заказа, переданный при создании счёта."
        },
        {
          "name": "invoice.amount",
          "type": "string",
          "nullable": false,
          "description": "Сумма счёта."
        },
        {
          "name": "invoice.subtotal",
          "type": "string",
          "nullable": true,
          "description": "Сумма до применения скидки. Только для счетов с корзиной/скидкой (subtotal и discount_sum приходят вместе)."
        },
        {
          "name": "invoice.discount_sum",
          "type": "string",
          "nullable": true,
          "description": "Сумма скидки. Только для счетов с корзиной/скидкой (subtotal и discount_sum приходят вместе)."
        },
        {
          "name": "invoice.discount_percentage",
          "type": "string",
          "nullable": true,
          "description": "Процент скидки. Только для счетов с корзиной/скидкой."
        },
        {
          "name": "invoice.status",
          "type": "string",
          "nullable": false,
          "description": "Статус счёта: pending / paid / cancelled / expired / error / partially_refunded. Статуса refunded не существует — полный возврат оставляет paid + is_fully_refunded=true."
        },
        {
          "name": "invoice.kaspi_invoice_id",
          "type": "string",
          "nullable": true,
          "description": "ID счёта в Kaspi. Появляется уже при pending (когда счёт создан в Kaspi); null — пока счёт не дошёл до Kaspi."
        },
        {
          "name": "invoice.client_phone",
          "type": "string",
          "nullable": false,
          "description": "Номер телефона клиента."
        },
        {
          "name": "invoice.kaspi_source_type",
          "type": "string",
          "nullable": true,
          "description": "Источник средств клиента: GOLD — дебетовая карта Kaspi, RED — кредитная карта Kaspi, LOAN — рассрочка/кредит, BUSINESSACCOUNT — бизнес-счёт, BANKINTEGRATIONACCOUNT — привязанный внешний банковский счёт. Обычно присутствует при status=paid, но гейтится по наличию значения, а не строго по статусу: поле приходит, когда Kaspi вернул значение (например, счёт, уже получивший его, может пробросить поле и в статусах cancelled/expired), и отсутствует/null иначе. Может отсутствовать для старых счетов; список может расширяться — обрабатывайте неизвестные значения как «прочее»."
        },
        {
          "name": "invoice.kaspi_sale_type",
          "type": "string",
          "nullable": true,
          "description": "Способ приёма счёта: Remote — push на номер, QR — QR-код, Restaurant — ресторанный счёт, Static — статичный QR. Обычно присутствует при status=paid, но гейтится по наличию значения, а не строго по статусу: поле приходит, когда Kaspi вернул значение (например, счёт, уже получивший его, может пробросить поле и в статусах cancelled/expired), и отсутствует/null иначе. Может отсутствовать для старых счетов; список может расширяться — обрабатывайте неизвестные значения как «прочее»."
        },
        {
          "name": "invoice.paid_at",
          "type": "string",
          "nullable": true,
          "description": "Время оплаты счёта (ISO 8601). Поле отсутствует во всех статусах, кроме paid (а не null до оплаты)."
        },
        {
          "name": "invoice.error_message",
          "type": "string",
          "nullable": true,
          "description": "Человекочитаемая причина. При status=error присутствует всегда; при status=cancelled — только если заполнена (обычно отсутствует при отмене клиентом или через API). В статусах paid/pending/expired поле отсутствует."
        },
        {
          "name": "invoice.error_code",
          "type": "string",
          "nullable": true,
          "description": "Стабильный snake_case-код из каталога (раздел \"Коды ошибок\"). Присутствует только если не null и только при status=error/cancelled. Стройте switch-логику по нему, а не по тексту."
        },
        {
          "name": "invoice.cancelled_at",
          "type": "string",
          "nullable": true,
          "description": "Время перехода в cancelled (ISO 8601). Присутствует только при соответствующем статусе."
        },
        {
          "name": "invoice.expired_at",
          "type": "string",
          "nullable": true,
          "description": "Время перехода в expired (ISO 8601). Присутствует только при соответствующем статусе."
        },
        {
          "name": "invoice.errored_at",
          "type": "string",
          "nullable": true,
          "description": "Время перехода в error (ISO 8601). Присутствует только при соответствующем статусе."
        },
        {
          "name": "source",
          "type": "string",
          "nullable": true,
          "description": "Название API-ключа, через который создан счёт."
        },
        {
          "name": "timestamp",
          "type": "string",
          "nullable": false,
          "description": "Время отправки события (ISO 8601)."
        }
      ]
    },
    "invoice.qr_scanned": {
      "example": {
        "event": "invoice.qr_scanned",
        "invoice": {
          "id": 108565,
          "external_order_id": "order-123",
          "amount": "1500.00",
          "status": "pending",
          "qr_substate": "scanned",
          "description": "Оплата заказа",
          "kaspi_invoice_id": "15977100656",
          "client_name": null,
          "client_phone": null,
          "is_sandbox": false
        },
        "source": "My API Key",
        "timestamp": "2026-06-14T14:37:00+00:00"
      },
      "description": "Клиент отсканировал QR-счёт и находится на экране оплаты Kaspi. Это НЕ смена статуса — суб-состояние счёта (status остаётся pending). Событие аддитивное и шлётся ровно один раз на QR-счёт; оно транзиентно — после него штатно приходит paid (клиент оплатил) или cancelled (клиент свернул/закрыл приложение). Отличайте событие по event=invoice.qr_scanned и/или маркеру qr_substate=scanned; не считайте скан гарантией оплаты.",
      "fields": [
        {
          "name": "event",
          "type": "string",
          "nullable": false,
          "description": "Тип события — invoice.qr_scanned."
        },
        {
          "name": "invoice.id",
          "type": "integer",
          "nullable": false,
          "description": "Внутренний ID счёта в ApiPay."
        },
        {
          "name": "invoice.external_order_id",
          "type": "string",
          "nullable": true,
          "description": "Ваш внешний идентификатор заказа, переданный при создании счёта."
        },
        {
          "name": "invoice.amount",
          "type": "string",
          "nullable": false,
          "description": "Сумма счёта в тенге."
        },
        {
          "name": "invoice.status",
          "type": "string",
          "nullable": false,
          "description": "Всегда pending — qr_scanned не меняет статус, а сообщает о суб-состоянии «на экране оплаты»."
        },
        {
          "name": "invoice.qr_substate",
          "type": "string",
          "nullable": false,
          "description": "Маркер суб-состояния QR — scanned (клиент отсканировал QR и на экране оплаты)."
        },
        {
          "name": "invoice.description",
          "type": "string",
          "nullable": true,
          "description": "Описание счёта."
        },
        {
          "name": "invoice.kaspi_invoice_id",
          "type": "string",
          "nullable": true,
          "description": "ID счёта в Kaspi."
        },
        {
          "name": "invoice.client_name",
          "type": "string",
          "nullable": true,
          "description": "Имя клиента (для QR-счёта обычно null)."
        },
        {
          "name": "invoice.client_phone",
          "type": "string",
          "nullable": true,
          "description": "Телефон клиента (для QR-счёта обычно null)."
        },
        {
          "name": "invoice.is_sandbox",
          "type": "boolean",
          "nullable": false,
          "description": "Признак sandbox-счёта."
        },
        {
          "name": "source",
          "type": "string",
          "nullable": true,
          "description": "Название API-ключа, через который создан счёт."
        },
        {
          "name": "timestamp",
          "type": "string",
          "nullable": false,
          "description": "Время отправки события (ISO 8601, UTC)."
        }
      ]
    },
    "invoice.refunded": {
      "example": {
        "event": "invoice.refunded",
        "refund": {
          "id": 5,
          "amount": "2000.00",
          "status": "completed",
          "kaspi_refund_id": "1126827352",
          "reason": "Возврат товара",
          "created_at": "2026-02-12T10:00:00+00:00",
          "items": [
            {
              "catalog_item_id": 12,
              "name": "Кофе",
              "price": "1000.00",
              "count": 2,
              "amount": "2000.00"
            }
          ]
        },
        "invoice": {
          "id": 42,
          "external_order_id": "order_123",
          "amount": "5000.00",
          "subtotal": "5500.00",
          "discount_sum": "500.00",
          "total_refunded": "2000.00",
          "available_for_refund": 3000,
          "is_fully_refunded": false,
          "is_sandbox": false,
          "status": "paid",
          "kaspi_invoice_id": "13234689513"
        },
        "source": "My API Key",
        "timestamp": "2026-02-12T10:00:01+00:00"
      },
      "description": "По счёту выполнен возврат (полный или частичный).",
      "fields": [
        {
          "name": "event",
          "type": "string",
          "nullable": false,
          "description": "Тип события — invoice.refunded."
        },
        {
          "name": "refund.id",
          "type": "integer",
          "nullable": false,
          "description": "ID возврата."
        },
        {
          "name": "refund.amount",
          "type": "string",
          "nullable": false,
          "description": "Сумма возврата."
        },
        {
          "name": "refund.status",
          "type": "string",
          "nullable": false,
          "description": "pending / processing / completed / failed. Вебхук приходит на completed И на failed."
        },
        {
          "name": "refund.kaspi_refund_id",
          "type": "string",
          "nullable": true,
          "description": "ID возврата в Kaspi; null при неудаче."
        },
        {
          "name": "refund.reason",
          "type": "string",
          "nullable": true,
          "description": "Причина возврата."
        },
        {
          "name": "refund.created_at",
          "type": "string",
          "nullable": false,
          "description": "Время создания возврата (ISO 8601)."
        },
        {
          "name": "refund.error_code",
          "type": "string",
          "nullable": true,
          "description": "Только при status=failed. Например refund_window_expired — истёк срок возврата (~14 дней). Поля error_message в вебхуке нет by design — текст смотрите в GET /invoices/{id}/refunds или резолвите код по каталогу."
        },
        {
          "name": "refund.items",
          "type": "array",
          "nullable": true,
          "description": "Позиции возврата (только для позиционных возвратов): catalog_item_id, name, price, count, amount."
        },
        {
          "name": "invoice.id",
          "type": "integer",
          "nullable": false,
          "description": "Внутренний ID счёта в ApiPay."
        },
        {
          "name": "invoice.external_order_id",
          "type": "string",
          "nullable": true,
          "description": "Ваш внешний идентификатор заказа."
        },
        {
          "name": "invoice.amount",
          "type": "string",
          "nullable": false,
          "description": "Сумма счёта."
        },
        {
          "name": "invoice.subtotal",
          "type": "string",
          "nullable": false,
          "description": "Сумма счёта до применения скидки."
        },
        {
          "name": "invoice.discount_sum",
          "type": "string",
          "nullable": false,
          "description": "Сумма скидки по счёту."
        },
        {
          "name": "invoice.total_refunded",
          "type": "string",
          "nullable": false,
          "description": "Суммарно возвращено по счёту на текущий момент."
        },
        {
          "name": "invoice.available_for_refund",
          "type": "number",
          "nullable": false,
          "description": "Сумма, ещё доступная для возврата. Приходит числом (float), в отличие от amount и total_refunded, которые передаются строками."
        },
        {
          "name": "invoice.is_fully_refunded",
          "type": "boolean",
          "nullable": false,
          "description": "true, если счёт возвращён полностью."
        },
        {
          "name": "invoice.is_sandbox",
          "type": "boolean",
          "nullable": false,
          "description": "Счёт создан в sandbox-режиме."
        },
        {
          "name": "invoice.status",
          "type": "string",
          "nullable": false,
          "description": "Статус счёта после возврата. Полный возврат статус НЕ меняет (остаётся paid — или partially_refunded, если ранее был частичный) + is_fully_refunded=true; первый частичный переводит в partially_refunded (и дополнительно приходит invoice.status_changed)."
        },
        {
          "name": "invoice.kaspi_invoice_id",
          "type": "string",
          "nullable": true,
          "description": "ID счёта в Kaspi."
        },
        {
          "name": "source",
          "type": "string",
          "nullable": true,
          "description": "Название API-ключа."
        },
        {
          "name": "timestamp",
          "type": "string",
          "nullable": false,
          "description": "Время отправки события (ISO 8601)."
        }
      ]
    },
    "subscription.payment_succeeded": {
      "example": {
        "event": "subscription.payment_succeeded",
        "subscription": {
          "id": 10,
          "external_subscriber_id": "CLIENT-001",
          "phone_number": "87071234567",
          "subscriber_name": "Иван Иванов",
          "amount": "5000.00",
          "billing_period": "monthly",
          "status": "active",
          "next_billing_at": "2026-03-01T00:00:00+00:00",
          "failed_attempts": 0,
          "in_grace_period": false,
          "is_sandbox": false
        },
        "invoice_id": 200,
        "amount": "5000.00",
        "paid_at": "2026-02-01T12:00:00+00:00",
        "source": "My API Key",
        "timestamp": "2026-02-01T12:00:01+00:00"
      },
      "description": "Очередной платёж по подписке прошёл успешно.",
      "fields": [
        {
          "name": "event",
          "type": "string",
          "nullable": false,
          "description": "Тип события — subscription.payment_succeeded."
        },
        {
          "name": "subscription.id",
          "type": "integer",
          "nullable": false,
          "description": "ID подписки."
        },
        {
          "name": "subscription.external_subscriber_id",
          "type": "string",
          "nullable": true,
          "description": "Ваш внешний идентификатор подписчика."
        },
        {
          "name": "subscription.phone_number",
          "type": "string",
          "nullable": false,
          "description": "Номер телефона подписчика."
        },
        {
          "name": "subscription.subscriber_name",
          "type": "string",
          "nullable": true,
          "description": "Имя подписчика."
        },
        {
          "name": "subscription.amount",
          "type": "string",
          "nullable": false,
          "description": "Сумма платежа по подписке."
        },
        {
          "name": "subscription.billing_period",
          "type": "string",
          "nullable": false,
          "description": "Период списания (например, monthly)."
        },
        {
          "name": "subscription.status",
          "type": "string",
          "nullable": false,
          "description": "Статус подписки (например, active)."
        },
        {
          "name": "subscription.next_billing_at",
          "type": "string",
          "nullable": true,
          "description": "Дата следующего списания (ISO 8601)."
        },
        {
          "name": "subscription.failed_attempts",
          "type": "integer",
          "nullable": false,
          "description": "Количество подряд неуспешных попыток списания."
        },
        {
          "name": "subscription.in_grace_period",
          "type": "boolean",
          "nullable": false,
          "description": "Находится ли подписка в льготном периоде."
        },
        {
          "name": "subscription.is_sandbox",
          "type": "boolean",
          "nullable": false,
          "description": "Подписка создана в sandbox-режиме."
        },
        {
          "name": "invoice_id",
          "type": "integer",
          "nullable": false,
          "description": "ID счёта, по которому прошёл платёж."
        },
        {
          "name": "amount",
          "type": "string",
          "nullable": false,
          "description": "Сумма успешного платежа."
        },
        {
          "name": "paid_at",
          "type": "string",
          "nullable": false,
          "description": "Время оплаты (ISO 8601)."
        },
        {
          "name": "source",
          "type": "string",
          "nullable": true,
          "description": "Название API-ключа."
        },
        {
          "name": "timestamp",
          "type": "string",
          "nullable": false,
          "description": "Время отправки события (ISO 8601)."
        }
      ]
    },
    "subscription.payment_failed": {
      "example": {
        "event": "subscription.payment_failed",
        "subscription": {
          "id": 10,
          "phone_number": "87071234567",
          "amount": "5000.00",
          "billing_period": "monthly",
          "status": "active",
          "failed_attempts": 2,
          "in_grace_period": false,
          "is_sandbox": false
        },
        "invoice_id": 201,
        "amount": "5000.00",
        "reason": "Invoice expired",
        "attempt_number": 2,
        "source": "My API Key",
        "timestamp": "2026-02-02T12:00:01+00:00"
      },
      "description": "Очередной платёж по подписке не прошёл.",
      "fields": [
        {
          "name": "event",
          "type": "string",
          "nullable": false,
          "description": "Тип события — subscription.payment_failed."
        },
        {
          "name": "subscription.id",
          "type": "integer",
          "nullable": false,
          "description": "ID подписки."
        },
        {
          "name": "subscription.phone_number",
          "type": "string",
          "nullable": false,
          "description": "Номер телефона подписчика."
        },
        {
          "name": "subscription.amount",
          "type": "string",
          "nullable": false,
          "description": "Сумма платежа по подписке."
        },
        {
          "name": "subscription.billing_period",
          "type": "string",
          "nullable": false,
          "description": "Период списания (например, monthly)."
        },
        {
          "name": "subscription.status",
          "type": "string",
          "nullable": false,
          "description": "Статус подписки (например, active)."
        },
        {
          "name": "subscription.failed_attempts",
          "type": "integer",
          "nullable": false,
          "description": "Количество подряд неуспешных попыток списания."
        },
        {
          "name": "subscription.in_grace_period",
          "type": "boolean",
          "nullable": false,
          "description": "Находится ли подписка в льготном периоде."
        },
        {
          "name": "subscription.is_sandbox",
          "type": "boolean",
          "nullable": false,
          "description": "Подписка создана в sandbox-режиме."
        },
        {
          "name": "invoice_id",
          "type": "integer",
          "nullable": false,
          "description": "ID счёта, по которому не прошёл платёж."
        },
        {
          "name": "amount",
          "type": "string",
          "nullable": false,
          "description": "Сумма неуспешного платежа."
        },
        {
          "name": "reason",
          "type": "string",
          "nullable": true,
          "description": "Причина неуспеха: принимает \"Invoice expired\" или \"Invoice cancelled\"."
        },
        {
          "name": "attempt_number",
          "type": "integer",
          "nullable": false,
          "description": "Номер текущей попытки списания."
        },
        {
          "name": "source",
          "type": "string",
          "nullable": true,
          "description": "Название API-ключа."
        },
        {
          "name": "timestamp",
          "type": "string",
          "nullable": false,
          "description": "Время отправки события (ISO 8601)."
        }
      ]
    },
    "subscription.grace_period_started": {
      "example": {
        "event": "subscription.grace_period_started",
        "subscription": {
          "id": 10,
          "phone_number": "87071234567",
          "amount": "5000.00",
          "status": "active",
          "failed_attempts": 3,
          "in_grace_period": true,
          "is_sandbox": false
        },
        "grace_period_days": 3,
        "expires_at": "2026-02-05T12:00:00+00:00",
        "source": "My API Key",
        "timestamp": "2026-02-02T12:00:01+00:00"
      },
      "description": "Подписка вошла в льготный период после нескольких неуспешных списаний.",
      "fields": [
        {
          "name": "event",
          "type": "string",
          "nullable": false,
          "description": "Тип события — subscription.grace_period_started."
        },
        {
          "name": "subscription.id",
          "type": "integer",
          "nullable": false,
          "description": "ID подписки."
        },
        {
          "name": "subscription.phone_number",
          "type": "string",
          "nullable": false,
          "description": "Номер телефона подписчика."
        },
        {
          "name": "subscription.amount",
          "type": "string",
          "nullable": false,
          "description": "Сумма платежа по подписке."
        },
        {
          "name": "subscription.status",
          "type": "string",
          "nullable": false,
          "description": "Статус подписки (например, active)."
        },
        {
          "name": "subscription.failed_attempts",
          "type": "integer",
          "nullable": false,
          "description": "Количество подряд неуспешных попыток списания."
        },
        {
          "name": "subscription.in_grace_period",
          "type": "boolean",
          "nullable": false,
          "description": "Находится ли подписка в льготном периоде (здесь true)."
        },
        {
          "name": "subscription.is_sandbox",
          "type": "boolean",
          "nullable": false,
          "description": "Подписка создана в sandbox-режиме."
        },
        {
          "name": "grace_period_days",
          "type": "integer",
          "nullable": false,
          "description": "Длительность льготного периода в днях."
        },
        {
          "name": "expires_at",
          "type": "string",
          "nullable": false,
          "description": "Когда истекает льготный период (ISO 8601)."
        },
        {
          "name": "source",
          "type": "string",
          "nullable": true,
          "description": "Название API-ключа."
        },
        {
          "name": "timestamp",
          "type": "string",
          "nullable": false,
          "description": "Время отправки события (ISO 8601)."
        }
      ]
    },
    "subscription.expired": {
      "example": {
        "event": "subscription.expired",
        "subscription": {
          "id": 10,
          "phone_number": "87071234567",
          "amount": "5000.00",
          "status": "expired",
          "next_billing_at": null,
          "failed_attempts": 3,
          "in_grace_period": false,
          "is_sandbox": false
        },
        "source": "My API Key",
        "timestamp": "2026-02-05T12:00:01+00:00"
      },
      "description": "Подписка истекла после исчерпания попыток списания и льготного периода.",
      "fields": [
        {
          "name": "event",
          "type": "string",
          "nullable": false,
          "description": "Тип события — subscription.expired."
        },
        {
          "name": "subscription.id",
          "type": "integer",
          "nullable": false,
          "description": "ID подписки."
        },
        {
          "name": "subscription.phone_number",
          "type": "string",
          "nullable": false,
          "description": "Номер телефона подписчика."
        },
        {
          "name": "subscription.amount",
          "type": "string",
          "nullable": false,
          "description": "Сумма платежа по подписке."
        },
        {
          "name": "subscription.status",
          "type": "string",
          "nullable": false,
          "description": "Статус подписки (здесь expired)."
        },
        {
          "name": "subscription.next_billing_at",
          "type": "string",
          "nullable": true,
          "description": "Дата следующего списания (null для истёкшей подписки)."
        },
        {
          "name": "subscription.failed_attempts",
          "type": "integer",
          "nullable": false,
          "description": "Количество подряд неуспешных попыток списания."
        },
        {
          "name": "subscription.in_grace_period",
          "type": "boolean",
          "nullable": false,
          "description": "Находится ли подписка в льготном периоде."
        },
        {
          "name": "subscription.is_sandbox",
          "type": "boolean",
          "nullable": false,
          "description": "Подписка создана в sandbox-режиме."
        },
        {
          "name": "source",
          "type": "string",
          "nullable": true,
          "description": "Название API-ключа."
        },
        {
          "name": "timestamp",
          "type": "string",
          "nullable": false,
          "description": "Время отправки события (ISO 8601)."
        }
      ]
    },
    "subscription.created": {
      "example": {
        "event": "subscription.created",
        "subscription": {
          "id": 10,
          "external_subscriber_id": "CLIENT-001",
          "phone_number": "87071234567",
          "subscriber_name": "Иван Иванов",
          "amount": "5000.00",
          "billing_period": "monthly",
          "status": "active",
          "next_billing_at": "2026-03-01T00:00:00+00:00",
          "failed_attempts": 0,
          "in_grace_period": false,
          "is_sandbox": false
        },
        "source": "My API Key",
        "timestamp": "2026-02-01T12:00:01+00:00"
      },
      "description": "Подписка создана. Первый счёт будет выставлен в next_billing_at (или сразу, если при создании передан bill_immediately).",
      "fields": [
        {
          "name": "event",
          "type": "string",
          "nullable": false,
          "description": "Тип события — subscription.created."
        },
        {
          "name": "subscription.id",
          "type": "integer",
          "nullable": false,
          "description": "ID подписки."
        },
        {
          "name": "subscription.external_subscriber_id",
          "type": "string",
          "nullable": true,
          "description": "Ваш внешний идентификатор подписчика."
        },
        {
          "name": "subscription.phone_number",
          "type": "string",
          "nullable": false,
          "description": "Номер телефона подписчика."
        },
        {
          "name": "subscription.subscriber_name",
          "type": "string",
          "nullable": true,
          "description": "Имя подписчика."
        },
        {
          "name": "subscription.amount",
          "type": "string",
          "nullable": false,
          "description": "Сумма платежа по подписке."
        },
        {
          "name": "subscription.billing_period",
          "type": "string",
          "nullable": false,
          "description": "Период списания (например, monthly)."
        },
        {
          "name": "subscription.status",
          "type": "string",
          "nullable": false,
          "description": "Статус подписки (здесь active)."
        },
        {
          "name": "subscription.next_billing_at",
          "type": "string",
          "nullable": true,
          "description": "Дата следующего списания (ISO 8601)."
        },
        {
          "name": "subscription.failed_attempts",
          "type": "integer",
          "nullable": false,
          "description": "Количество подряд неуспешных попыток списания."
        },
        {
          "name": "subscription.in_grace_period",
          "type": "boolean",
          "nullable": false,
          "description": "Находится ли подписка в льготном периоде."
        },
        {
          "name": "subscription.is_sandbox",
          "type": "boolean",
          "nullable": false,
          "description": "Подписка создана в sandbox-режиме."
        },
        {
          "name": "source",
          "type": "string",
          "nullable": true,
          "description": "Название API-ключа."
        },
        {
          "name": "timestamp",
          "type": "string",
          "nullable": false,
          "description": "Время отправки события (ISO 8601)."
        }
      ]
    },
    "subscription.paused": {
      "example": {
        "event": "subscription.paused",
        "subscription": {
          "id": 10,
          "external_subscriber_id": "CLIENT-001",
          "phone_number": "87071234567",
          "subscriber_name": "Иван Иванов",
          "amount": "5000.00",
          "billing_period": "monthly",
          "status": "paused",
          "next_billing_at": "2026-03-01T00:00:00+00:00",
          "failed_attempts": 0,
          "in_grace_period": false,
          "is_sandbox": false
        },
        "source": "My API Key",
        "timestamp": "2026-02-10T09:00:00+00:00"
      },
      "description": "Подписка приостановлена. Счета не выставляются до resume.",
      "fields": [
        {
          "name": "event",
          "type": "string",
          "nullable": false,
          "description": "Тип события — subscription.paused."
        },
        {
          "name": "subscription.id",
          "type": "integer",
          "nullable": false,
          "description": "ID подписки."
        },
        {
          "name": "subscription.external_subscriber_id",
          "type": "string",
          "nullable": true,
          "description": "Ваш внешний идентификатор подписчика."
        },
        {
          "name": "subscription.phone_number",
          "type": "string",
          "nullable": false,
          "description": "Номер телефона подписчика."
        },
        {
          "name": "subscription.subscriber_name",
          "type": "string",
          "nullable": true,
          "description": "Имя подписчика."
        },
        {
          "name": "subscription.amount",
          "type": "string",
          "nullable": false,
          "description": "Сумма платежа по подписке."
        },
        {
          "name": "subscription.billing_period",
          "type": "string",
          "nullable": false,
          "description": "Период списания (например, monthly)."
        },
        {
          "name": "subscription.status",
          "type": "string",
          "nullable": false,
          "description": "Статус подписки (здесь paused)."
        },
        {
          "name": "subscription.next_billing_at",
          "type": "string",
          "nullable": true,
          "description": "Дата следующего списания (ISO 8601)."
        },
        {
          "name": "subscription.failed_attempts",
          "type": "integer",
          "nullable": false,
          "description": "Количество подряд неуспешных попыток списания."
        },
        {
          "name": "subscription.in_grace_period",
          "type": "boolean",
          "nullable": false,
          "description": "Находится ли подписка в льготном периоде."
        },
        {
          "name": "subscription.is_sandbox",
          "type": "boolean",
          "nullable": false,
          "description": "Подписка создана в sandbox-режиме."
        },
        {
          "name": "source",
          "type": "string",
          "nullable": true,
          "description": "Название API-ключа."
        },
        {
          "name": "timestamp",
          "type": "string",
          "nullable": false,
          "description": "Время отправки события (ISO 8601)."
        }
      ]
    },
    "subscription.resumed": {
      "example": {
        "event": "subscription.resumed",
        "subscription": {
          "id": 10,
          "external_subscriber_id": "CLIENT-001",
          "phone_number": "87071234567",
          "subscriber_name": "Иван Иванов",
          "amount": "5000.00",
          "billing_period": "monthly",
          "status": "active",
          "next_billing_at": "2026-03-15T09:30:00+00:00",
          "failed_attempts": 0,
          "in_grace_period": false,
          "is_sandbox": false
        },
        "source": "My API Key",
        "timestamp": "2026-02-15T09:30:00+00:00"
      },
      "description": "Подписка возобновлена. next_billing_at пересчитан от момента возобновления — пропущенные периоды не доначисляются.",
      "fields": [
        {
          "name": "event",
          "type": "string",
          "nullable": false,
          "description": "Тип события — subscription.resumed."
        },
        {
          "name": "subscription.id",
          "type": "integer",
          "nullable": false,
          "description": "ID подписки."
        },
        {
          "name": "subscription.external_subscriber_id",
          "type": "string",
          "nullable": true,
          "description": "Ваш внешний идентификатор подписчика."
        },
        {
          "name": "subscription.phone_number",
          "type": "string",
          "nullable": false,
          "description": "Номер телефона подписчика."
        },
        {
          "name": "subscription.subscriber_name",
          "type": "string",
          "nullable": true,
          "description": "Имя подписчика."
        },
        {
          "name": "subscription.amount",
          "type": "string",
          "nullable": false,
          "description": "Сумма платежа по подписке."
        },
        {
          "name": "subscription.billing_period",
          "type": "string",
          "nullable": false,
          "description": "Период списания (например, monthly)."
        },
        {
          "name": "subscription.status",
          "type": "string",
          "nullable": false,
          "description": "Статус подписки (здесь active)."
        },
        {
          "name": "subscription.next_billing_at",
          "type": "string",
          "nullable": true,
          "description": "Дата следующего списания (ISO 8601), пересчитанная от момента возобновления."
        },
        {
          "name": "subscription.failed_attempts",
          "type": "integer",
          "nullable": false,
          "description": "Количество подряд неуспешных попыток списания."
        },
        {
          "name": "subscription.in_grace_period",
          "type": "boolean",
          "nullable": false,
          "description": "Находится ли подписка в льготном периоде."
        },
        {
          "name": "subscription.is_sandbox",
          "type": "boolean",
          "nullable": false,
          "description": "Подписка создана в sandbox-режиме."
        },
        {
          "name": "source",
          "type": "string",
          "nullable": true,
          "description": "Название API-ключа."
        },
        {
          "name": "timestamp",
          "type": "string",
          "nullable": false,
          "description": "Время отправки события (ISO 8601)."
        }
      ]
    },
    "subscription.cancelled": {
      "example": {
        "event": "subscription.cancelled",
        "subscription": {
          "id": 10,
          "external_subscriber_id": "CLIENT-001",
          "phone_number": "87071234567",
          "subscriber_name": "Иван Иванов",
          "amount": "5000.00",
          "billing_period": "monthly",
          "status": "cancelled",
          "next_billing_at": "2026-03-01T00:00:00+00:00",
          "failed_attempts": 0,
          "in_grace_period": false,
          "is_sandbox": false
        },
        "source": "My API Key",
        "timestamp": "2026-02-20T18:00:00+00:00"
      },
      "description": "Подписка отменена безвозвратно. next_billing_at НЕ обнуляется (сохраняет последнее значение; счета больше не выставляются). Для возобновления создайте новую подписку.",
      "fields": [
        {
          "name": "event",
          "type": "string",
          "nullable": false,
          "description": "Тип события — subscription.cancelled."
        },
        {
          "name": "subscription.id",
          "type": "integer",
          "nullable": false,
          "description": "ID подписки."
        },
        {
          "name": "subscription.external_subscriber_id",
          "type": "string",
          "nullable": true,
          "description": "Ваш внешний идентификатор подписчика."
        },
        {
          "name": "subscription.phone_number",
          "type": "string",
          "nullable": false,
          "description": "Номер телефона подписчика."
        },
        {
          "name": "subscription.subscriber_name",
          "type": "string",
          "nullable": true,
          "description": "Имя подписчика."
        },
        {
          "name": "subscription.amount",
          "type": "string",
          "nullable": false,
          "description": "Сумма платежа по подписке."
        },
        {
          "name": "subscription.billing_period",
          "type": "string",
          "nullable": false,
          "description": "Период списания (например, monthly)."
        },
        {
          "name": "subscription.status",
          "type": "string",
          "nullable": false,
          "description": "Статус подписки (здесь cancelled)."
        },
        {
          "name": "subscription.next_billing_at",
          "type": "string",
          "nullable": true,
          "description": "Дата следующего списания (ISO 8601). НЕ обнуляется при отмене — сохраняет последнее значение; счета больше не выставляются."
        },
        {
          "name": "subscription.failed_attempts",
          "type": "integer",
          "nullable": false,
          "description": "Количество подряд неуспешных попыток списания."
        },
        {
          "name": "subscription.in_grace_period",
          "type": "boolean",
          "nullable": false,
          "description": "Находится ли подписка в льготном периоде."
        },
        {
          "name": "subscription.is_sandbox",
          "type": "boolean",
          "nullable": false,
          "description": "Подписка создана в sandbox-режиме."
        },
        {
          "name": "source",
          "type": "string",
          "nullable": true,
          "description": "Название API-ключа."
        },
        {
          "name": "timestamp",
          "type": "string",
          "nullable": false,
          "description": "Время отправки события (ISO 8601)."
        }
      ]
    },
    "receipt.issued": {
      "example": {
        "event": "receipt.issued",
        "receipt": {
          "id": 4210,
          "client_operation_id": "pos-cash-0042",
          "payment_type": 3,
          "status": "issued",
          "fpd": "000000000000",
          "operation_id": "KKM00000000",
          "link": "https://receipt.kaspi.kz/preview/cashier?extTranId=KKM00000000",
          "shift_number": 106,
          "total_price": "10.00",
          "error_code": null,
          "error_message": null
        },
        "timestamp": "2026-07-12T16:25:43+00:00"
      },
      "description": "Фискальный чек успешно выбит в Kaspi OFD (POST /receipts). Равноправен поллингу GET /receipts/{id}. Дедуп по (event, receipt.id).",
      "fields": [
        {
          "name": "event",
          "type": "string",
          "nullable": false,
          "description": "Тип события — receipt.issued."
        },
        {
          "name": "receipt.id",
          "type": "integer",
          "nullable": false,
          "description": "Внутренний ID чека в ApiPay."
        },
        {
          "name": "receipt.client_operation_id",
          "type": "string",
          "nullable": true,
          "description": "Ваш ключ идемпотентности, переданный при выбивании чека."
        },
        {
          "name": "receipt.payment_type",
          "type": "integer",
          "nullable": false,
          "description": "Тип оплаты: 3 — наличные, 5 — POS другого банка."
        },
        {
          "name": "receipt.status",
          "type": "string",
          "nullable": false,
          "description": "Статус чека — issued (успешно выбит)."
        },
        {
          "name": "receipt.fpd",
          "type": "string",
          "nullable": true,
          "description": "Фискальный признак документа (ФПД) от Kaspi OFD."
        },
        {
          "name": "receipt.operation_id",
          "type": "string",
          "nullable": true,
          "description": "Идентификатор операции в Kaspi."
        },
        {
          "name": "receipt.link",
          "type": "string",
          "nullable": true,
          "description": "Ссылка на чек на receipt.kaspi.kz."
        },
        {
          "name": "receipt.shift_number",
          "type": "integer",
          "nullable": true,
          "description": "Номер смены кассира."
        },
        {
          "name": "receipt.total_price",
          "type": "string",
          "nullable": true,
          "description": "Сумма чека."
        },
        {
          "name": "receipt.error_code",
          "type": "string",
          "nullable": true,
          "description": "Для issued всегда null."
        },
        {
          "name": "receipt.error_message",
          "type": "string",
          "nullable": true,
          "description": "Для issued всегда null."
        },
        {
          "name": "timestamp",
          "type": "string",
          "nullable": false,
          "description": "Время отправки события (ISO 8601, UTC +00:00)."
        }
      ]
    },
    "receipt.failed": {
      "example": {
        "event": "receipt.failed",
        "receipt": {
          "id": 4211,
          "client_operation_id": "pos-cash-0043",
          "payment_type": 3,
          "status": "failed",
          "fpd": null,
          "operation_id": null,
          "link": null,
          "shift_number": null,
          "total_price": "10.00",
          "error_code": "shift_closed",
          "error_message": "Смена кассира закрыта — откройте смену в приложении Kaspi Pay и повторите."
        },
        "timestamp": "2026-07-12T16:26:10+00:00"
      },
      "description": "Выбивание фискального чека не удалось (POST /receipts). Причина — в receipt.error_code. Фискальный документ НЕ создан — повторите с НОВЫМ client_operation_id. Дедуп по (event, receipt.id).",
      "fields": [
        {
          "name": "event",
          "type": "string",
          "nullable": false,
          "description": "Тип события — receipt.failed."
        },
        {
          "name": "receipt.id",
          "type": "integer",
          "nullable": false,
          "description": "Внутренний ID чека в ApiPay."
        },
        {
          "name": "receipt.client_operation_id",
          "type": "string",
          "nullable": true,
          "description": "Ваш ключ идемпотентности, переданный при выбивании чека."
        },
        {
          "name": "receipt.payment_type",
          "type": "integer",
          "nullable": false,
          "description": "Тип оплаты: 3 — наличные, 5 — POS другого банка."
        },
        {
          "name": "receipt.status",
          "type": "string",
          "nullable": false,
          "description": "Статус чека — failed."
        },
        {
          "name": "receipt.fpd",
          "type": "string",
          "nullable": true,
          "description": "При failed всегда null — фискальный документ не создан."
        },
        {
          "name": "receipt.operation_id",
          "type": "string",
          "nullable": true,
          "description": "При failed всегда null."
        },
        {
          "name": "receipt.link",
          "type": "string",
          "nullable": true,
          "description": "При failed всегда null."
        },
        {
          "name": "receipt.shift_number",
          "type": "integer",
          "nullable": true,
          "description": "При failed обычно null."
        },
        {
          "name": "receipt.total_price",
          "type": "string",
          "nullable": true,
          "description": "Сумма чека."
        },
        {
          "name": "receipt.error_code",
          "type": "string",
          "nullable": true,
          "description": "Код причины: shift_closed (закрыта смена), item_not_fiscal (позиция без НТИН), rfo_missing, receipt_kaspi_error, receipt_dispatch_error. Стройте switch по нему, не по тексту."
        },
        {
          "name": "receipt.error_message",
          "type": "string",
          "nullable": true,
          "description": "Человекочитаемое пояснение ошибки."
        },
        {
          "name": "timestamp",
          "type": "string",
          "nullable": false,
          "description": "Время отправки события (ISO 8601, UTC +00:00)."
        }
      ]
    },
    "qr_refund.identified": {
      "example": {
        "event": "qr_refund.identified",
        "qr_refund": {
          "id": 42,
          "status": "customer_identified",
          "client_name": "Иван И.",
          "expires_at": "2026-07-27T17:27:09+00:00",
          "is_sandbox": false
        },
        "source": "CRM integration",
        "timestamp": "2026-07-27T17:22:31+00:00"
      },
      "description": "Покупатель отсканировал возвратный QR и подтверждён Kaspi — можно читать GET /qr-refunds/{id}/operations. Равноправен поллингу статуса. Шлётся один раз на переход.",
      "fields": [
        {
          "name": "event",
          "type": "string",
          "nullable": false,
          "description": "Тип события — qr_refund.identified."
        },
        {
          "name": "qr_refund.id",
          "type": "integer",
          "nullable": false,
          "description": "ID сессии QR-возврата."
        },
        {
          "name": "qr_refund.status",
          "type": "string",
          "nullable": false,
          "description": "Всегда customer_identified. Статус берётся из СОБЫТИЯ, а не из живой сессии: ретрай не принесёт противоречивый payload."
        },
        {
          "name": "qr_refund.client_name",
          "type": "string",
          "nullable": true,
          "description": "Имя покупателя от Kaspi. null, пока покупатель не подтвердил."
        },
        {
          "name": "qr_refund.expires_at",
          "type": "string",
          "nullable": true,
          "description": "Срок действия ссылки (ISO 8601)."
        },
        {
          "name": "qr_refund.is_sandbox",
          "type": "boolean",
          "nullable": false,
          "description": "Сессия создана в песочнице."
        },
        {
          "name": "source",
          "type": "string",
          "nullable": true,
          "description": "Имя API-ключа, которым создана сессия."
        },
        {
          "name": "timestamp",
          "type": "string",
          "nullable": false,
          "description": "Время отправки события (ISO 8601)."
        }
      ]
    },
    "qr_refund.completed": {
      "example": {
        "event": "qr_refund.completed",
        "qr_refund": {
          "id": 42,
          "status": "completed",
          "client_name": "Иван И.",
          "expires_at": "2026-07-27T17:27:09+00:00",
          "is_sandbox": false,
          "refunded_amount": "500.00",
          "receipt_url": "https://receipt.kaspi.kz/preview/cashier?extTranId=KKM00000000"
        },
        "source": "CRM integration",
        "timestamp": "2026-07-27T17:24:02+00:00"
      },
      "description": "Возврат выполнен. Денежные поля (refunded_amount, receipt_url) приходят только здесь. Дедуп по (event, qr_refund.id).",
      "fields": [
        {
          "name": "event",
          "type": "string",
          "nullable": false,
          "description": "Тип события — qr_refund.completed."
        },
        {
          "name": "qr_refund.id",
          "type": "integer",
          "nullable": false,
          "description": "ID сессии QR-возврата."
        },
        {
          "name": "qr_refund.status",
          "type": "string",
          "nullable": false,
          "description": "Всегда completed."
        },
        {
          "name": "qr_refund.client_name",
          "type": "string",
          "nullable": true,
          "description": "Имя покупателя от Kaspi. null, пока покупатель не подтвердил."
        },
        {
          "name": "qr_refund.expires_at",
          "type": "string",
          "nullable": true,
          "description": "Срок действия ссылки (ISO 8601)."
        },
        {
          "name": "qr_refund.is_sandbox",
          "type": "boolean",
          "nullable": false,
          "description": "Сессия создана в песочнице."
        },
        {
          "name": "qr_refund.refunded_amount",
          "type": "string",
          "nullable": true,
          "description": "Возвращённая сумма. Приходит ТОЛЬКО в qr_refund.completed."
        },
        {
          "name": "qr_refund.receipt_url",
          "type": "string",
          "nullable": true,
          "description": "Ссылка на чек возврата в Kaspi. Только в qr_refund.completed."
        },
        {
          "name": "source",
          "type": "string",
          "nullable": true,
          "description": "Имя API-ключа, которым создана сессия."
        },
        {
          "name": "timestamp",
          "type": "string",
          "nullable": false,
          "description": "Время отправки события (ISO 8601)."
        }
      ]
    },
    "qr_refund.expired": {
      "example": {
        "event": "qr_refund.expired",
        "qr_refund": {
          "id": 43,
          "status": "expired",
          "client_name": null,
          "expires_at": "2026-07-27T17:20:00+00:00",
          "is_sandbox": false
        },
        "source": "CRM integration",
        "timestamp": "2026-07-27T17:20:05+00:00"
      },
      "description": "Срок сессии истёк: покупатель не отсканировал QR либо не уложился во время выбора операции после подтверждения. Возврат не сделан — начните новую сессию.",
      "fields": [
        {
          "name": "event",
          "type": "string",
          "nullable": false,
          "description": "Тип события — qr_refund.expired."
        },
        {
          "name": "qr_refund.id",
          "type": "integer",
          "nullable": false,
          "description": "ID сессии QR-возврата."
        },
        {
          "name": "qr_refund.status",
          "type": "string",
          "nullable": false,
          "description": "Всегда expired."
        },
        {
          "name": "qr_refund.client_name",
          "type": "string",
          "nullable": true,
          "description": "Имя покупателя от Kaspi. null, пока покупатель не подтвердил."
        },
        {
          "name": "qr_refund.expires_at",
          "type": "string",
          "nullable": true,
          "description": "Срок действия ссылки (ISO 8601)."
        },
        {
          "name": "qr_refund.is_sandbox",
          "type": "boolean",
          "nullable": false,
          "description": "Сессия создана в песочнице."
        },
        {
          "name": "source",
          "type": "string",
          "nullable": true,
          "description": "Имя API-ключа, которым создана сессия."
        },
        {
          "name": "timestamp",
          "type": "string",
          "nullable": false,
          "description": "Время отправки события (ISO 8601)."
        }
      ]
    },
    "qr_refund.failed": {
      "example": {
        "event": "qr_refund.failed",
        "qr_refund": {
          "id": 44,
          "status": "failed",
          "client_name": null,
          "expires_at": null,
          "is_sandbox": false,
          "error_code": "qr_refund_activation_failed",
          "error_message": "Безопасно начать подтверждение не удалось. Создайте новую ссылку на возврат."
        },
        "source": "CRM integration",
        "timestamp": "2026-07-27T17:21:14+00:00"
      },
      "description": "Начать подтверждение не удалось: возвратный QR выдан не был. Терминал сессии. Приходит и по ссылке, и по немедленному старту POST /qr-refunds. Возврат не начинался, деньги не двигались. ⛔ Повторять нечего: попытка одноразовая и уже потрачена — нужна НОВАЯ сессия, новая ссылка POST /qr-refunds/links либо новый POST /qr-refunds. Дедуп по (event, qr_refund.id).",
      "fields": [
        {
          "name": "event",
          "type": "string",
          "nullable": false,
          "description": "Тип события — qr_refund.failed."
        },
        {
          "name": "qr_refund.id",
          "type": "integer",
          "nullable": false,
          "description": "ID сессии QR-возврата."
        },
        {
          "name": "qr_refund.status",
          "type": "string",
          "nullable": false,
          "description": "Всегда failed."
        },
        {
          "name": "qr_refund.client_name",
          "type": "string",
          "nullable": true,
          "description": "Имя покупателя от Kaspi. null, пока покупатель не подтвердил."
        },
        {
          "name": "qr_refund.expires_at",
          "type": "string",
          "nullable": true,
          "description": "Конец окна скана (ISO 8601). null, если окно так и не начиналось."
        },
        {
          "name": "qr_refund.is_sandbox",
          "type": "boolean",
          "nullable": false,
          "description": "Сессия создана в песочнице."
        },
        {
          "name": "qr_refund.error_code",
          "type": "string",
          "nullable": false,
          "description": "Закреплён за событием и всегда равен qr_refund_activation_failed — читать его с живой сессии не нужно."
        },
        {
          "name": "qr_refund.error_message",
          "type": "string",
          "nullable": false,
          "description": "Текст для продавца, закреплён за событием: начать подтверждение не удалось, нужна новая ссылка на возврат."
        },
        {
          "name": "source",
          "type": "string",
          "nullable": true,
          "description": "Имя API-ключа, которым создана сессия."
        },
        {
          "name": "timestamp",
          "type": "string",
          "nullable": false,
          "description": "Время отправки события (ISO 8601)."
        }
      ]
    },
    "qr_refund.execution_uncertain": {
      "example": {
        "event": "qr_refund.execution_uncertain",
        "qr_refund": {
          "id": 45,
          "status": "execution_uncertain",
          "client_name": "Иван И.",
          "expires_at": "2026-07-27T17:27:09+00:00",
          "is_sandbox": false,
          "error_code": "qr_refund_execution_uncertain",
          "error_message": "Статус возврата требует ручной проверки. Не повторяйте возврат и обратитесь в поддержку."
        },
        "source": "CRM integration",
        "timestamp": "2026-07-27T17:24:48+00:00"
      },
      "description": "⛔ Исход возврата не доказан. Денежный запрос ушёл, а ответ не доказал ни успех, ни отказ — деньги, возможно, уже списаны. Терминал для автоматики. Повторять возврат нельзя ни при каких условиях: второй запрос — это второй возврат живых денег покупателю. Разбирает человек — обратитесь в поддержку. До ответа поддержки не проводите этот возврат ни повторным execute, ни вручную в приложении Kaspi Pay — деньги могли уже уйти. Тот же исход виден синхронно как 202 qr_refund_execution_uncertain на POST /qr-refunds/{id}/execute. Дедуп по (event, qr_refund.id).",
      "fields": [
        {
          "name": "event",
          "type": "string",
          "nullable": false,
          "description": "Тип события — qr_refund.execution_uncertain."
        },
        {
          "name": "qr_refund.id",
          "type": "integer",
          "nullable": false,
          "description": "ID сессии QR-возврата."
        },
        {
          "name": "qr_refund.status",
          "type": "string",
          "nullable": false,
          "description": "Всегда execution_uncertain."
        },
        {
          "name": "qr_refund.client_name",
          "type": "string",
          "nullable": true,
          "description": "Имя покупателя от Kaspi."
        },
        {
          "name": "qr_refund.expires_at",
          "type": "string",
          "nullable": true,
          "description": "Конец окна скана (ISO 8601)."
        },
        {
          "name": "qr_refund.is_sandbox",
          "type": "boolean",
          "nullable": false,
          "description": "Сессия создана в песочнице."
        },
        {
          "name": "qr_refund.error_code",
          "type": "string",
          "nullable": false,
          "description": "Закреплён за событием и всегда равен qr_refund_execution_uncertain — читать его с живой сессии не нужно."
        },
        {
          "name": "qr_refund.error_message",
          "type": "string",
          "nullable": false,
          "description": "Текст для продавца, закреплён за событием: статус возврата требует ручной проверки, повторять возврат не нужно."
        },
        {
          "name": "source",
          "type": "string",
          "nullable": true,
          "description": "Имя API-ключа, которым создана сессия."
        },
        {
          "name": "timestamp",
          "type": "string",
          "nullable": false,
          "description": "Время отправки события (ISO 8601)."
        }
      ]
    },
    "cashbox.shift_closed": {
      "example": {
        "event": "cashbox.shift_closed",
        "operation": {
          "id": 812,
          "operation_type": "close_shift",
          "status": "completed",
          "shift_number": 106,
          "error_code": null
        },
        "timestamp": "2026-08-10T16:25:43+00:00"
      },
      "description": "Кассовая смена закрыта. Приходит после того, как закрытие, принятое запросом POST /cashbox/shifts/close, завершилось успехом.",
      "fields": [
        {
          "name": "event",
          "type": "string",
          "nullable": false,
          "description": "Тип события — cashbox.shift_closed."
        },
        {
          "name": "operation.id",
          "type": "integer",
          "nullable": false,
          "description": "ID кассовой операции — тот же, что вернул ответ 202 и по которому идёт поллинг GET /cashbox/operations/{id}. Дедуплицируйте по паре (event, operation.id)."
        },
        {
          "name": "operation.operation_type",
          "type": "string",
          "nullable": false,
          "description": "Тип операции. Сейчас всегда close_shift."
        },
        {
          "name": "operation.status",
          "type": "string",
          "nullable": false,
          "description": "Терминальный статус операции — completed."
        },
        {
          "name": "operation.shift_number",
          "type": "integer",
          "nullable": true,
          "description": "Номер закрытой смены."
        },
        {
          "name": "operation.error_code",
          "type": "string",
          "nullable": true,
          "description": "При успехе всегда null."
        },
        {
          "name": "timestamp",
          "type": "string",
          "nullable": false,
          "description": "Время события в UTC."
        }
      ]
    },
    "cashbox.shift_close_failed": {
      "example": {
        "event": "cashbox.shift_close_failed",
        "operation": {
          "id": 813,
          "operation_type": "close_shift",
          "status": "failed",
          "shift_number": 106,
          "error_code": "cashbox_operation_failed"
        },
        "timestamp": "2026-08-10T16:26:11+00:00"
      },
      "description": "Закрытие смены не удалось. Смена могла остаться открытой — решение о повторе принимайте по полю resolution.safe_to_retry в GET /cashbox/operations/{id}.",
      "fields": [
        {
          "name": "event",
          "type": "string",
          "nullable": false,
          "description": "Тип события — cashbox.shift_close_failed."
        },
        {
          "name": "operation.id",
          "type": "integer",
          "nullable": false,
          "description": "ID кассовой операции. Дедуплицируйте по паре (event, operation.id)."
        },
        {
          "name": "operation.operation_type",
          "type": "string",
          "nullable": false,
          "description": "Тип операции. Сейчас всегда close_shift."
        },
        {
          "name": "operation.status",
          "type": "string",
          "nullable": false,
          "description": "Терминальный статус операции — failed."
        },
        {
          "name": "operation.shift_number",
          "type": "integer",
          "nullable": true,
          "description": "Номер смены, которую пытались закрыть."
        },
        {
          "name": "operation.error_code",
          "type": "string",
          "nullable": true,
          "description": "Причина отказа — слаг вида cashbox_*. Повторяйте закрытие только новым client_operation_id: прежний ключ после отказа не освобождается."
        },
        {
          "name": "timestamp",
          "type": "string",
          "nullable": false,
          "description": "Время события в UTC."
        }
      ]
    }
  }
}
