openapi: 3.1.0

info:
  title: Finance OS - Merchant Customer Wallets API
  version: 1.0.0
  description: |
    Контур классических мерчантов Finance OS: вы ведёте на платформе своих
    конечных пользователей (клиентов), выдаёте им крипто-адреса, принимаете
    депозиты, выводите средства на внешние адреса и рассчитываетесь с ними
    внутренними операциями. Расчёты платформы с вами идут через мастер-счёт -
    предоплатный сервисный баланс вашей учётной записи.

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

    Все запросы выполняются с API-ключом в заголовке Authorization (схема
    Bearer). Окружение выбирается ПРЕФИКСОМ ключа: sk_live_ - боевое,
    sk_test_ - песочница. Отдельного заголовка окружения нет. Ключ секретный:
    используйте его только с сервера, никогда не вставляйте в браузерный или
    мобильный код.

    Окружения полностью изолированы: боевой ключ не видит клиентов песочницы
    и наоборот - обращение к чужому окружению даёт 404, а не 403.

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

    - Запросы и ответы - JSON (UTF-8). Успешный ответ - конверт {"data": ...};
      списки дополняются объектом meta.
    - Все денежные суммы - СТРОКИ. Число знаков после точки у разных полей
      разное и на смысл не влияет: разбирайте значение как decimal, а не как
      число с плавающей точкой.
    - Сумма В ЗАПРОСЕ (вывод и его предпросмотр, charge, payout, переводы
      мастер-счёта) принимается только плоской десятичной строкой: без знака
      и экспоненты, до 18 знаков после точки. Значения вида "1e3", "+10",
      "10." отклоняются кодом 422 invalid_request, а не приводятся молча
      к числу.
    - Время - ISO 8601.
    - Ошибка - конверт {"error": {"code", "message", "request_id"}} с машинным
      кодом. Полный реестр кодов - в описании схемы Error.

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

    Заголовок Idempotency-Key ОБЯЗАТЕЛЕН у операций, двигающих деньги: вывод,
    charge, payout, внутренние переводы мастер-счёта. Ключ считается только
    от пары (учётная запись, значение заголовка) - параметры запроса в него
    не входят. Повтор с тем же ключом и теми же параметрами возвращает
    результат первой операции с признаком duplicate=true и кодом 200 вместо
    201; тот же ключ с другими параметрами - 409 conflict и ни копейки
    движения. Повторяя запрос после таймаута, отправляйте ТОТ ЖЕ ключ.

    У AML-проверки заголовок необязателен, но с ним ретрай не создаёт вторую
    платную проверку.

    ## Права ключа

    Чтение доступно любому ключу учётной записи. Операции с деньгами (вывод,
    charge, payout, выдача адреса мастер-счёта, перевод владельцу) требуют
    роли владельца или администратора; пополнение мастер-счёта со счёта
    финансирования - только владельца. Иначе 403 forbidden.

    ## Ограничение частоты запросов

    Корзины лимитов раздельные, поэтому частое чтение не мешает записи:
    чтение кошельков и мастер-счёта - 120 запросов в минуту; выдача адресов,
    симуляция депозита, блокировка клиента, charge и payout - 30 в минуту;
    внутренние переводы мастер-счёта - 20 в минуту; вывод и принудительная
    сверка - 6 в минуту. Изменяющие методы клиентов (создание, PATCH, DELETE,
    consent) - 120 в минуту общей корзиной; блокировка и разблокировка живут
    в корзине записи кошельков (30). Чтение подписок на вебхуки и чтение
    AML-проверок - 120 в минуту; изменение подписок (создание, PATCH, DELETE,
    ротация секрета, тестовое событие) - 30 в минуту общей корзиной; проверка
    адреса - 60 в минуту. Своей корзины нет только у чтения клиентов (список,
    карточка, баланс, история). При превышении приходит 429 с заголовком
    Retry-After.

    ## Порядок интеграции

    1. Получите пару ключей sk_test_ / sk_live_ и подтвердите доступ к сервису
       кошельков.
    2. Подпишите эндпоинт вебхуков и сохраните секрет подписи; проверьте канал
       тестовым событием.
    3. Пополните мастер-счёт: с него платятся услуги платформы и выплаты
       клиентам.
    4. Заведите клиента (телефон в E.164 обязателен) и выдайте ему адрес
       пополнения в нужной сети.
    5. Отрабатывайте события customer.deposit.* и customer.withdrawal.*
       вебхуками, а не опросом.
    6. Перед выводом показывайте пользователю расчёт из
       GET /customers/{uuid}/wallet/withdraw/quote.
    7. Сверяйте расходы и доходы по леджеру мастер-счёта.

servers:
  - url: https://fin-os.io/api/v1
    description: Боевой сервер и песочница (окружение выбирается префиксом ключа)

security:
  - bearerAuth: []

tags:
  - name: Клиенты
    description: Конечные пользователи мерчанта - создание, поиск, блокировка, история.
  - name: Кошельки клиентов
    description: Адреса пополнения, балансы, журнал движений и принудительная сверка.
  - name: Выводы
    description: Предпросмотр, создание заявки на вывод и её статус.
  - name: Расчёты
    description: Внутренние операции между мерчантом и клиентом (charge, payout).
  - name: Мастер-счёт
    description: Предоплатный сервисный баланс мерчанта, его леджер и тариф.
  - name: AML
    description: Проверка блокчейн-адресов и обезличенные отчёты.
  - name: Подписки на вебхуки
    description: Приёмники событий мерчанта - регистрация, правка, секрет, тестовое событие.
  - name: Песочница
    description: Инструменты тестового окружения.

# Исходящие события платформы. Список ПОЛНЫЙ и совпадает со словарём событий
# приёмников: подписаться можно ровно на эти имена, любое другое отклоняется при
# сохранении вебхука. Тело - конверт {id, type, created_at, env, data}; подпись и
# лестница повторов описаны на странице /docs/b2b/webhooks.
webhooks:

  customer.deposit.detected:
    post:
      tags: [Клиенты]
      summary: Поступление обнаружено в сети и зачислено клиенту (0 подтверждений)
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  customer.deposit.confirmed:
    post:
      tags: [Клиенты]
      summary: Сеть подтвердила поступление клиента
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  customer.deposit.frozen:
    post:
      tags: [Клиенты]
      summary: Поступление заморожено по результатам AML-проверки
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  customer.deposit.released:
    post:
      tags: [Клиенты]
      summary: Фриз снят, сумма вернулась в доступный остаток клиента
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  customer.withdrawal.processing:
    post:
      tags: [Клиенты]
      summary: Заявка на вывод принята, средства списаны с клиента
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  customer.withdrawal.sent:
    post:
      tags: [Клиенты]
      summary: Вывод отправлен в сеть, есть tx_hash
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  customer.withdrawal.confirmed:
    post:
      tags: [Клиенты]
      summary: Сеть подтвердила вывод
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  customer.withdrawal.failed:
    post:
      tags: [Клиенты]
      summary: Вывод не выполнен, средства возвращены клиенту
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  customer.withdrawal.blocked:
    post:
      tags: [Клиенты]
      summary: Вывод отклонён комплаенсом по адресу назначения
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  customer.charged:
    post:
      tags: [Клиенты]
      summary: Внутреннее списание с клиента в вашу пользу
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  customer.payout:
    post:
      tags: [Клиенты]
      summary: Внутреннее зачисление клиенту с вашего мастер-счёта
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  customer.created:
    post:
      tags: [Клиенты]
      summary: Клиент заведён
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  customer.kyc.processing:
    post:
      tags: [Клиенты]
      summary: Документы клиента приняты в обработку
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  customer.kyc.verified:
    post:
      tags: [Клиенты]
      summary: Клиент верифицирован
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  customer.kyc.rejected:
    post:
      tags: [Клиенты]
      summary: Верификация клиента отклонена
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  customer.blocked:
    post:
      tags: [Клиенты]
      summary: Клиент заблокирован
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  customer.unblocked:
    post:
      tags: [Клиенты]
      summary: Блокировка клиента снята
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  merchant.balance.credited:
    post:
      tags: [Мастер-счёт]
      summary: Мастер-счёт пополнен
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  merchant.balance.low:
    post:
      tags: [Мастер-счёт]
      summary: Остаток мастер-счёта ниже порога - платные вызовы скоро начнут отвечать 402
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  payment.buy.initiated:
    post:
      tags: [Мастер-счёт]
      summary: Заявка на покупку принята
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  payment.buy.completed:
    post:
      tags: [Мастер-счёт]
      summary: Покупка исполнена
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  payment.buy.failed:
    post:
      tags: [Мастер-счёт]
      summary: Покупка не исполнена
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  payment.sell.completed:
    post:
      tags: [Мастер-счёт]
      summary: Продажа исполнена
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  payment.sell.failed:
    post:
      tags: [Мастер-счёт]
      summary: Продажа не исполнена
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  payment.deposit.detected:
    post:
      tags: [Мастер-счёт]
      summary: Поступление на реквизиты обнаружено
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  payment.deposit.confirmed:
    post:
      tags: [Мастер-счёт]
      summary: Поступление на реквизиты подтверждено
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  payment.withdraw.completed:
    post:
      tags: [Мастер-счёт]
      summary: Вывод с реквизитов исполнен
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  payment.withdraw.failed:
    post:
      tags: [Мастер-счёт]
      summary: Вывод с реквизитов не исполнен
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

  webhook.test:
    post:
      tags: [Песочница]
      summary: Тестовое событие из личного кабинета
      requestBody:
        $ref: '#/components/requestBodies/WebhookEnvelope'
      responses:
        '200':
          description: Событие принято

paths:

  # ─────────────────────────── Клиенты ───────────────────────────

  /customers:
    get:
      tags: [Клиенты]
      operationId: listCustomers
      summary: Список клиентов
      description: |
        Постраничный список клиентов вашей учётной записи в окружении ключа.
        Поиск по телефону через filter[phone] - ТОЧНЫЙ (не подстрока) и
        требует валидный E.164; подстрочный поиск - filter[search].
      parameters:
        - name: filter[phone]
          in: query
          description: Точное совпадение номера в формате E.164.
          schema: { type: string, pattern: '^\+[1-9][0-9]{6,19}$' }
          example: "+79001234567"
        - name: filter[status]
          in: query
          schema: { type: string, enum: [active, blocked] }
        - name: filter[kyc_status]
          in: query
          schema:
            type: string
            enum: [not_started, pending, processing, verified, rejected, expired]
        - name: filter[env]
          in: query
          schema: { type: string, enum: [live, sandbox] }
        - name: filter[search]
          in: query
          description: Подстрока по имени, почте, телефону и external_id.
          schema: { type: string, maxLength: 100 }
        - $ref: '#/components/parameters/PerPage'
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: Список клиентов.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Customer' }
                  links: { $ref: '#/components/schemas/PageLinks' }
                  meta: { $ref: '#/components/schemas/PageMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

    post:
      tags: [Клиенты]
      operationId: createCustomer
      summary: Создать клиента
      description: |
        Создаёт клиента в окружении ключа. Телефон в формате E.164
        ОБЯЗАТЕЛЕН - он идентифицирует человека в контуре и уникален
        в пределах учётной записи и окружения.

        Создание идемпотентно по двум ключам, проверяемым по порядку:
        сначала external_id (если передан), затем phone. Найденный клиент
        возвращается с кодом 200 БЕЗ перезаписи полей из запроса; новый -
        с кодом 201. Различайте случаи по HTTP-коду: набор полей в ответе
        у 201 и 200 ОДИНАКОВЫЙ.

        Лимит - 120 запросов в минуту общей корзиной с PATCH, DELETE
        и consent.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, phone]
              properties:
                name:
                  type: string
                  maxLength: 255
                  examples: ["Иван Петров"]
                phone:
                  type: string
                  pattern: '^\+[1-9][0-9]{6,19}$'
                  description: Телефон в E.164, без пробелов и разделителей.
                  examples: ["+79001234567"]
                email:
                  type: [string, "null"]
                  format: email
                  maxLength: 255
                external_id:
                  type: [string, "null"]
                  maxLength: 100
                  description: Ваш идентификатор пользователя.
                  examples: ["user-42"]
                metadata:
                  type: [object, "null"]
                  description: Произвольные поля интегратора.
            example:
              name: "Иван Петров"
              phone: "+79001234567"
              email: "ivan@example.com"
              external_id: "user-42"
              metadata: { tier: "gold" }
      responses:
        '201':
          description: Клиент создан.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Customer' }
        '200':
          description: Клиент с таким external_id или телефоном уже существует - возвращён он.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Customer' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /customers/{uuid}:
    parameters:
      - $ref: '#/components/parameters/CustomerUuid'
    get:
      tags: [Клиенты]
      operationId: getCustomer
      summary: Карточка клиента
      responses:
        '200':
          description: Карточка клиента.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Customer' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

    patch:
      tags: [Клиенты]
      operationId: updateCustomer
      summary: Обновить клиента
      description: |
        Меняет только перечисленные поля. Телефон здесь необязателен и
        допускает null (очистку), поэтому round-trip GET -> PATCH тем же
        телом безопасен. Поля kyc_*, env, status и consent_* этим методом
        не меняются.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 255 }
                email: { type: [string, "null"], format: email, maxLength: 255 }
                phone: { type: [string, "null"], pattern: '^\+[1-9][0-9]{6,19}$' }
                external_id: { type: [string, "null"], maxLength: 100 }
                metadata: { type: [object, "null"] }
            example:
              name: "Иван Петров"
              metadata: { tier: "platinum" }
      responses:
        '200':
          description: Клиент обновлён.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Customer' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Номер телефона уже занят другим вашим клиентом.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: "duplicate_phone"
                  message: "Этот номер телефона уже занят другим клиентом."
                  request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
        '422': { $ref: '#/components/responses/ValidationFailed' }

    delete:
      tags: [Клиенты]
      operationId: deleteCustomer
      summary: Удалить клиента
      description: |
        Мягкое удаление: запись сохраняется для целей комплаенса. Удаление
        отклоняется, если клиент заблокирован ПЛАТФОРМОЙ (комплаенс-офицером
        либо автоматически по санкционному совпадению), если на его счетах
        есть остатки (в том числе замороженные) либо если ему выдавались
        крипто-адреса или по нему есть история движений.

        Ваша собственная блокировка (metadata.block_source = merchant)
        удалению не мешает: заблокированного вами клиента без остатков,
        адресов и истории DELETE удалит.
      responses:
        '204':
          description: Клиент удалён.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Удаление запрещено текущим состоянием клиента.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                customer_has_funds:
                  summary: На счетах есть остатки
                  value:
                    error:
                      code: "customer_has_funds"
                      message: "У клиента есть остатки на счетах - удаление запрещено."
                      request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
                customer_has_wallets:
                  summary: Есть адреса или история движений
                  value:
                    error:
                      code: "customer_has_wallets"
                      message: "У клиента есть крипто-адреса или история движений - удаление запрещено."
                      request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
                customer_blocked:
                  summary: >-
                    Блокировка наложена платформой (комплаенс-офицер или
                    санкции); собственная блокировка мерчанта удалению
                    не мешает
                  value:
                    error:
                      code: "customer_blocked"
                      message: "Клиент заблокирован - операции по нему запрещены."
                      request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"

  /customers/{uuid}/block:
    parameters:
      - $ref: '#/components/parameters/CustomerUuid'
    post:
      tags: [Клиенты]
      operationId: blockCustomer
      summary: Заблокировать клиента
      description: |
        Запрещает по клиенту вывод, charge и payout. Уходит вебхук
        customer.blocked. Повторная блокировка уже заблокированного клиента
        ошибкой не считается. Лимит - 30 запросов в минуту.

        Удалению собственная блокировка мерчанта не мешает: DELETE по
        заблокированному вами клиенту пройдёт, если у него нет остатков,
        адресов и истории. Удаление запрещает только блокировка со стороны
        платформы - комплаенс-офицера или санкционная.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: [string, "null"]
                  maxLength: 255
                  examples: ["Подозрительная активность"]
      responses:
        '200':
          description: Клиент заблокирован.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Customer' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /customers/{uuid}/unblock:
    parameters:
      - $ref: '#/components/parameters/CustomerUuid'
    post:
      tags: [Клиенты]
      operationId: unblockCustomer
      summary: Снять блокировку
      description: |
        Снимает ТОЛЬКО блокировку, наложенную вами (metadata.block_source =
        merchant). Блокировку комплаенс-офицера и санкционный автоблок этим
        вызовом снять нельзя - придёт 409. Уходит вебхук customer.unblocked.
      responses:
        '200':
          description: Блокировка снята.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Customer' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Блокировка снимается только комплаенс-офицером платформы.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: "conflict"
                  message: "Конфликт состояния операции."
                  request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"

  /customers/{uuid}/consent:
    parameters:
      - $ref: '#/components/parameters/CustomerUuid'
    post:
      tags: [Клиенты]
      operationId: recordCustomerConsent
      summary: Зафиксировать согласие клиента
      description: |
        Подтверждает, что клиент дал согласие на обработку данных. Обязательно
        перед операциями верификации. Сохраняет IP и отметку времени.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ip]
              properties:
                ip:
                  type: string
                  description: IP клиента в момент согласия (IPv4 или IPv6).
                  examples: ["203.0.113.42"]
      responses:
        '200':
          description: Согласие зафиксировано.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Customer' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /customers/{uuid}/balance:
    parameters:
      - $ref: '#/components/parameters/CustomerUuid'
    get:
      tags: [Клиенты]
      operationId: getCustomerBalance
      summary: Балансы клиента
      description: |
        В data - торговые остатки клиента, в meta.crypto - крипто-балансы по
        каждой доступной монете (то же, что отдаёт GET /customers/{uuid}/wallet).
      responses:
        '200':
          description: Балансы клиента.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/LedgerBalance' }
                  meta:
                    type: object
                    properties:
                      crypto:
                        type: array
                        items: { $ref: '#/components/schemas/CryptoBalance' }
                      status: { type: string, enum: [active, blocked] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /customers/{uuid}/transactions:
    parameters:
      - $ref: '#/components/parameters/CustomerUuid'
    get:
      tags: [Клиенты]
      operationId: listCustomerTransactions
      summary: Единая история операций клиента
      description: |
        Крипто-движения и торговые операции в одной ленте, от новых к старым.
        Пагинация КУРСОРНАЯ: номера страниц не поддерживаются. Передавайте
        starting_after из meta.next_cursor, пока meta.has_more = true.
        Неизвестный или устаревший курсор не ошибка - вернётся первая страница.
      parameters:
        - $ref: '#/components/parameters/PerPageCursor'
        - $ref: '#/components/parameters/StartingAfter'
      responses:
        '200':
          description: Лента операций.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items:
                      oneOf:
                        - $ref: '#/components/schemas/CryptoHistoryEntry'
                        - $ref: '#/components/schemas/TradingHistoryEntry'
                  meta: { $ref: '#/components/schemas/CursorMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  # ─────────────────────── Кошельки клиентов ───────────────────────

  /customers/{uuid}/wallet/addresses:
    parameters:
      - $ref: '#/components/parameters/CustomerUuid'
    post:
      tags: [Кошельки клиентов]
      operationId: createCustomerWalletAddress
      summary: Выдать адрес пополнения
      description: |
        Идемпотентно: повторный вызов с той же сетью возвращает тот же адрес.
        Адрес закреплён за клиентом навсегда. В песочнице возвращается
        синтетический адрес с префиксом SBX и признаком sandbox=true - реальные
        средства на него отправлять нельзя. Лимит - 30 запросов в минуту.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [chain]
              properties:
                chain:
                  $ref: '#/components/schemas/Network'
            example:
              chain: "TRON"
      responses:
        '201':
          description: Адрес выдан (или возвращён существующий).
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/WalletAddress' }
              example:
                data:
                  network: "TRON"
                  address: "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE"
                  sandbox: null
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Кошельки клиентов недоступны вашей учётной записи.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: "wallets_disabled"
                  message: "Крипто-кошельки клиентов сейчас недоступны."
                  request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: Сеть недоступна вашей учётной записи либо не прошла валидацию.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: "coin_not_allowed"
                  message: "Эта монета недоступна для вашей учётной записи."
                  request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
        '429': { $ref: '#/components/responses/RateLimited' }

    get:
      tags: [Кошельки клиентов]
      operationId: listCustomerWalletAddresses
      summary: Все адреса клиента
      responses:
        '200':
          description: Список адресов, отсортированный по сети.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/WalletAddress' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /customers/{uuid}/wallet:
    parameters:
      - $ref: '#/components/parameters/CustomerUuid'
    get:
      tags: [Кошельки клиентов]
      operationId: getCustomerWallet
      summary: Крипто-балансы клиента
      description: |
        Строка на каждую доступную вам монету, включая нулевые. available -
        доступный остаток, locked - зарезервированное: заморозка комплаенса
        либо средства под принятой заявкой на вывод.
      responses:
        '200':
          description: Балансы по монетам.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/CryptoBalance' }
                  meta:
                    type: object
                    properties:
                      customer_status: { type: string, enum: [active, blocked] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /customers/{uuid}/wallet/transactions:
    parameters:
      - $ref: '#/components/parameters/CustomerUuid'
    get:
      tags: [Кошельки клиентов]
      operationId: listCustomerWalletTransactions
      summary: Журнал крипто-движений
      parameters:
        - name: coin
          in: query
          description: Фильтр по тикеру монеты.
          schema: { type: string, maxLength: 16 }
          example: "USDT"
        - name: direction
          in: query
          schema: { type: string, enum: [in, out] }
        - name: status
          in: query
          schema: { type: string, enum: [pending, confirmed, failed] }
        - $ref: '#/components/parameters/PerPageCursor'
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: Постраничный журнал движений.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/WalletTransaction' }
                  meta: { $ref: '#/components/schemas/SimplePageMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /customers/{uuid}/wallet/sync:
    parameters:
      - $ref: '#/components/parameters/CustomerUuid'
    post:
      tags: [Кошельки клиентов]
      operationId: syncCustomerWallet
      summary: Принудительная сверка поступлений
      description: |
        Аварийный инструмент на случай «клиент отправил, а депозита не видно».
        Обходит адреса клиента и проводит незачисленные поступления обычным
        порядком. Покрывает сеть TRON и UTXO-сети; в EVM-сетях поступления
        приходят по подписке и в сверке не нуждаются. В песочнице всегда
        возвращает new_deposits=0. Лимит - 6 запросов в минуту.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                chain:
                  $ref: '#/components/schemas/Network'
      responses:
        '200':
          description: Сверка выполнена.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    properties:
                      network: { type: [string, "null"], examples: ["TRON"] }
                      new_deposits:
                        type: integer
                        description: Сколько поступлений зачислено этим вызовом.
                        examples: [1]
                      synced_at: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ─────────────────────────── Выводы ───────────────────────────

  /customers/{uuid}/wallet/withdraw/quote:
    parameters:
      - $ref: '#/components/parameters/CustomerUuid'
    get:
      tags: [Выводы]
      operationId: quoteCustomerWithdraw
      summary: Предпросмотр вывода
      description: |
        Денег не двигает и заявку не создаёт - можно вызывать из формы на
        каждое изменение суммы. Проверки и их порядок те же, что у боевого
        вызова, поэтому расхождений между предпросмотром и созданием заявки
        не бывает. Комплаенс-проверку адреса назначения предпросмотр НЕ
        выполняет: поле aml_precheck всегда false, вердикт приходит в момент
        создания заявки.
      parameters:
        - name: coin
          in: query
          required: true
          schema: { type: string, maxLength: 16 }
          example: "USDT"
        - name: network
          in: query
          description: Обязательна, если тикер существует в нескольких сетях.
          schema: { type: string, maxLength: 32 }
          example: "TRON"
        - name: address
          in: query
          required: true
          schema: { type: string, maxLength: 128 }
          example: "TWd4WrZ9wn84f5x1hZhL4DHvk738ns5jwb"
        - name: amount
          in: query
          required: true
          description: >-
            Сумма к отправке без комиссий. Плоская десятичная строка без знака
            и экспоненты, до 18 знаков после точки: "1e3", "+10", "10." дают
            422.
          schema: { type: string, maxLength: 40, pattern: '^\d+(\.\d{1,18})?$' }
          example: "100"
      responses:
        '200':
          description: |
            Расчёт вывода. Поле allowed говорит, пройдут ли проверки прямо
            сейчас; при allowed=false в reason лежит код отказа, имя не
            пройденной проверки и текст для человека.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/WithdrawQuote' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /customers/{uuid}/wallet/withdraw:
    parameters:
      - $ref: '#/components/parameters/CustomerUuid'
    post:
      tags: [Выводы]
      operationId: createCustomerWithdraw
      summary: Вывести средства клиента на внешний адрес
      description: |
        Двигает реальные деньги. Заголовок Idempotency-Key обязателен.
        Ответ приходит сразу, отправка в сеть идёт фоном: следите за стадией
        по полю state и вебхукам customer.withdrawal.*. Терминальные стадии -
        confirmed и failed.

        Клиент платит amount + fee_platform + fee_merchant; адрес назначения
        получает ровно amount. Для UTXO-сетей сеть удерживает свою плату из
        отправляемой суммы (признак network_fee_from_amount в предпросмотре).
        Сетевые издержки токенов покрывает платформа - комиссия фиксированная.

        Требует роль владельца или администратора. Лимит - 6 запросов в минуту.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [coin, address, amount]
              properties:
                coin: { type: string, maxLength: 16, examples: ["USDT"] }
                network: { type: [string, "null"], maxLength: 32, examples: ["TRON"] }
                address: { type: string, maxLength: 128, examples: ["TWd4WrZ9wn84f5x1hZhL4DHvk738ns5jwb"] }
                amount:
                  type: string
                  maxLength: 40
                  pattern: '^\d+(\.\d{1,18})?$'
                  description: >-
                    Плоская десятичная строка без знака и экспоненты, до 18
                    знаков после точки: "1e3", "+10", "10." дают 422.
                  examples: ["100"]
            example:
              coin: "USDT"
              network: "TRON"
              address: "TWd4WrZ9wn84f5x1hZhL4DHvk738ns5jwb"
              amount: "100"
      responses:
        '201':
          description: Заявка принята.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/WithdrawTransaction' }
        '200':
          description: Повтор по уже использованному Idempotency-Key - та же заявка, денег не двигали.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/WithdrawTransaction' }
        '400': { $ref: '#/components/responses/IdempotencyKeyRequired' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: На счёте клиента не хватает средств с учётом комиссий.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: "insufficient_funds"
                  message: "Недостаточно средств для операции."
                  request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
        '403':
          description: Вывод недоступен учётной записи либо у ключа нет прав на денежные операции.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                withdraw_disabled:
                  value:
                    error:
                      code: "withdraw_disabled"
                      message: "Вывод средств клиентов сейчас недоступен."
                      request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
                forbidden:
                  value:
                    error:
                      code: "forbidden"
                      message: "Доступ запрещён."
                      request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Клиент заблокирован, идёт комплаенс-проверка поступления либо ключ идемпотентности переиспользован с другими параметрами.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                customer_blocked:
                  value:
                    error:
                      code: "customer_blocked"
                      message: "Клиент заблокирован - операции по нему запрещены."
                      request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
                aml_screening_in_progress:
                  value:
                    error:
                      code: "aml_screening_in_progress"
                      message: "По клиенту идёт комплаенс-проверка поступления. Повторите позже."
                      request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
        '422':
          description: Не пройдена одна из проверок вывода.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                amount_below_minimum:
                  value:
                    error:
                      code: "amount_below_minimum"
                      message: "Сумма меньше минимальной для этой монеты."
                      request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
                invalid_address:
                  value:
                    error:
                      code: "invalid_address"
                      message: "Некорректный адрес получателя для указанной сети."
                      request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
                withdraw_limit_exceeded:
                  value:
                    error:
                      code: "withdraw_limit_exceeded"
                      message: "Превышен лимит вывода."
                      request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
                aml_blocked:
                  value:
                    error:
                      code: "aml_blocked"
                      message: "Операция отклонена комплаенс-проверкой."
                      request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
                coin_not_allowed:
                  summary: Монета недоступна либо адрес принадлежит платформе
                  value:
                    error:
                      code: "coin_not_allowed"
                      message: "Эта монета недоступна для вашей учётной записи."
                      request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /customers/{uuid}/wallet/withdrawals/{withdrawalId}:
    parameters:
      - $ref: '#/components/parameters/CustomerUuid'
      - name: withdrawalId
        in: path
        required: true
        description: Значение data.id из ответа на создание заявки.
        schema: { type: string, format: uuid }
    get:
      tags: [Выводы]
      operationId: getCustomerWithdrawal
      summary: Статус заявки на вывод
      responses:
        '200':
          description: Текущее состояние заявки.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/WithdrawTransaction' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  # ─────────────────────────── Расчёты ───────────────────────────

  /customers/{uuid}/wallet/charge:
    parameters:
      - $ref: '#/components/parameters/CustomerUuid'
    post:
      tags: [Расчёты]
      operationId: chargeCustomer
      summary: Списать с клиента в пользу мерчанта
      description: |
        Мгновенная внутренняя операция без блокчейна и без сетевых комиссий:
        сумма списывается с клиента и зачисляется на ваш мастер-счёт.
        Idempotency-Key обязателен. Требует роль владельца или администратора.
        Лимит - 30 запросов в минуту.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SettlementRequest' }
            example:
              coin: "USDT"
              network: "TRON"
              amount: "12.5"
              reason: "Абонентская плата за август"
      responses:
        '201':
          description: Операция проведена.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/SettlementTransaction' }
        '200':
          description: Повтор по уже использованному Idempotency-Key - тот же результат, денег не двигали.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/SettlementTransaction' }
        '400': { $ref: '#/components/responses/IdempotencyKeyRequired' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: На счёте клиента недостаточно средств.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: "insufficient_funds"
                  message: "Недостаточно средств для операции."
                  request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/SettlementConflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /customers/{uuid}/wallet/payout:
    parameters:
      - $ref: '#/components/parameters/CustomerUuid'
    post:
      tags: [Расчёты]
      operationId: payoutCustomer
      summary: Выплатить клиенту с мастер-счёта
      description: |
        Обратная к charge операция: сумма списывается с вашего мастер-счёта
        и зачисляется клиенту. Idempotency-Key обязателен. Требует роль
        владельца или администратора. Лимит - 30 запросов в минуту.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SettlementRequest' }
            example:
              coin: "USDT"
              network: "TRON"
              amount: "50"
              reason: "Возврат по заказу 84213"
      responses:
        '201':
          description: Операция проведена.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/SettlementTransaction' }
        '200':
          description: Повтор по уже использованному Idempotency-Key.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/SettlementTransaction' }
        '400': { $ref: '#/components/responses/IdempotencyKeyRequired' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/ServiceBalanceInsufficient' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/SettlementConflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  # ───────────────────────── Мастер-счёт ─────────────────────────

  /merchant/balance:
    get:
      tags: [Мастер-счёт]
      operationId: getMerchantBalance
      summary: Баланс мастер-счёта
      description: |
        Строка на каждую валюту счёта. Расчётная строка USDT присутствует
        всегда, даже нулевая: именно с неё списываются услуги платформы.
        В песочнице ответ синтетический и содержит признак sandbox=true.
      responses:
        '200':
          description: Строки валют мастер-счёта.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/LedgerBalance' }
                  sandbox:
                    type: boolean
                    description: Присутствует только в ответах песочницы.
              example:
                data:
                  - { currency: "USDT", available: "312.5000000000", locked: "0.0000000000" }
                  - { currency: "USDC_BSC", available: "40.0000000000", locked: "0.0000000000" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /merchant/balance/transactions:
    get:
      tags: [Мастер-счёт]
      operationId: listMerchantBalanceTransactions
      summary: Леджер мастер-счёта
      description: |
        Движения от новых к старым. Пагинация КУРСОРНАЯ: передавайте
        starting_after из meta.next_cursor, пока meta.has_more = true.
        Знак операции несёт поле direction, amount всегда положительна.
      parameters:
        - name: type
          in: query
          description: Фильтр по типу операции. Неизвестное значение - 422.
          schema: { $ref: '#/components/schemas/BalanceTxType' }
        - name: currency
          in: query
          schema: { type: string, maxLength: 16 }
          example: "USDT"
        - name: from
          in: query
          description: Начало периода включительно (с начала суток).
          schema: { type: string, format: date }
          example: "2026-08-01"
        - name: to
          in: query
          description: Конец периода включительно (до конца суток).
          schema: { type: string, format: date }
          example: "2026-08-31"
        - $ref: '#/components/parameters/PerPageCursor'
        - $ref: '#/components/parameters/StartingAfter'
      responses:
        '200':
          description: Записи леджера.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/BalanceTransaction' }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
                  sandbox:
                    type: boolean
                    description: Присутствует только в ответах песочницы.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /merchant/balance/deposit-address:
    post:
      tags: [Мастер-счёт]
      operationId: createMerchantDepositAddress
      summary: Адрес пополнения мастер-счёта
      description: |
        Идемпотентно: повторный вызов с той же сетью возвращает тот же адрес.
        Допустимы только сети, в которых вам доступны стейблкоины. Адрес
        принимает стейблкоины именно этой сети; перевод в другой сети или
        неподдерживаемый токен не зачисляется. Требует роль владельца или
        администратора. Лимит - 30 запросов в минуту.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [chain]
              properties:
                chain:
                  $ref: '#/components/schemas/StableNetwork'
            example:
              chain: "TRON"
      responses:
        '201':
          description: Адрес выдан.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    properties:
                      network: { type: string, examples: ["TRON"] }
                      address: { type: string, examples: ["TJmVfKq3Zx8Yb1nQpR6cW4hS2dT7uL0aE9"] }
                  sandbox:
                    type: boolean
                    description: Присутствует только в ответах песочницы.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422':
          description: Сеть недоступна вашей учётной записи.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: "coin_not_allowed"
                  message: "Эта монета недоступна для вашей учётной записи."
                  request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /merchant/balance/topup-from-treasury:
    post:
      tags: [Мастер-счёт]
      operationId: topupMerchantBalance
      summary: Пополнить мастер-счёт со счёта финансирования владельца
      description: |
        Мгновенный внутренний перевод без блокчейна. Доступен ТОЛЬКО владельцу
        учётной записи: списываются его личные средства. Зачисление всегда идёт
        в строку USDT - поле currency на пополнении игнорируется.
        Idempotency-Key обязателен. Лимит - 20 запросов в минуту.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount:
                  type: string
                  pattern: '^\d+(\.\d{1,18})?$'
                  description: >-
                    Сумма перевода, больше нуля. Плоская десятичная строка без
                    знака и экспоненты, до 18 знаков после точки: "1e3", "+10",
                    "10." дают 422.
                  examples: ["100"]
                chain:
                  $ref: '#/components/schemas/StableNetwork'
                currency:
                  type: [string, "null"]
                  description: На пополнении игнорируется.
            example:
              amount: "100"
              chain: "TRON"
      responses:
        '200':
          description: Перевод проведён (или возвращён результат первой проводки по этому ключу).
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/InternalTransfer' }
                  sandbox:
                    type: boolean
                    description: Присутствует только в ответах песочницы.
        '400': { $ref: '#/components/responses/IdempotencyKeyRequired' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: На счёте финансирования владельца недостаточно средств.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: "insufficient_funds"
                  message: "Недостаточно средств для операции."
                  request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/SettlementConflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /merchant/balance/transfer-to-treasury:
    post:
      tags: [Мастер-счёт]
      operationId: transferMerchantBalanceToTreasury
      summary: Перевести средства мастер-счёта владельцу
      description: |
        Обратная к пополнению операция: деньги уходят на счёт финансирования
        владельца учётной записи. Он-чейн вывода с мастер-счёта нет. Выйти
        можно любой валютой строки счёта; сеть нужна только агрегированной
        строке USDT. Требует роль владельца или администратора.
        Idempotency-Key обязателен. Лимит - 20 запросов в минуту.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount:
                  type: string
                  pattern: '^\d+(\.\d{1,18})?$'
                  description: >-
                    Сумма перевода, больше нуля. Плоская десятичная строка без
                    знака и экспоненты, до 18 знаков после точки: "1e3", "+10",
                    "10." дают 422.
                  examples: ["50"]
                currency:
                  type: string
                  default: USDT
                  description: Валюта строки мастер-счёта.
                  examples: ["USDT"]
                chain:
                  $ref: '#/components/schemas/StableNetwork'
            example:
              amount: "50"
              currency: "USDT"
              chain: "TRON"
      responses:
        '200':
          description: Перевод проведён (или возвращён результат первой проводки по этому ключу).
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/InternalTransfer' }
                  sandbox:
                    type: boolean
                    description: Присутствует только в ответах песочницы.
        '400': { $ref: '#/components/responses/IdempotencyKeyRequired' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/ServiceBalanceInsufficient' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/SettlementConflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /merchant/service-pricing:
    get:
      tags: [Мастер-счёт]
      operationId: getServicePricing
      summary: Действующие цены, комиссии и лимиты
      description: |
        Витрина условий вашей учётной записи: цена платного вызова, комиссии
        вывода по каждой доступной монете с минимальными суммами, лимиты
        выводов, ваша надбавка по каждой монете и порог предупреждения о
        низком остатке. Значения актуальны на момент запроса и одинаковы
        в обоих окружениях.
      responses:
        '200':
          description: Действующий тариф.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/ServicePricing' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ─────────────────────────────  AML  ─────────────────────────────

  /aml/checks:
    get:
      tags: [AML]
      operationId: listAmlChecks
      summary: Последние проверки
      description: |
        До 100 последних проверок вашей учётной записи в текущем окружении,
        новые первыми. Пагинации нет. Чтение бесплатно. Лимит - 120 запросов
        в минуту.
      responses:
        '200':
          description: Список проверок.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/AmlCheck' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

    post:
      tags: [AML]
      operationId: createAmlCheck
      summary: Проверить блокчейн-адрес
      description: |
        Синхронная проверка: один запрос выполняет оценку и возвращает полный
        обезличенный отчёт. Боевой вызов платный - стоимость списывается
        с мастер-счёта до оценки; если вердикт не получен, сумма возвращается
        автоматически. В песочнице проверка бесплатна и детерминирована
        по самому адресу.

        Заголовок Idempotency-Key необязателен, но с ним ретрай по таймауту
        возвращает ТУ ЖЕ проверку и не списывает деньги второй раз.

        На холодном адресе оценка может занимать несколько секунд - задавайте
        щедрый таймаут HTTP-клиента и вызывайте проверку вне горячего пути.
        Лимит - 60 запросов в минуту.

        Уровень риска и рекомендацию считает платформа: пороги перевода
        risk_score в risk_level настраиваются и могут меняться, поэтому
        опирайтесь на поля risk_level и recommendation, а не на сравнение
        risk_score с собственными константами. В песочнице скоринг
        синтетический - он проверяет формат ответа, но не совпадает
        с боевыми значениями.
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          description: Ключ повтора, до 80 символов. Действует в пределах учётной записи и окружения.
          schema: { type: string, maxLength: 80 }
          example: "check-order-1042"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [address]
              properties:
                address:
                  type: string
                  maxLength: 120
                  examples: ["TXYZ1234567890abcdefGHIJKLmnop"]
                network:
                  type: [string, "null"]
                  maxLength: 20
                  description: >-
                    Сеть адреса в нижнем регистре: tron, ethereum, bitcoin,
                    litecoin и т.д. Без параметра сеть определяется по формату
                    адреса; для адресов, валидных в нескольких сетях, указывайте
                    её явно.
                  examples: ["tron"]
            example:
              address: "TXYZ1234567890abcdefGHIJKLmnop"
              network: "tron"
      responses:
        '201':
          description: Проверка выполнена.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/AmlCheck' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/ServiceBalanceInsufficient' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422':
          description: |
            Некорректные параметры либо не удалось определить сеть по формату
            адреса - в этом случае объект fields содержит ключ network.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: "invalid_request"
                  message: "Некорректные данные запроса."
                  request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
                  fields:
                    network: ["Не удалось определить сеть адреса - укажите параметр network."]
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /aml/checks/{uuid}:
    parameters:
      - name: uuid
        in: path
        required: true
        description: Идентификатор проверки из ответа создания.
        schema: { type: string, format: uuid }
    get:
      tags: [AML]
      operationId: getAmlCheck
      summary: Сохранённая проверка
      description: |
        Возвращает сохранённый снимок отчёта. Повторная оценка не выполняется,
        деньги не списываются.
      responses:
        '200':
          description: Проверка.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/AmlCheck' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /aml/checks/{uuid}/report.pdf:
    parameters:
      - name: uuid
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [AML]
      operationId: downloadAmlReport
      summary: Отчёт в PDF
      description: |
        Тот же обезличенный отчёт в печатной форме, готовый для комплаенс-архива.
        Требует тот же Bearer-ключ; ссылка не публичная. Выгрузка бесплатна.
      responses:
        '200':
          description: "PDF-файл (заголовок Content-Disposition: attachment)."
          content:
            application/pdf:
              schema: { type: string, format: binary }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ──────────────────── Подписки на вебхуки ────────────────────

  /webhooks:
    get:
      tags: [Подписки на вебхуки]
      operationId: listWebhookEndpoints
      summary: Приёмники событий
      description: |
        Приёмники текущего окружения, новые первыми. Окружение выбирает
        префикс ключа: sk_test_ видит только sandbox-приёмники, sk_live_ -
        только боевые. Секрет подписи не возвращается никогда - в ответе
        только его последние символы (secret_last4). Лимит - 120 запросов
        в минуту.
      responses:
        '200':
          description: Список приёмников.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/WebhookEndpoint' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

    post:
      tags: [Подписки на вебхуки]
      operationId: createWebhookEndpoint
      summary: Зарегистрировать приёмник
      description: |
        Создаёт приёмник в окружении ключа и выдаёт секрет подписи. Секрет
        показывается ОДИН раз - сохраните его сразу: платформа хранит только
        хеш, и восстановить значение нельзя, можно лишь выпустить новое
        через rotate-secret.

        Адрес обязан быть https. Имена событий перечисляются точно, маски
        вида customer.* не поддерживаются; неизвестное имя отклоняется
        валидацией. Лимит - 30 запросов в минуту общей корзиной со всеми
        изменяющими вызовами подписок.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url:
                  type: string
                  format: uri
                  maxLength: 500
                  description: HTTPS-адрес приёмника. http:// отклоняется.
                  examples: ["https://your-app.com/webhooks/finos"]
                events:
                  type: array
                  minItems: 1
                  description: Непустой список имён событий; повторы схлопываются.
                  items: { $ref: '#/components/schemas/WebhookEventName' }
            example:
              url: "https://your-app.com/webhooks/finos"
              events:
                - customer.deposit.confirmed
                - customer.withdrawal.confirmed
                - merchant.balance.low
      responses:
        '201':
          description: Приёмник создан, секрет выдан один раз.
          content:
            application/json:
              schema:
                type: object
                required: [data, secret]
                properties:
                  data: { $ref: '#/components/schemas/WebhookEndpoint' }
                  secret:
                    type: string
                    description: >-
                      Секрет подписи, показывается ТОЛЬКО в этом ответе.
                      Им проверяется заголовок X-FinOs-Signature.
                    examples: ["whsec_ВАШ_СЕКРЕТ"]
                  notice: { type: string }
              example:
                data:
                  id: 42
                  url: "https://your-app.com/webhooks/finos"
                  events: ["customer.deposit.confirmed", "customer.withdrawal.confirmed", "merchant.balance.low"]
                  env: "live"
                  active: true
                  secret_last4: "wxyz"
                  delivery_count: 0
                  failure_count: 0
                  last_delivery_at: null
                  last_success_at: null
                  last_failure_at: null
                  created_at: "2026-08-17T11:23:00.000000Z"
                secret: "whsec_ВАШ_СЕКРЕТ"
                notice: "Save this secret now - it will not be shown again."
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/{id}:
    parameters:
      - $ref: '#/components/parameters/WebhookId'
    patch:
      tags: [Подписки на вебхуки]
      operationId: updateWebhookEndpoint
      summary: Изменить приёмник
      description: |
        Меняет только переданные поля. Список events заменяется целиком,
        а не дополняется. Значение active: false останавливает доставку,
        не удаляя приёмник, - удобнее удаления на время работ на вашей
        стороне. Лимит - 30 запросов в минуту (общая корзина изменений).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                url:
                  type: string
                  format: uri
                  maxLength: 500
                  examples: ["https://your-app.com/webhooks/finos-v2"]
                events:
                  type: array
                  minItems: 1
                  items: { $ref: '#/components/schemas/WebhookEventName' }
                active:
                  type: boolean
                  description: false - доставка остановлена, приёмник сохранён.
            example:
              active: false
      responses:
        '200':
          description: Приёмник обновлён.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/WebhookEndpoint' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/WebhookNotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

    delete:
      tags: [Подписки на вебхуки]
      operationId: deleteWebhookEndpoint
      summary: Удалить приёмник
      description: |
        Приёмник удаляется вместе с секретом, доставка прекращается сразу.
        Если нужен перерыв, а не удаление, используйте PATCH с active: false.
        Лимит - 30 запросов в минуту (общая корзина изменений).
      responses:
        '204':
          description: Приёмник удалён.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/WebhookNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/{id}/rotate-secret:
    parameters:
      - $ref: '#/components/parameters/WebhookId'
    post:
      tags: [Подписки на вебхуки]
      operationId: rotateWebhookSecret
      summary: Выпустить новый секрет подписи
      description: |
        Выдаёт новый секрет и показывает его ОДИН раз. Прежний секрет гаснет
        немедленно: обновите проверку подписи на своей стороне до следующей
        доставки, иначе события начнут отклоняться вашим приёмником.
        Тела запроса нет. Лимит - 30 запросов в минуту (общая корзина
        изменений).
      responses:
        '200':
          description: Секрет заменён.
          content:
            application/json:
              schema:
                type: object
                required: [data, secret]
                properties:
                  data: { $ref: '#/components/schemas/WebhookEndpoint' }
                  secret:
                    type: string
                    description: Новый секрет подписи, показывается только здесь.
                    examples: ["whsec_ВАШ_СЕКРЕТ"]
                  notice: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/WebhookNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/{id}/test:
    parameters:
      - $ref: '#/components/parameters/WebhookId'
    post:
      tags: [Подписки на вебхуки]
      operationId: testWebhookEndpoint
      summary: Отправить тестовое событие
      description: |
        Синхронно отправляет на приёмник событие webhook.test и возвращает
        результат доставки. Поле delivered = true означает, что ваш адрес
        ответил 2xx; false - что запрос ушёл, но ответ был другим либо
        не пришёл вовсе. Тело события подписывается тем же секретом, что
        и боевые доставки, поэтому вызов проверяет и проверку подписи.
        Тела запроса нет. Лимит - 30 запросов в минуту (общая корзина
        изменений).
      responses:
        '200':
          description: Событие отправлено, результат в data.delivered.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    properties:
                      webhook_id: { type: integer, examples: [42] }
                      delivered:
                        type: boolean
                        description: true - приёмник ответил 2xx.
                      last_success: { type: [string, "null"], format: date-time }
                      last_failure: { type: [string, "null"], format: date-time }
                  notice: { type: string }
              example:
                data:
                  webhook_id: 42
                  delivered: true
                  last_success: "2026-08-17T12:41:03.000000Z"
                  last_failure: null
                notice: "Test event delivered successfully (2xx received)."
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/WebhookNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ───────────────────────── Песочница ─────────────────────────

  /test/customers/{uuid}/simulate-deposit:
    parameters:
      - $ref: '#/components/parameters/CustomerUuid'
    post:
      tags: [Песочница]
      operationId: simulateCustomerDeposit
      summary: Сымитировать поступление на кошелёк клиента
      description: |
        Только для ключей sk_test_. Прогоняет полный конвейер зачисления:
        баланс клиента, строка журнала со статусом pending, вебхук
        customer.deposit.detected, комплаенс-проверка. Боевым ключом маршрут
        отвечает 404. Лимит - 30 запросов в минуту.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [coin, amount]
              properties:
                coin: { type: string, maxLength: 16, examples: ["USDT"] }
                network: { type: [string, "null"], maxLength: 32, examples: ["TRON"] }
                amount:
                  type: [string, number]
                  description: Сумма поступления, больше нуля. Как и везде, рекомендуем строку.
                  examples: ["100"]
            example:
              coin: "USDT"
              network: "TRON"
              amount: "100"
      responses:
        '201':
          description: Поступление зачислено.
          content:
            application/json:
              schema:
                type: object
                required: [data, sandbox]
                properties:
                  data: { $ref: '#/components/schemas/WalletTransaction' }
                  sandbox: { type: boolean, const: true }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: Маршрут недоступен боевому ключу либо клиент не найден.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: "not_found"
                  message: "Ресурс не найден."
                  request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
        '409':
          description: Зачислить симулированное поступление не удалось.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

components:

  requestBodies:
    WebhookEnvelope:
      required: true
      description: >-
        Конверт исходящего события. Подписывается заголовком X-FinOs-Signature
        (HMAC-SHA256 сырого тела секретом приёмника); повтор доставки шлёт те же
        байты с тем же id, поэтому обрабатывайте события идемпотентно.
      content:
        application/json:
          schema:
            type: object
            required: [id, type, created_at, env, data]
            properties:
              id:
                type: string
                description: Идентификатор события, стабилен во всех повторах.
                examples: [evt_7f1c2a9e4b6d]
              type:
                type: string
                description: Имя события.
                examples: [customer.deposit.confirmed]
              created_at:
                type: string
                format: date-time
              env:
                type: string
                enum: [live, sandbox]
              data:
                type: object
                description: Полезная нагрузка события.

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        API-ключ учётной записи в заголовке
        Authorization: Bearer sk_live_ВАШ_КЛЮЧ (боевое окружение) либо
        Authorization: Bearer sk_test_ВАШ_КЛЮЧ (песочница).

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

  parameters:
    CustomerUuid:
      name: uuid
      in: path
      required: true
      description: Публичный идентификатор клиента.
      schema: { type: string, format: uuid }
      example: "5f8d7a3c-1234-4567-89ab-cdef01234567"

    WebhookId:
      name: id
      in: path
      required: true
      description: Числовой идентификатор приёмника из ответа на его создание.
      schema: { type: integer, minimum: 1 }
      example: 42

    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: >-
        Ключ идемпотентности операции. Сгенерируйте уникальное значение
        на каждую новую операцию и повторяйте ЕГО ЖЕ при ретраях после
        сетевых сбоев. Тот же ключ с другими параметрами - 409 conflict.
      schema: { type: string }
      example: "wd-2026-08-17-0001"

    Page:
      name: page
      in: query
      description: Номер страницы.
      schema: { type: integer, minimum: 1, default: 1 }

    PerPage:
      name: per_page
      in: query
      description: Размер страницы.
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }

    PerPageCursor:
      name: per_page
      in: query
      description: Размер страницы.
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }

    StartingAfter:
      name: starting_after
      in: query
      description: >-
        Курсор: идентификатор последней полученной записи (meta.next_cursor).
        Выборка продолжается строго после неё.
      schema: { type: string, maxLength: 64 }

  responses:
    Unauthorized:
      description: Ключ API отсутствует или недействителен.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: "unauthorized"
              message: "Требуется аутентификация. Проверьте ключ API."
              request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"

    Forbidden:
      description: >-
        Доступ запрещён: сервис не подключён учётной записи, у ключа нет
        нужной роли либо учётная запись приостановлена.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            forbidden:
              summary: Нет прав на действие
              value:
                error:
                  code: "forbidden"
                  message: "Доступ запрещён."
                  request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
            merchant_suspended:
              summary: Учётная запись приостановлена
              value:
                error:
                  code: "merchant_suspended"
                  message: "Учётная запись мерчанта приостановлена - обратитесь в поддержку."
                  request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"

    NotFound:
      description: >-
        Объект не найден. Тем же кодом отвечает обращение к объекту чужого
        окружения - существование ресурса наружу не подтверждается.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: "not_found"
              message: "Ресурс не найден."
              request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"

    WebhookNotFound:
      description: >-
        Приёмник не найден. Тем же кодом отвечает обращение к приёмнику
        ЧУЖОГО окружения: боевой ключ не видит sandbox-приёмники и наоборот.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: "not_found"
              message: "Ресурс не найден."
              request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"

    ValidationFailed:
      description: Не прошли валидацию параметры или тело запроса.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: "invalid_request"
              message: "Некорректные данные запроса."
              request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
              fields:
                phone: ["The phone field format is invalid."]

    IdempotencyKeyRequired:
      description: Не передан обязательный заголовок Idempotency-Key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: "idempotency_key_required"
              message: "Требуется заголовок Idempotency-Key."
              request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"

    SettlementConflict:
      description: >-
        Конфликт состояния: клиент заблокирован либо Idempotency-Key уже
        использован с другими параметрами операции.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            conflict:
              value:
                error:
                  code: "conflict"
                  message: "Конфликт состояния операции."
                  request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
            customer_blocked:
              value:
                error:
                  code: "customer_blocked"
                  message: "Клиент заблокирован - операции по нему запрещены."
                  request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"

    ServiceBalanceInsufficient:
      description: >-
        На мастер-счёте не хватает средств. Конверт дополнен полями currency,
        required и available - показывайте точную нехватку пользователю.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ServiceBalanceError' }
          example:
            error:
              code: "service_balance_insufficient"
              message: "Недостаточно средств на сервисном счёте. Пополните мастер-счёт."
              request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"
              currency: "USDT"
              required: "0.2"
              available: "0.0500000000"

    RateLimited:
      description: Превышен лимит запросов для корзины этого эндпоинта.
      headers:
        Retry-After:
          description: Через сколько секунд можно повторить запрос.
          schema: { type: integer }
          example: 60
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: "rate_limited"
              message: "Слишком много запросов. Повторите позже."
              request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"

    ServiceUnavailable:
      description: Сервис временно недоступен - повторите позже с тем же Idempotency-Key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: "service_unavailable"
              message: "Сервис временно недоступен. Попробуйте позже."
              request_id: "req_5f8d2c1b0a9e8d7c6b5a4f3e"

  schemas:

    Error:
      type: object
      description: |
        Единый конверт ошибки. Реестр кодов контура:

        - idempotency_key_required (400) - не передан обязательный заголовок
          Idempotency-Key;
        - unauthorized (401) - отсутствует или недействителен ключ API;
        - insufficient_funds (402) - не хватает средств на счёте клиента
          (с учётом комиссий) либо на счёте финансирования владельца;
        - service_balance_insufficient (402) - не хватает средств на
          мастер-счёте; конверт дополнен полями currency, required, available;
        - forbidden (403) - нет прав на действие или сервис не подключён;
        - wallets_disabled (403) - кошельки клиентов недоступны учётной записи;
        - withdraw_disabled (403) - вывод средств клиентов недоступен;
        - sandbox_unsupported (403) - сервис не поддерживает тестовые ключи;
        - merchant_suspended (403) - учётная запись приостановлена;
        - verification_required (403) - клиент не прошёл верификацию;
        - not_found (404) - объект не найден, в том числе в чужом окружении;
        - method_not_allowed (405) - адрес не принимает этот HTTP-метод;
        - conflict (409) - конфликт состояния, в том числе повтор
          Idempotency-Key с другими параметрами;
        - customer_blocked (409) - клиент заблокирован;
        - customer_has_funds (409) - у клиента есть остатки, удаление запрещено;
        - customer_has_wallets (409) - у клиента есть адреса или история
          движений, удаление запрещено;
        - duplicate_phone (409) - номер занят другим клиентом;
        - aml_screening_in_progress (409) - по клиенту идёт проверка
          поступления, повторите позже;
        - invalid_request (422) - не прошли валидацию параметры или тело;
        - coin_not_allowed (422) - монета или сеть недоступны, либо адрес
          получателя принадлежит платформе;
        - amount_below_minimum (422) - сумма меньше минимальной для монеты;
        - invalid_address (422) - адрес не соответствует формату сети;
        - withdraw_limit_exceeded (422) - превышен лимит на операцию или
          суточный лимит клиента;
        - aml_blocked (422) - операция отклонена комплаенс-проверкой;
        - rate_limited (429) - превышен лимит запросов;
        - service_unavailable (503) - сервис временно недоступен;
        - internal_error (500) - непредвиденная ошибка платформы.

        ИСКЛЮЧЕНИЕ: гейт доступа отвечает ПЛОСКОЙ формой без вложенного
        объекта error - {"message", "error_code", "docs"} с кодами
        UNAUTHENTICATED (401) и B2B_ACCESS_REQUIRED (403). Разбирайте обе
        формы.
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Машинный код ошибки из реестра выше.
              examples: ["invalid_request"]
            message:
              type: string
              description: Человекочитаемое сообщение по-русски, уже обезличенное.
              examples: ["Некорректные данные запроса."]
            request_id:
              type: string
              description: Идентификатор запроса для обращения в поддержку.
              examples: ["req_5f8d2c1b0a9e8d7c6b5a4f3e"]
            fields:
              type: object
              description: >-
                Ошибки по отдельным полям (только при invalid_request):
                имя поля - массив сообщений.
              additionalProperties:
                type: array
                items: { type: string }

    ServiceBalanceError:
      allOf:
        - $ref: '#/components/schemas/Error'
      description: >-
        Конверт service_balance_insufficient: к обычным полям добавлены
        валюта, требуемая сумма и доступный остаток мастер-счёта.

    Network:
      type: string
      description: >-
        Блокчейн-сеть в верхнем регистре. Точный список доступных вам сетей -
        в GET /merchant/service-pricing.
      enum: [TRON, ETHEREUM, BSC, POLYGON, BASE, BITCOIN, LITECOIN]
      examples: ["TRON"]

    StableNetwork:
      type: string
      description: Сеть, в которой вам доступны стейблкоины.
      enum: [TRON, ETHEREUM, BSC, POLYGON, BASE]
      examples: ["TRON"]

    Customer:
      type: object
      description: >-
        Клиент мерчанта. Объект может содержать дополнительные служебные поля -
        читайте их по имени и не полагайтесь на полный состав ответа.
      properties:
        uuid: { type: string, format: uuid }
        external_id: { type: [string, "null"], examples: ["user-42"] }
        name: { type: string, examples: ["Иван Петров"] }
        email: { type: [string, "null"], format: email }
        phone: { type: [string, "null"], examples: ["+79001234567"] }
        env: { type: string, enum: [live, sandbox] }
        status:
          type: string
          enum: [active, blocked]
          description: >-
            У заблокированного клиента запрещены вывод и расчёты. Удалению
            мешает только блокировка платформы (офицер или санкции),
            собственная блокировка мерчанта - нет.
        blocked_reason: { type: [string, "null"] }
        blocked_at: { type: [string, "null"], format: date-time }
        metadata:
          type: [object, "null"]
          description: >-
            Ваши произвольные поля. У заблокированного клиента платформа
            добавляет сюда ключ block_source со значением merchant, officer
            или sanctions - снять через API можно только merchant.
        kyc_status:
          type: string
          enum: [not_started, pending, processing, verified, rejected, expired]
        kyc_level: { type: integer, minimum: 0, maximum: 3 }
        kyc_rejection_reason: { type: [string, "null"] }
        consent_signed: { type: boolean }
        consent_signed_at: { type: [string, "null"], format: date-time }
        consent_ip: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    WalletAddress:
      type: object
      description: Адрес пополнения клиента в одной сети.
      properties:
        network: { type: string, examples: ["TRON"] }
        address: { type: string, examples: ["TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE"] }
        sandbox:
          type: [boolean, "null"]
          description: >-
            true у синтетического адреса песочницы (префикс SBX), null
            в боевом окружении.

    LedgerBalance:
      type: object
      description: Строка баланса в валюте леджера.
      properties:
        currency: { type: string, examples: ["USDT"] }
        available: { type: string, examples: ["312.5000000000"] }
        locked: { type: string, examples: ["0.0000000000"] }

    CryptoBalance:
      type: object
      description: Крипто-баланс клиента по одной монете.
      properties:
        asset: { type: string, examples: ["USDT"] }
        symbol: { type: string, examples: ["USDT"] }
        network: { type: string, examples: ["TRON"] }
        currency:
          type: string
          description: >-
            Код строки баланса: для токенов составной {ASSET}_{NETWORK},
            для нативных монет - тикер.
          examples: ["USDT_TRON"]
        available: { type: string, examples: ["150.0000000000"] }
        locked: { type: string, examples: ["0.0000000000"] }

    WalletTransaction:
      type: object
      description: |
        Строка журнала движений по кошельку клиента.

        ВНИМАНИЕ: поля status и state - разные. status отвечает на вопрос
        «деньги дошли?» и никогда не выходит за три значения. state
        появляется ТОЛЬКО у заявок на вывод и описывает стадию обработки.
      properties:
        id: { type: string, format: uuid }
        network: { type: string, examples: ["TRON"] }
        asset: { type: string, examples: ["USDT"] }
        direction:
          type: string
          enum: [in, out]
          description: Направление с точки зрения клиента.
        amount: { type: string, examples: ["150.000000000000000000"] }
        address: { type: [string, "null"] }
        counterparty: { type: [string, "null"] }
        tx_hash:
          type: [string, "null"]
          description: >-
            Хеш транзакции в сети. null у внутренних операций и пока
            отправки не было.
        status:
          type: string
          enum: [pending, confirmed, failed]
        state:
          $ref: '#/components/schemas/WithdrawState'
        confirmations: { type: integer, examples: [21] }
        aml_status:
          type: string
          enum: [pending, clear, flagged, skipped]
          description: >-
            pending - проверка идёт; clear - вопросов нет; flagged - есть
            риск (обычно вместе с заморозкой); skipped - проверка неприменима.
        frozen:
          type: boolean
          description: Сумма по этой строке сейчас заморожена.
        fee:
          type: object
          properties:
            platform:
              type: string
              description: Комиссия платформы, удержанная сверх суммы.
              examples: ["0.000000000000000000"]
            merchant:
              type: string
              description: >-
                Ваша надбавка, удержанная сверх суммы; фиксированная сумма
                по монете. У поступлений 0 - зачисление бесплатно.
              examples: ["0.000000000000000000"]
        confirmed_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    WithdrawState:
      type: string
      description: |
        Стадия заявки на вывод (только у заявок):

        - processing - заявка принята, деньги списаны, отправки ещё не было;
        - sending - команда на отправку отдана, исход неизвестен;
        - sent - транзакция ушла в сеть, идёт набор подтверждений;
        - confirmed - сеть подтвердила (терминальная стадия);
        - failed - отправить не удалось, деньги возвращены клиенту
          (терминальная стадия).
      enum: [processing, sending, sent, confirmed, failed]

    WithdrawTransaction:
      allOf:
        - $ref: '#/components/schemas/WalletTransaction'
        - type: object
          properties:
            duplicate:
              type: boolean
              description: >-
                true - повтор по уже использованному Idempotency-Key: ответ
                прежний, движения денег не было (HTTP 200 вместо 201).

    SettlementTransaction:
      allOf:
        - $ref: '#/components/schemas/WalletTransaction'
        - type: object
          properties:
            operation:
              type: string
              enum: [charge, payout]
            merchant_balance:
              type: [string, "null"]
              description: Остаток мастер-счёта в валюте операции после проводки.
              examples: ["312.5000000000"]
            duplicate:
              type: boolean
              description: true - повтор по уже использованному Idempotency-Key.

    SettlementRequest:
      type: object
      required: [coin, amount]
      properties:
        coin: { type: string, maxLength: 16, examples: ["USDT"] }
        network:
          type: [string, "null"]
          maxLength: 32
          description: Обязательна, если тикер существует в нескольких сетях.
          examples: ["TRON"]
        amount:
          type: string
          maxLength: 40
          pattern: '^\d+(\.\d{1,18})?$'
          description: >-
            Плоская десятичная строка без знака и экспоненты, до 18 знаков
            после точки: "1e3", "+10", "10." дают 422.
          examples: ["12.5"]
        reason:
          type: [string, "null"]
          maxLength: 240
          description: >-
            Назначение операции. Попадает в описание строки мастер-счёта
            и в полезную нагрузку вебхука.

    WithdrawQuote:
      type: object
      description: >-
        Расчёт вывода без движения денег. Списание с клиента = amount +
        fee_platform + fee_merchant, где fee_merchant - фиксированная
        надбавка мерчанта по этой монете.
      properties:
        asset: { type: string, examples: ["USDT"] }
        network: { type: string, examples: ["TRON"] }
        amount:
          type: string
          description: Запрошенная сумма.
          examples: ["100"]
        fee_platform:
          type: string
          description: >-
            Фиксированная комиссия платформы за операцию в этой сети.
            Сетевые издержки покрывает платформа.
          examples: ["5"]
        fee_merchant:
          type: string
          description: >-
            Ваша надбавка - ФИКСИРОВАННАЯ сумма в единицах выводимой монеты,
            заданная для этой пары монета+сеть, а не процент от суммы.
            Удерживается с клиента и зачисляется на мастер-счёт. Действующие
            значения и верхние границы - в GET /merchant/service-pricing,
            блок withdraw.markup. На пополнение надбавки нет вовсе.
          examples: ["1.0000000000"]
        total_debit:
          type: string
          description: Сколько всего спишется с клиента.
          examples: ["106.0000000000"]
        receives:
          type: string
          description: Сколько получит адрес назначения.
          examples: ["100"]
        balance:
          type: string
          description: Доступный остаток клиента по этой монете.
          examples: ["150.0000000000"]
        daily_limit:
          type: string
          description: >-
            Суточный лимит вывода на клиента в эквиваленте USDT. Окно
            СКОЛЬЗЯЩЕЕ: учитываются операции за последние 24 часа, полночь
            лимит не обнуляет.
          examples: ["30000"]
        daily_used:
          type: string
          description: Выведено клиентом за последние 24 часа, в эквиваленте USDT.
          examples: ["0.0000000000"]
        daily_left:
          type: string
          description: >-
            Остаток суточного лимита; освобождается по мере того, как операции
            выходят за окно в 24 часа.
          examples: ["30000.0000000000"]
        max_per_tx:
          type: string
          description: Лимит на одну операцию, в эквиваленте USDT.
          examples: ["10000"]
        min_withdraw:
          type: string
          description: >-
            Минимум монеты. Значение в примере иллюстративное - действующие
            минимумы отдаёт GET /merchant/service-pricing.
          examples: ["10.000000000000000000"]
        network_fee_from_amount:
          type: boolean
          description: >-
            true для UTXO-сетей: сеть удержит свою плату из отправляемой
            суммы, и получатель получит немного меньше amount.
        aml_precheck:
          type: boolean
          description: >-
            Всегда false: предпросмотр не выполняет комплаенс-проверку
            адреса назначения.
        allowed:
          type: boolean
          description: Пройдут ли проверки при создании заявки прямо сейчас.
        reason:
          type: [object, "null"]
          description: Причина отказа при allowed=false.
          properties:
            code:
              type: string
              description: Тот же код, что вернёт боевой вызов.
              examples: ["withdraw_limit_exceeded"]
            gate:
              type: string
              description: Имя не пройденной проверки.
              examples: ["max_per_tx"]
            error:
              type: string
              description: Текст для человека.
              examples: ["Сумма больше лимита на одну операцию (10000 USDT)."]

    BalanceTxType:
      type: string
      description: |
        Тип операции леджера мастер-счёта:

        - topup_deposit - пополнение переводом на адрес мастер-счёта;
        - topup_internal - пополнение со счёта финансирования владельца;
        - charge - списание с клиента в пользу мерчанта;
        - withdraw_markup - надбавка, удержанная при выводе клиента;
        - aml_fee - плата за AML-проверку;
        - aml_fee_refund - возврат платы за проверку без вердикта;
        - payout - выплата клиенту;
        - transfer_out - перевод на счёт финансирования владельца;
        - adjustment - корректировка оператора платформы.
      enum:
        - topup_deposit
        - topup_internal
        - aml_fee
        - aml_fee_refund
        - withdraw_markup
        - charge
        - payout
        - adjustment
        - transfer_out

    BalanceTransaction:
      type: object
      description: Запись леджера мастер-счёта.
      properties:
        id: { type: string, format: uuid }
        currency: { type: string, examples: ["USDT"] }
        type: { $ref: '#/components/schemas/BalanceTxType' }
        direction:
          type: string
          enum: [credit, debit]
          description: Знак операции. amount всегда положительна.
        amount: { type: string, examples: ["0.2000000000"] }
        balance_after:
          type: string
          description: Остаток строки счёта сразу после операции.
          examples: ["312.3000000000"]
        description: { type: [string, "null"], examples: ["AML-проверка адреса"] }
        created_at: { type: string, format: date-time }

    InternalTransfer:
      type: object
      description: >-
        Результат внутреннего перевода между мастер-счётом и счётом
        финансирования владельца. В песочнице форма ответа та же - включая
        wallet_balance и duplicate, - но значения синтетические: балансы
        нулевые, duplicate всегда false.
      properties:
        direction:
          type: string
          enum: [topup, transfer_out]
        network: { type: string, examples: ["TRON"] }
        currency: { type: string, examples: ["USDT"] }
        amount:
          type: string
          description: >-
            Реально проведённая сумма. При повторе ключа возвращается сумма
            ПЕРВОЙ проводки, а не переданная в повторе.
          examples: ["100"]
        balance:
          type: string
          description: Остаток мастер-счёта после операции.
          examples: ["412.5000000000"]
        wallet_balance:
          type: string
          description: Остаток счёта финансирования владельца после операции.
          examples: ["900.00000000"]
        duplicate:
          type: boolean
          description: true - повтор по уже использованному ключу, движения денег не было.

    ServicePricing:
      type: object
      description: Действующие цены, комиссии и лимиты учётной записи.
      properties:
        currency:
          type: string
          description: Расчётная валюта услуг платформы.
          examples: ["USDT"]
        aml:
          type: object
          properties:
            check_price:
              type: string
              description: Цена одной платной проверки адреса.
              examples: ["0.2"]
            billing_enabled:
              type: boolean
              description: Тарификация проверок включена на платформе.
            exempt:
              type: boolean
              description: Учётная запись освобождена от оплаты проверок.
            charged:
              type: boolean
              description: >-
                Итог: спишется ли плата за следующий боевой вызов проверки.
                Опирайтесь на это поле, а не на цену.
        withdraw:
          type: object
          properties:
            fees:
              type: array
              items:
                type: object
                properties:
                  asset: { type: string, examples: ["USDT"] }
                  network: { type: string, examples: ["TRON"] }
                  fee:
                    type: string
                    description: Фиксированная комиссия платформы за вывод клиента.
                    examples: ["5"]
                  min_withdraw:
                    type: string
                    description: >-
                      Минимальная сумма вывода. Числа в примерах
                      иллюстративные - ваши действующие значения приходят
                      в этом же ответе, не зашивайте минимумы в код.
                    examples: ["10.000000000000000000"]
                  min_deposit:
                    type: string
                    description: Минимальная сумма поступления (пример, см. оговорку выше).
                    examples: ["1.000000000000000000"]
                  native:
                    type: boolean
                    description: >-
                      true у нативной монеты сети. Плату из отправляемой суммы
                      сеть удерживает только в UTXO-сетях (BITCOIN, LITECOIN):
                      именно там quote отдаёт network_fee_from_amount = true.
                      У нативных TRX и ETH получатель получает ровно amount.
            limits:
              type: object
              properties:
                max_per_tx:
                  type: string
                  description: Лимит на одну операцию, в эквиваленте USDT.
                  examples: ["10000"]
                daily_per_customer:
                  type: string
                  description: >-
                    Суточный лимит на клиента, в эквиваленте USDT. Окно
                    скользящее - последние 24 часа, а не календарные сутки.
                  examples: ["30000"]
            markup:
              type: array
              description: >-
                Ваша надбавка на выводы клиентов - строка на каждую доступную
                монету. Надбавка задаётся ФИКСИРОВАННОЙ суммой в единицах
                монеты, а не процентом: работа платформы и сети на выводе
                10 и 10 000 USDT одинакова. Верхнюю границу по каждой монете
                устанавливает платформа; сохранённое значение выше границы
                урезается до неё и при сохранении, и при каждом расчёте.
              items:
                type: object
                properties:
                  coin_id:
                    type: integer
                    description: Идентификатор монеты в справочнике платформы.
                    examples: [1]
                  label:
                    type: string
                    description: Человекочитаемое имя монеты.
                    examples: ["USDT / TRON"]
                  asset: { type: string, examples: ["USDT"] }
                  network: { type: string, examples: ["TRON"] }
                  native:
                    type: boolean
                    description: true у нативной монеты сети.
                    examples: [false]
                  fee:
                    type: string
                    description: >-
                      Комиссия платформы за вывод в этой сети - та же, что
                      в withdraw.fees[].
                    examples: ["5"]
                  markup:
                    type: string
                    description: >-
                      Действующая надбавка по этой монете, в единицах монеты.
                      Именно она попадает в fee_merchant расчёта вывода.
                    examples: ["1.0000000000"]
                  max:
                    type: string
                    description: >-
                      Верхняя граница надбавки по этой монете, в единицах
                      монеты. Устанавливает платформа.
                    examples: ["10.0000000000"]
                  editable:
                    type: boolean
                    description: >-
                      false, если граница равна нулю: по такой монете
                      надбавка недоступна, пока платформа не разрешит её.
                    examples: [true]
        balance:
          type: object
          properties:
            low_threshold:
              type: string
              description: >-
                Порог остатка USDT, ниже которого приходит вебхук
                merchant.balance.low.
              examples: ["5"]

    AmlCheck:
      type: object
      description: Обезличенный результат проверки блокчейн-адреса.
      properties:
        id: { type: string, format: uuid }
        address: { type: string, examples: ["TXYZ1234567890abcdefGHIJKLmnop"] }
        network: { type: [string, "null"], examples: ["tron"] }
        risk_score:
          type: integer
          minimum: 0
          maximum: 100
          description: >-
            Балл риска. Пороги перевода балла в risk_level - настройка
            платформы, они меняются при перекалибровке скоринга и в контракте
            не фиксируются: не сравнивайте risk_score с собственными
            константами, читайте готовые risk_level и recommendation.
            В песочнице балл синтетический и с боевым по тому же адресу
            не совпадает.
          examples: [64]
        risk_level:
          type: string
          description: >-
            Уровень риска, посчитанный платформой. Связь с рекомендацией
            жёсткая: low - allow, medium - review, high - reject,
            critical - block.
          enum: [low, medium, high, critical]
        report:
          type: object
          description: >-
            Полный обезличенный отчёт: risk_score, risk_level, готовая
            рекомендация recommendation (allow, review, reject, block),
            плоский набор signals, разложение по factors и агрегаты
            по сетям. Состав может пополняться - опирайтесь на стабильные
            поля. В песочнице содержит признак sandbox=true.
          additionalProperties: true
        price:
          type: [string, "null"]
          description: >-
            Сумма, фактически списанная с мастер-счёта. null - вызов был
            бесплатным (песочница, освобождение или проверка платформы).
          examples: ["0.2000000000"]
        pricing:
          type: object
          properties:
            markup_pct:
              type: number
              description: >-
                Историческое поле, всегда 0. Проверка - расход мерчанта,
                а не товар для перепродажи: надбавки у неё нет.
              examples: [0]
            price: { type: [string, "null"], examples: ["0.2000000000"] }
            currency: { type: string, examples: ["USDT"] }
        report_url:
          type: string
          format: uri
          description: Ссылка на PDF-отчёт. Требует того же Bearer-ключа.
        created_at: { type: string, format: date-time }

    WebhookEventName:
      type: string
      description: >-
        Имя события, на которое можно подписаться. Список строгий и полный:
        подписаться можно ровно на эти имена, маски вида customer.* не
        поддерживаются, любое другое значение отклоняется валидацией.
      enum:
        - customer.created
        - customer.kyc.processing
        - customer.kyc.verified
        - customer.kyc.rejected
        - customer.deposit.detected
        - customer.deposit.confirmed
        - customer.deposit.frozen
        - customer.deposit.released
        - customer.withdrawal.processing
        - customer.withdrawal.sent
        - customer.withdrawal.confirmed
        - customer.withdrawal.failed
        - customer.withdrawal.blocked
        - customer.charged
        - customer.payout
        - customer.blocked
        - customer.unblocked
        - merchant.balance.credited
        - merchant.balance.low
        - payment.buy.initiated
        - payment.buy.completed
        - payment.buy.failed
        - payment.sell.completed
        - payment.sell.failed
        - payment.deposit.detected
        - payment.deposit.confirmed
        - payment.withdraw.completed
        - payment.withdraw.failed
        - webhook.test
      examples: ["customer.deposit.confirmed"]

    WebhookEndpoint:
      type: object
      description: >-
        Приёмник событий мерчанта. Секрет подписи в этом объекте никогда
        не возвращается - только его последние символы.
      properties:
        id: { type: integer, examples: [42] }
        url:
          type: string
          format: uri
          description: HTTPS-адрес, на который уходят события.
          examples: ["https://your-app.com/webhooks/finos"]
        events:
          type: array
          description: Имена событий, на которые подписан приёмник.
          items: { $ref: '#/components/schemas/WebhookEventName' }
        env:
          type: string
          description: Окружение приёмника; определяется префиксом ключа при создании.
          enum: [live, sandbox]
        active:
          type: boolean
          description: false - доставка приостановлена, приёмник сохранён.
        secret_last4:
          type: [string, "null"]
          description: Последние символы секрета - чтобы отличать приёмники в интерфейсе.
          examples: ["wxyz"]
        delivery_count: { type: integer, examples: [128] }
        failure_count: { type: integer, examples: [3] }
        last_delivery_at: { type: [string, "null"], format: date-time }
        last_success_at: { type: [string, "null"], format: date-time }
        last_failure_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CryptoHistoryEntry:
      allOf:
        - $ref: '#/components/schemas/WalletTransaction'
        - type: object
          properties:
            kind: { type: string, const: crypto }

    TradingHistoryEntry:
      type: object
      description: Операция обмена фиата и криптовалюты в единой истории клиента.
      properties:
        kind: { type: string, const: trading }
        id: { type: string, format: uuid }
        type: { type: string, examples: ["payment"] }
        status: { type: string, examples: ["completed"] }
        amount: { type: string, examples: ["10000.00"] }
        currency: { type: string, examples: ["RUB"] }
        amount_to: { type: [string, "null"], examples: ["117.4500"] }
        currency_to: { type: [string, "null"], examples: ["USDT"] }
        created_at: { type: [string, "null"], format: date-time }

    PageLinks:
      type: object
      description: Ссылки постраничной навигации.
      properties:
        first: { type: [string, "null"], format: uri }
        last: { type: [string, "null"], format: uri }
        prev: { type: [string, "null"], format: uri }
        next: { type: [string, "null"], format: uri }

    PageMeta:
      type: object
      description: Метаданные постраничной выборки.
      properties:
        current_page: { type: integer, examples: [1] }
        from: { type: [integer, "null"], examples: [1] }
        to: { type: [integer, "null"], examples: [20] }
        per_page: { type: integer, examples: [20] }
        total: { type: integer, examples: [1] }
        last_page: { type: integer, examples: [1] }

    SimplePageMeta:
      type: object
      description: Метаданные постраничной выборки журнала движений.
      properties:
        current_page: { type: integer, examples: [1] }
        per_page: { type: integer, examples: [25] }
        total: { type: integer, examples: [1] }
        last_page: { type: integer, examples: [1] }

    CursorMeta:
      type: object
      description: Метаданные курсорной выборки.
      properties:
        per_page: { type: integer, examples: [25] }
        returned:
          type: integer
          description: Сколько записей в этом ответе.
          examples: [2]
        has_more:
          type: boolean
          description: Есть ли ещё записи.
        next_cursor:
          type: [string, "null"]
          description: >-
            Значение для starting_after следующего запроса. null, когда
            выборка закончилась.
