openapi: 3.0.3
info:
  title: Loyella API
  version: "1.0.0"
  x-source: docs/api-blueprint.apib
  description: |
    Публичный HTTP API сервиса электронных карт **Loyella** (Apple Wallet, Google Wallet,
    Telegram). Через API вы выпускаете и обновляете карты, начисляете и списываете баллы,
    ставите штампы, погашаете подарочные карты, читаете состояние карт и подписываетесь
    на события через вебхуки.

    Единый источник правды по контрактам — файл API Blueprint (`docs/api-blueprint.apib`,
    страница https://loyella.ru/developers). Эта OpenAPI-спецификация повторяет те же
    контракты в машиночитаемом виде для импорта в Postman, Insomnia, Swagger UI и генераторы
    клиентов.

    ## Обзор

    - **Base URL:** `https://loyella.ru`.
    - **Версия:** все методы под префиксом `/v1`. Аддитивные изменения (новые поля/методы)
      выходят без смены версии; ломающие — только в новой версии (`/v2`).
    - **Формат:** запросы и ответы — JSON (`Content-Type: application/json`), кодировка UTF-8.
    - **Обёртка ответа:** успешный ответ — `{ "data": … }`. До 15.07.2026 было одно исключение —
      выпуск карты (`POST /v1/passes`) отдавался плоским объектом без обёртки `data`; это оказалось
      ошибкой (инцидент #326): реальная 1С Fitness 53.1 читает РОВНО `data.serial_number/
      pass_number/link`. Исключение снято, форма приведена к донору бит-в-бит.
    - **Неизвестный путь** под `/v1` возвращает JSON `404` (а не HTML) — инвариант
      совместимости с 1С Fitness.

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

    Каждый запрос подписывается парой ключей организации в заголовках `X-Client-Id` и
    `X-Client-Secret`. Ключи выпускаются в личном кабинете (раздел «Интеграции», тариф Pro);
    секрет показывается один раз при выпуске, в базе хранится только его bcrypt-хэш.
    Любой отказ авторизации (нет заголовков / неизвестный client_id / неверный секрет /
    организация приостановлена) — единый ответ `403` с конвертом ошибки. Данные жёстко
    изолированы по организации: чужой серийный номер отдаёт `404`.

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

    Повторная отправка мутации (POST/PATCH/PUT/DELETE) с тем же ключом идемпотентности не
    выполняет операцию второй раз, а возвращает сохранённый первый ответ дословно (заголовок
    ответа `Idempotent-Replay: true`).

    - Общий случай — заголовок `Idempotency-Key` (принимается и написание `Idempotence-Key`).
    - Денежные пути (`points/credit`, `points/debit`, `redeem`, `stamps/set`, `broadcasts`) —
      ключом служит обязательное поле тела `operation_id`, и оно приоритетнее заголовка.

    Тот же ключ + другое тело → `409`. Ключ хранится ~24 часа. ВНИМАНИЕ: реплеится любой
    сохранённый ответ, включая ошибки 4xx; если первая попытка вернула исправимую 4xx —
    повторяйте с новым `operation_id`/`Idempotency-Key`. Ответы 5xx не кэшируются.

    ## Лимиты запросов

    По умолчанию 600 запросов в минуту на организацию. На каждом ответе — заголовки
    `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`. При превышении — `429`
    с заголовком `Retry-After`.

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

    Единый конверт: `{ "error": { "code": 422, "message": "…" } }`. Поле `code` обычно
    дублирует HTTP-статус; у части унаследованных методов 1С (пакетный `PATCH /v1/passes`,
    `POST /v1/webhooks` с ошибкой валидации) поле `code` может отсутствовать — тогда есть
    только `message`. Отдельные валидации несут дополнительные поля: `field`, `balance`,
    `target`.

    ## Совместимость с 1С Fitness

    Loyella — прямая замена сервиса карт для 1С Fitness. Прежние эндпоинты работают без
    изменений (тот же формат тела и ответов). Для подключения существующей интеграции 1С
    достаточно сменить адрес API на `https://loyella.ru` и прописать выданные ключи
    `X-Client-Id` / `X-Client-Secret`.
  contact:
    name: Loyella
    url: https://loyella.ru/developers
  license:
    name: Проприетарная лицензия Loyella
    url: https://loyella.ru/oferta

servers:
  - url: https://loyella.ru
    description: Продакшн

security:
  - ClientId: []
    ClientSecret: []

tags:
  - name: Карты
    description: Выпуск, чтение, обновление и удаление карт.
  - name: Штампы
    description: Штамп-карты (собери N штампов — получи награду). Настраиваются на виде карты.
  - name: Баллы
    description: Начисление и списание баллов/бонусов с журналом операций и идемпотентностью.
  - name: Погашение
    description: Частичное списание с номинала подарочной карты/депозита.
  - name: Рассылки
    description: Push-рассылка держателям карт (текст на экране блокировки). Лимит 5 в сутки.
  - name: Аналитика
    description: Компактная сводка организации (только чтение) — для BI и учётных систем.
  - name: Поля
    description: Реестр известных полей организации (латинский ключ → русская подпись).
  - name: Сверка с 1С
    description: |
      Автогашение карт, которых нет в 1С: приём полного среза живых карт учётной системы частями
      и решение об аннулировании «сирот» с многоступенчатыми предохранителями (полнота среза,
      порог покрытия 95%, лимит 2% базы за прогон, возраст карты 24ч, канал выпуска). Сирота
      бывает двух природ: карточки нет в срезе вовсе («удалена в 1С») либо она есть, но помечена
      на удаление, — и гасятся они РАЗДЕЛЬНО. Дефолт нового тенанта — режим наблюдения
      (`observe`): ничего не гасится, пока владелец сам не включит в кабинете боевой режим
      (`enforce_missing` — только удалённые, `enforce_all` — и помеченные на удаление).
  - name: Справочники
    description: Метаданные вида карты, нумераторы и данные сертификата (совместимость с 1С Fitness).
  - name: Вебхуки
    description: |
      Подписка на события Loyella. Тело события POST-ится на ваш URL с HMAC-подписью,
      доставка надёжная (до 6 попыток с backoff, журнал доставок, авто-отключение после
      15 подряд «мёртвых» доставок). Заголовки доставки: `X-Loyella-Event`,
      `X-Loyella-Delivery`, `X-Loyella-Timestamp`, `X-Loyella-Attempt`, `X-Loyella-Signature`
      (`sha256=hmac_sha256(secret, "<timestamp>.<rawBody>")`). Секрет (`whsec_…`) выдаётся
      один раз при создании подписки.

paths:
  /v1/passes:
    get:
      tags: [Карты]
      operationId: listPasses
      summary: Список / поиск карт
      description: |
        Два режима. 1) Точечный поиск по одному идентификатору: `serial_number` | `pass_number`
        | `phone` (телефон нормализуется) — возвращает 0..1 карт. 2) Фильтруемый список:
        `query` (ФИО/номер), `status` (`active`/`voided`) или `voided`, `project_id` (внешний
        номер вида), `updated_since` (ISO 8601, инкрементальный синк), `limit`, `offset`.
      parameters:
        - name: serial_number
          in: query
          required: false
          schema: { type: string }
          description: Точечный поиск по серийному номеру.
        - name: pass_number
          in: query
          required: false
          schema: { type: string }
          description: Точечный поиск по номеру карты.
        - name: phone
          in: query
          required: false
          schema: { type: string }
          description: Точечный поиск по телефону (нормализуется).
        - name: query
          in: query
          required: false
          schema: { type: string }
          description: Поиск по ФИО или номеру карты.
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum: [active, voided]
          description: Фильтр по статусу.
        - name: voided
          in: query
          required: false
          schema: { type: boolean }
          description: Альтернатива status — только аннулированные (`true`) / только активные (`false`).
        - name: project_id
          in: query
          required: false
          schema: { type: integer }
          description: Внешний номер вида карты.
        - name: updated_since
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Только карты, изменённые после указанного момента.
        - $ref: '#/components/parameters/LimitQuery'
        - $ref: '#/components/parameters/OffsetQuery'
      responses:
        '200':
          description: Список карт.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: { $ref: '#/components/schemas/PassListItem' }
                      total: { type: integer }
                      limit: { type: integer }
                      offset: { type: integer }
              example:
                data:
                  items:
                    - serial_number: a1b2c3d4-0000-0000-0000-000000000001
                      pass_number: CARD-000123
                      project_id: 1
                      status: active
                      voided: false
                      first_name: Иван
                      last_name: Петров
                      fields: { first_name: Иван, last_name: Петров, bonuses: "150" }
                      created_at: "2026-07-01T10:00:00.000Z"
                      updated_at: "2026-07-10T12:30:00.000Z"
                      link: https://loyella.ru/pass/a1b2c3d4-0000-0000-0000-000000000001
                  total: 1
                  limit: 20
                  offset: 0
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Карты]
      operationId: issuePass
      summary: Выпустить карту
      description: |
        Создаёт карту выбранного вида. `project_id` — внешний номер вида (по умолчанию `1`).
        Номер карты берётся из нумератора организации. Выпуск списывает годовую ставку с баланса
        той же транзакцией; при нехватке средств → `402`, карта не создаётся. `created_via:
        "test"` — бесплатный тестовый выпуск (не идёт в счёт). Ответ — с обёрткой `data`, бит-в-бит
        донор (до 15.07.2026 отдавался плоским, без обёртки, — ошибка: реальная 1С Fitness 53.1
        читает РОВНО `data.serial_number/pass_number/link`, инцидент #326).
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fields]
              properties:
                project_id:
                  type: integer
                  default: 1
                  description: Внешний номер вида карты.
                fields:
                  type: object
                  additionalProperties: true
                  description: Поля карты (латинский ключ → значение).
                created_via:
                  type: string
                  enum: [api, admin, form, test]
                  description: Канал выпуска. `test` — бесплатный тестовый выпуск.
            example:
              project_id: 1
              fields: { first_name: Иван, last_name: Петров, phone: "+79990001122" }
              created_via: api
      responses:
        '201':
          description: Карта выпущена (ответ с обёрткой `data`, бит-в-бит донор).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      serial_number: { type: string }
                      pass_number: { type: string }
                      link: { type: string }
                      project_id:
                        type: integer
                        description: Внешний номер вида карты (НЕ internal projects.id, инцидент #325).
                      fields:
                        type: object
                        additionalProperties: true
              example:
                data:
                  serial_number: a1b2c3d4-0000-0000-0000-000000000001
                  pass_number: CARD-000123
                  link: https://loyella.ru/pass/a1b2c3d4-0000-0000-0000-000000000001
                  project_id: 1
                  fields: { first_name: Иван, last_name: Петров, phone: "+79990001122" }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Карты]
      operationId: batchPatchPasses
      summary: Обновить набор карт (batch)
      description: |
        Пакетное обновление полей. Принимает массив `[{serial_number, fields}]` (или один
        объект). Ответ — карта результатов по серийным номерам (форма, совместимая с 1С
        Fitness). Основной метод, вызываемый из 1С Fitness.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              items:
                type: object
                required: [serial_number, fields]
                properties:
                  serial_number: { type: string }
                  fields:
                    type: object
                    additionalProperties: true
            example:
              - serial_number: a1b2c3d4-0000-0000-0000-000000000001
                fields: { bonuses: "300" }
      responses:
        '200':
          description: Карта результатов по серийным номерам.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    additionalProperties: { $ref: '#/components/schemas/BatchResultItem' }
              example:
                data:
                  a1b2c3d4-0000-0000-0000-000000000001:
                    serial_number: a1b2c3d4-0000-0000-0000-000000000001
                    pass_number: CARD-000123
                    status: updated
                    success: true
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/passes/by-fields:
    patch:
      tags: [Карты]
      operationId: patchPassesByFields
      summary: Записать одни и те же поля списку карт
      description: |
        Массовая правка одного набора полей у перечисленных карт. Ответ — карта результатов по
        серийным. Ненайденная карта → `error.code: "not_found"` (#329, инцидент W3, 15.07.2026) —
        по этому коду 1С (`ОбновитьМассивКарт`) запускает самоочистку устаревшей карты в своей базе.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [serial_numbers, fields]
              properties:
                serial_numbers:
                  type: array
                  items: { type: string }
                fields:
                  type: object
                  additionalProperties: true
            example:
              serial_numbers: [a1b2c3d4-...-001, a1b2c3d4-...-002]
              fields: { tier: gold }
      responses:
        '200':
          description: Карта результатов по серийным номерам (по каждой карте — успех или ошибка).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    additionalProperties: { $ref: '#/components/schemas/BatchResultItem' }
              example:
                data:
                  a1b2c3d4-...-001:
                    serial_number: a1b2c3d4-...-001
                    pass_number: CARD-000123
                    status: updated
                    success: true
                  a1b2c3d4-...-002:
                    serial_number: a1b2c3d4-...-002
                    error: { code: not_found, message: Pass not found }
                    success: false
                    status: 404
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/passes/{serial}:
    parameters:
      - $ref: '#/components/parameters/SerialPath'
    get:
      tags: [Карты]
      operationId: getPass
      summary: Прочитать карту
      description: |
        Полное состояние карты: поля, статус, вид, число установок на устройства, метки
        времени, ссылка на установку. Резолвится сначала по `serial_number`, затем по
        `pass_number`. Чужой/несуществующий серийный → `404`.
      responses:
        '200':
          description: Состояние карты.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Pass' }
              example:
                data:
                  serial_number: a1b2c3d4-0000-0000-0000-000000000001
                  pass_number: CARD-000123
                  project_id: 1
                  status: active
                  voided: false
                  created_via: api
                  fields: { first_name: Иван, last_name: Петров, bonuses: "150", phone: "+79990001122" }
                  point_id: 4
                  registrations: 1
                  created_at: "2026-07-01T10:00:00.000Z"
                  updated_at: "2026-07-10T12:30:00.000Z"
                  link: https://loyella.ru/pass/a1b2c3d4-0000-0000-0000-000000000001
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Карты]
      operationId: patchPass
      summary: Обновить одну карту
      description: |
        Поверхностный мерж переданных полей. `{"voided": true}` аннулирует карту. После
        обновления карта пушится на устройства, изменения фиксируются в журнале, отправляется
        вебхук.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
              properties:
                voided:
                  type: boolean
                  description: '`true` — аннулировать карту (мягкое аннулирование).'
            example: { phone: "+79995556677", bonuses: "200" }
      responses:
        '200':
          description: Карта обновлена.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: object, additionalProperties: true }
              example:
                data:
                  serial_number: a1b2c3d4-0000-0000-0000-000000000001
                  pass_number: CARD-000123
                  status: updated
                  success: true
                  fields: { first_name: Иван, phone: "+79995556677", bonuses: "200" }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Карты]
      operationId: deletePass
      summary: Удалить карту
      description: |
        Удаляет карту организации. **Идемпотентно** (#329, инцидент W6, 15.07.2026, как у донора
        1С Fitness): повторное удаление, удаление уже отсутствующей карты и удаление чужого
        серийного — ВСЕГДА `200` с одинаковым телом; существование чужой карты не раскрывается
        кодом ответа. Для «мягкого» аннулирования используйте `PATCH /v1/passes/{serial}` с телом
        `{"voided": true}`.
      responses:
        '200':
          description: Карта удалена (или уже отсутствовала — идемпотентный ответ).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      serial_number: { type: string }
                      status: { type: string }
                      success: { type: boolean }
              example:
                data: { serial_number: a1b2c3d4-0000-0000-0000-000000000001, status: deleted, success: true }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/passes/{serial}/events:
    parameters:
      - $ref: '#/components/parameters/SerialPath'
    get:
      tags: [Карты]
      operationId: getPassEvents
      summary: Журнал событий карты
      description: |
        События карты (выпуск, установка, штампы, баллы, рассылки и т.д.), новейшие первыми.
        Внутренние поля `meta` наружу не отдаются — только безопасное человекочитаемое
        подмножество в `details`. Чужой/несуществующий серийный → `404`.
      parameters:
        - name: type
          in: query
          required: false
          schema: { type: string }
          description: Фильтр по типу события (например, `points_earn`).
        - $ref: '#/components/parameters/LimitQuery'
        - $ref: '#/components/parameters/OffsetQuery'
      responses:
        '200':
          description: Журнал событий карты.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: { $ref: '#/components/schemas/CardEvent' }
                      total: { type: integer }
                      limit: { type: integer }
                      offset: { type: integer }
              example:
                data:
                  items:
                    - id: 812
                      type: points_earn
                      source: api
                      created_at: "2026-07-13T14:05:00Z"
                      details: { field: bonuses, delta: 150, before: 150, after: 300, operation_id: sale-4417 }
                    - id: 640
                      type: issue
                      source: api
                      created_at: "2026-07-10T09:00:00Z"
                      details: { channel: api }
                  total: 2
                  limit: 20
                  offset: 0
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/passes/{serial}/stamp:
    parameters:
      - $ref: '#/components/parameters/SerialPath'
    post:
      tags: [Штампы]
      operationId: addStamp
      summary: Поставить один штамп (+1)
      description: |
        Инкремент на 1 с логикой награды: при достижении цели выдаётся награда (`reward_text`),
        счётчик сбрасывается в 0. Кулдауна нет. Для защиты от двойного штампа при ретрае
        передавайте заголовок `Idempotency-Key`. Если у вида штампы выключены — `422`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyHeader'
      responses:
        '200':
          description: Штамп поставлен.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: object, additionalProperties: true }
              example:
                data:
                  serial_number: a1b2c3d4-...-001
                  pass_number: CARD-000123
                  stamps: { current: 4, target: 6 }
                  rewarded: false
                  success: true
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/passes/{serial}/stamps/set:
    parameters:
      - $ref: '#/components/parameters/SerialPath'
    post:
      tags: [Штампы]
      operationId: setStamps
      summary: Установить абсолютное значение счётчика штампов
      description: |
        Задаёт счётчик штампов в точное значение `count` (0..target) — для импорта/коррекции.
        Награда не триггерится, даже если `count === target`. `operation_id` обязателен
        (ключ идемпотентности).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [count, operation_id]
              properties:
                count:
                  type: integer
                  minimum: 0
                  description: Целое от 0 до target вида карты.
                operation_id:
                  type: string
                  description: Ключ идемпотентности проводки.
            example: { count: 3, operation_id: import-2026-07-13-001 }
      responses:
        '200':
          description: Счётчик установлен.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: object, additionalProperties: true }
              example:
                data:
                  serial_number: a1b2c3d4-...-001
                  pass_number: CARD-000123
                  stamps: { current: 3, target: 6 }
                  operation_id: import-2026-07-13-001
                  success: true
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/passes/{serial}/points/credit:
    parameters:
      - $ref: '#/components/parameters/SerialPath'
    post:
      tags: [Баллы]
      operationId: creditPoints
      summary: Начислить баллы
      description: |
        Начисление баллов/бонусов. Поле по умолчанию — `bonuses`; можно указать другое
        поле-счётчик (латиница/цифры/`_`, 1..64 символа). `operation_id` обязателен — это
        ключ идемпотентности проводки.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PointsRequest' }
            example:
              amount: 150
              field: bonuses
              operation_id: sale-4417
              comment: Начисление за покупку
              source_doc: Чек №4417
      responses:
        '200':
          description: Баллы начислены.
          headers:
            Idempotent-Replay:
              $ref: '#/components/headers/IdempotentReplay'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/PointsResult' }
              example:
                data:
                  serial_number: a1b2c3d4-...-001
                  pass_number: CARD-000123
                  field: bonuses
                  before: 150
                  after: 300
                  delta: 150
                  operation_id: sale-4417
                  success: true
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/passes/{serial}/points/debit:
    parameters:
      - $ref: '#/components/parameters/SerialPath'
    post:
      tags: [Баллы]
      operationId: debitPoints
      summary: Списать баллы
      description: |
        Та же механика, что начисление, но со знаком минус. Уход остатка ниже нуля запрещён —
        `422` с текущим остатком в поле `balance`. `operation_id` обязателен.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PointsRequest' }
            example: { amount: 50, field: bonuses, operation_id: redeem-4418 }
      responses:
        '200':
          description: Баллы списаны.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/PointsResult' }
              example:
                data:
                  serial_number: a1b2c3d4-...-001
                  pass_number: CARD-000123
                  field: bonuses
                  before: 300
                  after: 250
                  delta: -50
                  operation_id: redeem-4418
                  success: true
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422':
          description: 'Бизнес-ошибка (например, недостаточно средств: `field`, `balance`).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              example:
                error: { code: 422, message: Недостаточно средств на балансе, field: bonuses, balance: 40 }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/passes/{serial}/redeem:
    parameters:
      - $ref: '#/components/parameters/SerialPath'
    post:
      tags: [Погашение]
      operationId: redeemGift
      summary: Погасить подарочную карту / депозит
      description: |
        Частичное списание с номинала подарочной карты/депозита (поле по умолчанию —
        `balance`). Уход в минус запрещён (`422`). `void_at_zero: true` + остаток 0 → карта
        аннулируется в той же транзакции. `operation_id` обязателен.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, operation_id]
              properties:
                amount:
                  type: number
                  description: Сумма погашения.
                field:
                  type: string
                  default: balance
                  description: Поле-номинал (по умолчанию `balance`).
                operation_id:
                  type: string
                  description: Ключ идемпотентности проводки.
                void_at_zero:
                  type: boolean
                  description: Аннулировать карту, если остаток стал 0.
            example: { amount: 300, field: balance, operation_id: sale-9001, void_at_zero: true }
      responses:
        '200':
          description: Подарочная карта погашена.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: object, additionalProperties: true }
              example:
                data:
                  serial_number: GIFT-000042
                  pass_number: GIFT-000042
                  field: balance
                  before: 300
                  after: 0
                  redeemed: 300
                  operation_id: sale-9001
                  voided: true
                  success: true
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422':
          description: 'Бизнес-ошибка (недостаточно средств на карте: `field`, `balance`).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              example:
                error: { code: 422, message: Недостаточно средств на карте, field: balance, balance: 100 }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/broadcasts:
    post:
      tags: [Рассылки]
      operationId: startBroadcast
      summary: Запустить рассылку
      description: |
        `operation_id` обязателен (ключ идемпотентности). `target`: `"all"`,
        `{"project_id": N}`, `{"serial": "…"}` или `{"serials": ["…"]}`. Ответ `202` —
        рассылка поставлена в очередь; `409` — предыдущая ещё идёт; `429` — исчерпан дневной
        лимит (5 в сутки). Чужие/аннулированные/тестовые карты в `serials` молча исключаются.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [message, target, operation_id]
              properties:
                message:
                  type: string
                  description: Текст рассылки (появляется на экране блокировки).
                target:
                  description: '`"all"`, `{project_id}`, `{serial}` или `{serials[]}`.'
                  oneOf:
                    - type: string
                      enum: [all]
                    - type: object
                      additionalProperties: true
                operation_id:
                  type: string
                  description: Ключ идемпотентности.
            example:
              message: Акция выходного дня!
              target: { project_id: 1 }
              operation_id: promo-2026-07-13
      responses:
        '202':
          description: Рассылка поставлена в очередь.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: integer }
                      queued: { type: integer }
                      status: { type: string }
              example:
                data: { id: 5501, queued: 128, status: sending }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/broadcasts/{id}:
    parameters:
      - $ref: '#/components/parameters/BroadcastIdPath'
    get:
      tags: [Рассылки]
      operationId: getBroadcast
      summary: Статус рассылки
      description: |
        Пока это последняя рассылка организации и она идёт — `status: "sending"` с прогрессом
        `sent`; иначе `completed` (`sent` = `queued`). Чужой/несуществующий id → `404`.
      responses:
        '200':
          description: Статус рассылки.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: object, additionalProperties: true }
              example:
                data:
                  id: 5501
                  message: Акция выходного дня!
                  target: project:1
                  queued: 128
                  sent: 128
                  status: completed
                  created_at: "2026-07-13T14:05:00Z"
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/stats:
    get:
      tags: [Аналитика]
      operationId: getStats
      summary: Сводка организации
      description: |
        Карты (всего/активные/аннулированные), установки в Apple Wallet, выпуск за сегодня и
        30 дней, распределение активных карт по каналам выпуска (`api` — 1С, `admin` — кабинет,
        `form` — анкета).
      responses:
        '200':
          description: Сводка организации.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: object, additionalProperties: true }
              example:
                data:
                  cards: { total: 1240, active: 1180, voided: 60 }
                  installs: { apple: 902 }
                  issued:
                    today: 8
                    last_30_days: 210
                    by_channel:
                      - { channel: api, count: 800 }
                      - { channel: admin, count: 200 }
                      - { channel: form, count: 180 }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/fields:
    get:
      tags: [Поля]
      operationId: getFields
      summary: Получить реестр полей
      description: |
        Возвращает объявленный реестр плюс фактические ключи, встречающиеся в картах, но ещё не
        заведённые (`in_registry: false`). Плоские срезы `keys[]` / `labels{}` — для простой
        интеграции.
      responses:
        '200':
          description: Реестр полей организации.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      fields:
                        type: array
                        items: { $ref: '#/components/schemas/FieldEntry' }
                      keys:
                        type: array
                        items: { type: string }
                      labels:
                        type: object
                        additionalProperties: { type: string }
              example:
                data:
                  fields:
                    - { key: first_name, label: Имя, in_registry: true }
                    - { key: bonuses, label: Баллы, in_registry: true }
                    - { key: tier, label: Tier, in_registry: false }
                  keys: [first_name, bonuses, tier]
                  labels: { first_name: Имя, bonuses: Баллы, tier: Tier }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    put:
      tags: [Поля]
      operationId: putFields
      summary: Обновить подписи полей (upsert)
      description: |
        Аддитивный upsert: новый ключ — создаётся, существующий — переименовывается подпись.
        Ничего не удаляется. Принимает `{fields:[{key,label}]}` или `{labels:{ключ:подпись}}`.
        Любая невалидная запись → `422`, реестр не меняется.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                fields:
                  type: array
                  items: { $ref: '#/components/schemas/FieldEntry' }
                labels:
                  type: object
                  additionalProperties: { type: string }
            example:
              labels: { tier: Уровень, visits: Визитов }
      responses:
        '200':
          description: Реестр обновлён.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: object, additionalProperties: true }
              example:
                data:
                  created: 1
                  updated: 1
                  fields:
                    - { key: first_name, label: Имя }
                    - { key: tier, label: Уровень }
                    - { key: visits, label: Визитов }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/field-manifest:
    post:
      tags: [Поля]
      operationId: postFieldManifest
      summary: Манифест полей коннектора
      description: |
        **Статус метода: НЕ обязательный, но настоятельно РЕКОМЕНДУЕМЫЙ** для разработчиков
        коннекторов.

        Без манифеста интеграция полностью работоспособна: ключи полей обнаруживаются автоматически
        из фактических данных карт (раздел «Обнаружены в данных, но не в списке» на «Настройки →
        Поля»), их можно завести в реестр вручную или пресетом учётной системы.

        С манифестом реестр полей организации наполняется автоматически — с готовыми подписями и
        категориями (клиент/сотрудник), в кабинете видна версия подключённого коннектора, и состав
        полей всегда соответствует версии интеграции. Рекомендация: отправлять манифест один раз при
        каждой смене версии коннектора или состава его полей.

        Коннектор 1С шлёт свою версию и структуру полей при включении/смене версии расширения —
        реестр наполняется аддитивно (та же семантика, что применение пресета: новый ключ создаётся
        с подписью и категорией манифеста, существующий не трогается вовсе). Версия/момент манифеста
        сохраняются НЕЗАВИСИМО от того, добавились ли новые поля, и показываются тихой строкой на
        «Настройки → Интеграции». Любая невалидная запись → `400` со всеми причинами сразу
        (`error.message`, склеены через «; »), реестр не меняется.

        Ключи полей последнего манифеста в кабинете («Настройки → Поля») показывают категорию
        фиксированной (значок замка вместо селектора) — она приходит из манифеста, а не выбирается
        владельцем вручную.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [extension_version, fields]
              properties:
                extension_version:
                  type: string
                  maxLength: 32
                  description: Версия расширения 1С («1.0.1.1»).
                fields:
                  type: array
                  maxItems: 200
                  items: { $ref: '#/components/schemas/FieldManifestEntry' }
            example:
              extension_version: 1.0.1.1
              fields:
                - { key: membership_name, label: Членство }
                - { key: employee_next_shift, label: Следующая смена, category: employee }
      responses:
        '200':
          description: Манифест применён.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created: { type: integer, description: Сколько новых полей заведено. }
                      skipped: { type: integer, description: Сколько ключей уже были в реестре (не тронуты). }
                      extension_version: { type: string }
              example:
                data:
                  created: 2
                  skipped: 1
                  extension_version: 1.0.1.1
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/sync/cards-snapshot:
    post:
      tags: [Сверка с 1С]
      operationId: postSyncCardsSnapshot
      summary: Принять часть полного среза карт 1С (автогашение)
      description: |
        Приём одной части ПОЛНОГО среза живых карт учётной системы. Сверка идёт только по
        `serial_number` (внутренний код карты Loyella) — номер карты 1С (`pass_number`) не
        уникален и для сверки непригоден. Присылайте ВСЕ живые карты организации в каждом
        прогоне, а не только изменившиеся, — решение принимается по отсутствию карты в полном
        срезе. Карты, помеченные на удаление в 1С, передавайте в `deleted_serial_numbers`
        (необязательно; можно не присылать вовсе).

        Один `run_id` (GUID) на прогон; части нумеруются с 1 (`chunk`/`chunks_total`) и могут
        приходить в любом порядке. Открытие нового `run_id` закрывает («superseded») любой
        прошлый незавершённый прогон организации. Когда собраны ВСЕ части — приём последней части
        сразу ФИНАЛИЗИРУЕТ прогон: принимается решение `result.decision` (см. схему
        `SyncSnapshotResult`), а в боевом режиме тенанта тут же аннулируются карты без пары в 1С
        (`enforce_missing` — только те, которых нет в срезе вовсе; `enforce_all` — ещё и
        помеченные на удаление). Дефолт нового тенанта — режим наблюдения (`observe`): ничего не
        гасится, пока владелец не включит боевой режим в кабинете.

        Идемпотентность структурная (без `Idempotency-Key`): повтор той же части не меняет состав
        принятых серийников; повтор ПОСЛЕ финализации отдаёт сохранённый итог (`repeated: true`),
        ничего не пересчитывая. При обрыве связи используйте `GET
        /v1/sync/cards-snapshot/{runId}` — поле `chunks_missing` покажет, какие части дослать.

        Срез могут отклонить целиком (без ошибки HTTP, `result.decision`): `blocked_coverage` —
        срез увидел меньше 95% базы (обрезанная/сломанная выгрузка — отправьте прогон заново с
        новым `run_id`); `blocked_limit` — «сирот» больше разового лимита автоматики (2% базы, не
        меньше 10) — нужно подтверждение владельца в кабинете.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SyncSnapshotChunkRequest' }
            example:
              run_id: 8f1c2e40-9b7a-4a11-8d2f-000000000001
              chunk: 2
              chunks_total: 2
              serial_numbers: [a1b2c3d4-0000-0000-0000-000000000004]
              deleted_serial_numbers: [a1b2c3d4-0000-0000-0000-000000000099]
      responses:
        '200':
          description: >-
            Часть принята. Если это была последняя недостающая часть — прогон финализирован,
            присутствует `result` (иначе `complete: false` и `result` отсутствует).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/SyncSnapshotChunkResponse' }
              example:
                data:
                  run_id: 8f1c2e40-9b7a-4a11-8d2f-000000000001
                  status: complete
                  chunks_total: 2
                  chunks_received: 2
                  chunks_missing: []
                  serials_stored: 5
                  started_at: '2026-07-26T09:00:00.000Z'
                  chunk: 2
                  accepted: 2
                  duplicates: 0
                  repeated: false
                  complete: true
                  result:
                    mode: observe
                    decision: observed
                    base: 1751
                    matched: 1740
                    coverage_ppm: 993718
                    coverage_min_ppm: 950000
                    orphans: 11
                    voided: 0
                    void_limit: 35
                    completed_at: '2026-07-26T09:00:05.000Z'
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/sync/cards-snapshot/{runId}:
    parameters:
      - $ref: '#/components/parameters/RunIdPath'
    get:
      tags: [Сверка с 1С]
      operationId: getSyncCardsSnapshot
      summary: Состояние прогона сверки
      description: |
        Позволяет коннектору после сбоя связи дослать РОВНО недостающие части (`chunks_missing`),
        не гоняя срез заново, и посмотреть решение уже завершённого прогона. `mode` в ответе —
        ТЕКУЩИЙ режим тенанта (может отличаться от `result.mode`, если владелец сменил режим уже
        после завершения прогона). Незавершённый прогон решения не имеет — вместо `result`
        отдаётся `preview` («что было бы, если бы срез собрался прямо сейчас»).
      responses:
        '200':
          description: Состояние прогона (`result` — если завершён, иначе `preview`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/SyncSnapshotStatusResponse' }
              example:
                data:
                  run_id: 8f1c2e40-9b7a-4a11-8d2f-000000000001
                  status: open
                  chunks_total: 2
                  chunks_received: 1
                  chunks_missing: [2]
                  serials_stored: 3
                  started_at: '2026-07-26T09:00:00.000Z'
                  serials_marked_deleted: 0
                  mode: observe
                  preview:
                    base: 1751
                    matched: 3
                    coverage_ppm: 1713
                    orphans: 1748
                    note: Срез не собран целиком — решение по нему не принимается.
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/projects:
    get:
      tags: [Справочники]
      operationId: listProjects
      summary: Список видов карт
      description: |
        Все виды карт организации: внешний номер, название, продукт, настройка штампов, счётчик
        активных карт. Только чтение (создание/переименование видов через API пока не
        поддерживается).

        Формат — бит-в-бит с донором: `data` — ПЛОСКИЙ массив (без обёртки `items`/`total`; учётные
        системы, включая 1С Fitness, перебирают `data` как массив и читают `.id`/`.name` каждого
        элемента — инцидент #325). `id` элемента — номер вида карты (совпадает с `external_id`,
        продублирован для совместимости с донором); его же передают в `project_id` при создании
        карты (`POST /v1/passes/`).
      responses:
        '200':
          description: Список видов карт.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Project' }
              example:
                data:
                  - id: 1
                    external_id: 1
                    name: Карта лояльности
                    product: loyalty
                    stamps: { enabled: true, target: 6, reward_text: Кофе в подарок }
                    active_cards: 1180
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/projects/{id}:
    parameters:
      - $ref: '#/components/parameters/ProjectIdPath'
    get:
      tags: [Справочники]
      operationId: getProject
      summary: Метаданные вида карты
      description: |
        Данные вида + описания полей (`attribute`/`label`/`required`/`type`) из шаблона. `id` в
        ответе — номер вида карты (совпадает с `external_id`, тот же, что в пути `{id}`); его же
        передают в `project_id` при создании карты.
      responses:
        '200':
          description: Метаданные вида карты.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: integer, description: 'Номер вида карты (= external_id); передаётся в project_id при создании карты.' }
                      external_id: { type: integer }
                      name: { type: string }
                      fields:
                        type: array
                        items: { $ref: '#/components/schemas/ProjectFieldMeta' }
              example:
                data:
                  id: 1
                  external_id: 1
                  name: Карта лояльности
                  fields:
                    - { attribute: first_name, label: Имя, required: false, type: string }
                    - { attribute: bonuses, label: Баллы, required: false, type: string }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/numerators:
    get:
      tags: [Справочники]
      operationId: listNumerators
      summary: Список нумераторов
      description: Нумераторы организации (правила генерации номеров карт).
      responses:
        '200':
          description: Список нумераторов.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Numerator' }
              example:
                data:
                  - { id: 1, name: Основной, prefix: CARD-, start_number: 1, format: "%d" }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Справочники]
      operationId: createNumerator
      summary: Создать нумератор
      description: Создаёт новый нумератор организации.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                prefix: { type: string }
                start: { type: integer }
                format: { type: string }
            example: { name: VIP, prefix: VIP-, start: 1000, format: "%d" }
      responses:
        '201':
          description: Нумератор создан.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created: { type: boolean }
                      id: { type: integer }
              example:
                data: { created: true, id: 2 }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/certificates:
    get:
      tags: [Справочники]
      operationId: getCertificates
      summary: Данные сертификата
      description: Идентификаторы сертификата (без секретов) — для совместимости с протоколом 1С Fitness.
      responses:
        '200':
          description: Данные сертификата.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        pass_type_identifier: { type: string }
                        team_identifier: { type: string }
                        organization_name: { type: string }
              example:
                data:
                  - { pass_type_identifier: pass.ru.loyella, team_identifier: XXXXXXXXXX, organization_name: Моя организация }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/webhooks:
    get:
      tags: [Вебхуки]
      operationId: listWebhooks
      summary: Список подписок
      description: Секреты в списке не отдаются (секрет показывается только при создании).
      responses:
        '200':
          description: Список подписок.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Webhook' }
              example:
                data:
                  - id: 7
                    event: null
                    events: [card.*, points.earned]
                    url: https://example.com/hooks/loyella
                    global: true
                    projects: []
                    active: true
                    disabled_at: null
                    created_at: "2026-07-13T14:00:00.000Z"
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Вебхуки]
      operationId: createWebhook
      summary: Создать подписку
      description: |
        Новый контур (рекомендуется): передайте `events[]` — вернётся `secret` (HMAC,
        показывается один раз), доставка надёжная с подписью. `global: false` +
        `projects: [<external_id>...]` ограничивает подписку конкретными видами карт.
        Старый контур 1С — одиночный `event` (`PassCreated`/`PassUpdated`/`PassRegistered`/
        `PassUnregistered`), доставка прежним телом без подписи; `event: "*"` (как шлёт 1С Fitness
        при регистрации) — legacy-wildcard, эквивалентен `"all"` (#329, инцидент W1, 15.07.2026).
        Неизвестное событие или отсутствие `url` → `400`.

        **Payload `PassCreated`** (#329, инцидент W2, 15.07.2026): `ОбработатьВебХук()` в 1С читает
        `data.link`/`data.project_id`/`data.created_via`/`data.fields.*`, поэтому тело доставки
        полное — `{serial_number, pass_number, project_id, link, fields, created_via}`, а не только
        `{serial_number, pass_number}`. `project_id` — ВНЕШНИЙ id вида (инцидент #325).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                name: { type: string }
                url: { type: string, format: uri }
                events:
                  type: array
                  items: { type: string }
                  description: Список подписок (новый контур). Поддерживаются группы (`card.*`) и `*`.
                event:
                  type: string
                  description: Одиночное событие (старый контур 1С).
                global: { type: boolean }
                projects:
                  type: array
                  items: { type: integer }
            example:
              name: CRM sync
              url: https://example.com/hooks/loyella
              events: [card.*, points.earned]
              global: true
      responses:
        '201':
          description: Подписка создана (для нового контура — с одноразовым `secret`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: integer }
                      secret: { type: string }
                      events:
                        type: array
                        items: { type: string }
              example:
                data: { id: 7, secret: whsec_0123abcd..., events: [card.*, points.earned] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/webhooks/catalog:
    get:
      tags: [Вебхуки]
      operationId: getWebhookCatalog
      summary: Справочник событий
      description: Машиночитаемый список подписываемых событий (`name`/`label`/`group`).
      responses:
        '200':
          description: Каталог событий.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/EventCatalogItem' }
              example:
                data:
                  - { name: card.issued, label: Карта выпущена, group: Карты }
                  - { name: points.earned, label: Баллы начислены, group: Лояльность }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/webhooks/{id}:
    parameters:
      - $ref: '#/components/parameters/WebhookIdPath'
    patch:
      tags: [Вебхуки]
      operationId: updateWebhook
      summary: Обновить подписку
      description: 'Аддитивно: `{url?, events?, active?, name?}`. Обновление `events` не меняет секрет.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                url: { type: string, format: uri }
                events:
                  type: array
                  items: { type: string }
                active: { type: boolean }
            example:
              active: true
              events: [card.issued, card.voided]
      responses:
        '200':
          description: Подписка обновлена.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Webhook' }
              example:
                data:
                  id: 7
                  event: null
                  events: [card.issued, card.voided]
                  url: https://example.com/hooks/loyella
                  global: true
                  projects: []
                  active: true
                  disabled_at: null
                  created_at: "2026-07-13T14:00:00.000Z"
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Вебхуки]
      operationId: deleteWebhook
      summary: Удалить подписку
      description: Удаляет подписку организации.
      responses:
        '200':
          description: Подписка удалена.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      deleted: { type: boolean }
              example:
                data: { deleted: true }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/webhooks/{id}/test:
    parameters:
      - $ref: '#/components/parameters/WebhookIdPath'
    post:
      tags: [Вебхуки]
      operationId: testWebhook
      summary: Отправить test.ping
      description: |
        Немедленно отправляет событие `test.ping` на URL подписки (в обход очереди) и
        возвращает ответ приёмника — удобно проверить настройку.
      responses:
        '200':
          description: Результат тест-пинга.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ok: { type: boolean }
                      status_code: { type: integer }
                      error:
                        type: string
                        nullable: true
              example:
                data: { ok: true, status_code: 200, error: null }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/webhooks/{id}/deliveries:
    parameters:
      - $ref: '#/components/parameters/WebhookIdPath'
    get:
      tags: [Вебхуки]
      operationId: getWebhookDeliveries
      summary: Последние доставки
      description: Журнал последних доставок подписки.
      parameters:
        - $ref: '#/components/parameters/LimitQuery'
      responses:
        '200':
          description: Журнал доставок.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/WebhookDelivery' }
              example:
                data:
                  - id: evt_2b7c...
                    event: points.earned
                    status: success
                    attempts: 1
                    last_status_code: 200
                    created_at: "2026-07-13T14:05:00.000Z"
                    delivered_at: "2026-07-13T14:05:01.000Z"
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

components:
  securitySchemes:
    ClientId:
      type: apiKey
      in: header
      name: X-Client-Id
      description: Идентификатор ключа организации (`client-<slug>-<xxxxxxxx>`). Выпускается в кабинете.
    ClientSecret:
      type: apiKey
      in: header
      name: X-Client-Secret
      description: Секрет ключа организации. Показывается один раз при выпуске; хранится bcrypt-хэш.

  parameters:
    SerialPath:
      name: serial
      in: path
      required: true
      schema: { type: string }
      description: Серийный номер (UUID) или номер карты (`pass_number`).
    WebhookIdPath:
      name: id
      in: path
      required: true
      schema: { type: integer }
      description: Идентификатор подписки на вебхук.
    ProjectIdPath:
      name: id
      in: path
      required: true
      schema: { type: integer }
      description: Внешний номер вида карты в вашей организации.
    BroadcastIdPath:
      name: id
      in: path
      required: true
      schema: { type: integer }
      description: Идентификатор рассылки из ответа POST /v1/broadcasts.
    RunIdPath:
      name: runId
      in: path
      required: true
      schema: { type: string }
      description: Тот же `run_id`, что был передан в POST /v1/sync/cards-snapshot.
    LimitQuery:
      name: limit
      in: query
      required: false
      schema:
        type: integer
        default: 50
        minimum: 1
        maximum: 100
      description: Размер страницы (по умолчанию 50, максимум 100).
    OffsetQuery:
      name: offset
      in: query
      required: false
      schema:
        type: integer
        default: 0
        minimum: 0
      description: Смещение для пагинации.
    IdempotencyKeyHeader:
      name: Idempotency-Key
      in: header
      required: false
      schema:
        type: string
        maxLength: 255
      description: |
        Ключ идемпотентности для не-денежных мутаций (принимается и написание
        `Idempotence-Key`). На денежных путях ключом служит поле тела `operation_id`.

  headers:
    RateLimitLimit:
      description: Потолок запросов за окно.
      schema: { type: integer }
    RateLimitRemaining:
      description: Сколько запросов осталось в текущем окне.
      schema: { type: integer }
    RateLimitReset:
      description: Секунд до сброса окна.
      schema: { type: integer }
    RetryAfter:
      description: Секунд до следующей допустимой попытки (при 429).
      schema: { type: integer }
    IdempotentReplay:
      description: '`true`, если ответ реплеен из сохранённого результата идемпотентности.'
      schema: { type: boolean }

  responses:
    Forbidden:
      description: Ошибка аутентификации или организация приостановлена.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error: { code: 403, message: Forbidden }
    NotFound:
      description: Карта / вид / вебхук / рассылка не найдены (или принадлежат другой организации).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error: { code: 404, message: Pass not found }
    BadRequest:
      description: Некорректное тело запроса (например, отсутствует `url` вебхука или неизвестное событие).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error: { message: 'Unknown event(s): card.frobnicated' }
    PaymentRequired:
      description: Недостаточно средств на балансе организации для выпуска карты.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error: { code: 402, message: Недостаточно средств на балансе для выпуска карты }
    Conflict:
      description: Конфликт идемпотентности (тот же ключ с другим телом / запрос ещё в обработке) или рассылка уже выполняется.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error: { code: 409, message: Рассылка уже выполняется для этой организации — дождитесь завершения предыдущей }
    UnprocessableEntity:
      description: Бизнес-ошибка (штампы не настроены, некорректный параметр, недостаточно средств и т.п.).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error: { code: 422, message: Штампы не настроены для этого вида карты }
    RateLimited:
      description: Превышен лимит запросов.
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error: { code: 429, message: Слишком много запросов — попробуйте позже. }

  schemas:
    ErrorEnvelope:
      type: object
      required: [error]
      description: Единый конверт ошибки.
      properties:
        error:
          type: object
          required: [message]
          additionalProperties: true
          properties:
            code:
              type: integer
              description: Обычно дублирует HTTP-статус. У части унаследованных методов 1С может отсутствовать.
            message: { type: string }
            field:
              type: string
              description: Поле, к которому относится ошибка валидации.
            balance:
              type: number
              description: Текущий остаток (для ошибок недостатка средств).
            target:
              type: integer
              description: Целевое значение (например, число штампов вида).
    Pass:
      type: object
      additionalProperties: true
      properties:
        serial_number: { type: string }
        pass_number: { type: string }
        project_id: { type: integer }
        status: { type: string }
        voided: { type: boolean }
        created_via: { type: string }
        fields:
          type: object
          additionalProperties: true
        point_id: { type: integer }
        registrations: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        link: { type: string }
    PassListItem:
      type: object
      additionalProperties: true
      properties:
        serial_number: { type: string }
        pass_number: { type: string }
        project_id: { type: integer }
        status: { type: string }
        voided: { type: boolean }
        first_name: { type: string }
        last_name: { type: string }
        fields:
          type: object
          additionalProperties: true
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        link: { type: string }
    BatchResultItem:
      type: object
      additionalProperties: true
      properties:
        serial_number: { type: string }
        pass_number: { type: string }
        status:
          description: '`"updated"` при успехе либо числовой HTTP-статус ошибки.'
        success: { type: boolean }
        error:
          type: object
          additionalProperties: true
    CardEvent:
      type: object
      additionalProperties: true
      properties:
        id: { type: integer }
        type: { type: string }
        source: { type: string }
        created_at: { type: string, format: date-time }
        details:
          type: object
          additionalProperties: true
    PointsRequest:
      type: object
      required: [amount, operation_id]
      properties:
        amount:
          type: number
          description: Сумма проводки (положительное число; знак задаётся методом credit/debit).
        field:
          type: string
          default: bonuses
          description: Поле-счётчик (латиница/цифры/`_`, 1..64 символа). По умолчанию `bonuses`.
        operation_id:
          type: string
          description: Обязательный ключ идемпотентности проводки.
        comment:
          type: string
          description: Произвольный комментарий к проводке.
        source_doc:
          type: string
          description: Ссылка на документ-основание (например, номер чека).
    PointsResult:
      type: object
      additionalProperties: true
      properties:
        serial_number: { type: string }
        pass_number: { type: string }
        field: { type: string }
        before: { type: number }
        after: { type: number }
        delta: { type: number }
        operation_id: { type: string }
        success: { type: boolean }
    FieldEntry:
      type: object
      required: [key, label]
      additionalProperties: true
      properties:
        key: { type: string }
        label: { type: string }
        in_registry:
          type: boolean
          description: '`false` — ключ встречается в картах, но ещё не заведён в реестре.'
    FieldManifestEntry:
      type: object
      required: [key, label]
      properties:
        key:
          type: string
          pattern: '^[a-zA-Z0-9_]{1,64}$'
        label:
          type: string
          maxLength: 120
        category:
          type: string
          enum: [client, employee]
          description: >-
            Не задана — категория выводится правилом (префикс `employee_` и три легаси-ключа
            сотрудников → `employee`, иначе `client`). Действует только для НОВОГО ключа — уже
            заведённый в реестре ключ манифест не трогает.
    SyncSnapshotChunkRequest:
      type: object
      required: [run_id, chunk, chunks_total, serial_numbers]
      properties:
        run_id:
          type: string
          minLength: 1
          maxLength: 64
          description: Один GUID на весь прогон (новый run_id — на каждый новый прогон).
        chunk:
          type: integer
          minimum: 1
          description: Номер части, 1-based; части можно слать в любом порядке.
        chunks_total:
          type: integer
          minimum: 1
          maximum: 200
          description: Сколько всего частей в этом прогоне.
        serial_numbers:
          type: array
          maxItems: 5000
          items: { type: string, maxLength: 128 }
          description: >-
            ЖИВЫЕ карты 1С в этой части (serial_number Loyella). Поле обязательно (массив может
            быть пустым, если часть целиком состоит из удалённых карт).
        deleted_serial_numbers:
          type: array
          maxItems: 5000
          items: { type: string, maxLength: 128 }
          description: >-
            Опционально: карты, которые в 1С есть, но помечены на удаление. Можно не присылать
            вовсе — трактуется так же, как отсутствие карты в срезе.
        source:
          type: string
          maxLength: 64
          description: 'Опциональная метка источника для журнала, например "1c:ПолнаяСверка".'
    SyncSnapshotResult:
      type: object
      description: Итог прогона (принимается один раз, при финализации; далее отдаётся как сохранённый).
      properties:
        mode:
          type: string
          enum: [observe, enforce_missing, enforce_all]
          description: >-
            Режим тенанта, действовавший НА МОМЕНТ финализации этого прогона. `enforce_missing`
            гасит только карты, которых нет в срезе вовсе; `enforce_all` — ещё и помеченные в 1С
            на удаление. (До 26.07.2026 боевой режим назывался `enforce` и соответствовал
            `enforce_all`; у прогонов тех дней в этом поле может стоять старое значение.)
        decision:
          type: string
          enum: [observed, voided, blocked_coverage, blocked_limit, nothing]
          description: >-
            `nothing` — сирот нет; `observed` — режим наблюдения, сироты посчитаны, не погашены;
            `blocked_coverage` — срез покрыл < 95% базы, гашение отменено целиком; `blocked_limit`
            — боевой режим, но сирот больше разового лимита, нужно подтверждение владельца;
            `voided` — боевой режим, сироты в пределах лимита, аннулированы автоматически.
        orphans_missing:
          type: integer
          nullable: true
          description: >-
            Из сирот — сколько карт не пришло в срезе ВОВСЕ (карточка в 1С удалена физически).
            `null` у прогонов до 26.07.2026: тогда разбивка по природам не сохранялась.
        orphans_deleted:
          type: integer
          nullable: true
          description: Из сирот — сколько карт в 1С есть, но помечены на удаление. `null` — см. выше.
        base:
          type: integer
          description: Сколько карт организации вообще участвовали в сверке (знаменатель покрытия).
        matched:
          type: integer
          description: Сколько из них срез увидел (включая помеченные на удаление).
        coverage_ppm:
          type: integer
          description: Покрытие среза в миллионных долях (1 000 000 = 100%).
        coverage_min_ppm:
          type: integer
          description: Порог правдоподобия среза — константа 950000 (95%).
        orphans:
          type: integer
          description: Карт без пары в 1С (нет в срезе или помечены на удаление).
        voided:
          type: integer
          description: Реально аннулировано карт этим прогоном (0 вне режима voided).
        void_limit:
          type: integer
          description: Потолок автоматического гашения за этот прогон (2% базы, не меньше 10).
        completed_at:
          type: string
          format: date-time
          nullable: true
    SyncSnapshotPreview:
      type: object
      description: Только у незавершённого прогона — «что было бы, если бы срез собрался сейчас».
      properties:
        base: { type: integer }
        matched: { type: integer }
        coverage_ppm: { type: integer }
        orphans: { type: integer }
        note: { type: string }
    SyncSnapshotChunkResponse:
      type: object
      additionalProperties: true
      description: Тело ответа POST /v1/sync/cards-snapshot (в поле `data`).
      properties:
        run_id: { type: string }
        status:
          type: string
          enum: [open, complete, superseded]
        chunks_total: { type: integer }
        chunks_received:
          type: integer
          description: Сколько частей принято всего (не только в этом запросе).
        chunks_missing:
          type: array
          items: { type: integer }
          description: Номера частей, которых ещё не хватает.
        serials_stored:
          type: integer
          description: Сколько серийников сохранено в срезе на данный момент (суммарно по всем частям).
        started_at: { type: string, format: date-time }
        chunk:
          type: integer
          description: Номер части ИЗ ЭТОГО запроса.
        accepted:
          type: integer
          description: Сколько серийников этой части реально записано (без повторов).
        duplicates:
          type: integer
          description: Серийники, уже присутствовавшие в срезе (включая полный повтор части).
        repeated:
          type: boolean
          description: Часть с этим номером уже принималась ранее (или прогон уже был завершён).
        complete:
          type: boolean
          description: 'true — эта часть была последней недостающей, прогон только что финализирован.'
        result:
          allOf: [{ $ref: '#/components/schemas/SyncSnapshotResult' }]
          description: 'Присутствует только когда complete: true.'
    SyncSnapshotStatusResponse:
      type: object
      additionalProperties: true
      description: Тело ответа GET /v1/sync/cards-snapshot/{runId} (в поле `data`).
      properties:
        run_id: { type: string }
        status:
          type: string
          enum: [open, complete, superseded]
        chunks_total: { type: integer }
        chunks_received: { type: integer }
        chunks_missing:
          type: array
          items: { type: integer }
        serials_stored: { type: integer }
        started_at: { type: string, format: date-time }
        serials_marked_deleted:
          type: integer
          description: Сколько серийников в срезе помечены в 1С на удаление.
        mode:
          type: string
          enum: [observe, enforce_missing, enforce_all]
          description: ТЕКУЩИЙ режим тенанта (может отличаться от result.mode для старого прогона).
        result:
          allOf: [{ $ref: '#/components/schemas/SyncSnapshotResult' }]
          description: Присутствует только у завершённого прогона (status = complete).
        preview:
          allOf: [{ $ref: '#/components/schemas/SyncSnapshotPreview' }]
          description: Присутствует только у незавершённого прогона.
    Project:
      type: object
      additionalProperties: true
      properties:
        id: { type: integer, description: 'Номер вида карты (= external_id); передаётся в project_id при создании карты.' }
        external_id: { type: integer }
        name: { type: string }
        description: { type: string }
        product: { type: string }
        stamps:
          type: object
          properties:
            enabled: { type: boolean }
            target: { type: integer }
            reward_text: { type: string }
        active_cards: { type: integer }
    ProjectFieldMeta:
      type: object
      properties:
        attribute: { type: string }
        label: { type: string }
        required: { type: boolean }
        type: { type: string }
    Numerator:
      type: object
      additionalProperties: true
      properties:
        id: { type: integer }
        name: { type: string }
        prefix: { type: string }
        start_number: { type: integer }
        format: { type: string }
    Webhook:
      type: object
      additionalProperties: true
      properties:
        id: { type: integer }
        event:
          type: string
          nullable: true
          description: Одиночное событие старого контура 1С (или `null` для нового контура).
        events:
          type: array
          items: { type: string }
        url: { type: string }
        global: { type: boolean }
        projects:
          type: array
          items: { type: integer }
        active: { type: boolean }
        disabled_at:
          type: string
          format: date-time
          nullable: true
        created_at: { type: string, format: date-time }
    WebhookDelivery:
      type: object
      additionalProperties: true
      properties:
        id: { type: string }
        event: { type: string }
        status: { type: string }
        attempts: { type: integer }
        last_status_code:
          type: integer
          nullable: true
        created_at: { type: string, format: date-time }
        delivered_at:
          type: string
          format: date-time
          nullable: true
    EventCatalogItem:
      type: object
      properties:
        name: { type: string }
        label: { type: string }
        group: { type: string }
