# Install-IOS Partner API — задание для ИИ-агента

Редакция комплекта: **2026-10-21**, контракт **1.11.0** (OpenAPI 3.1). Это задание для интеграции системы партнёра, а не доступ к внутренним серверам Install-IOS. Ключи, адреса API и форматы существующих запросов не менялись.

Этот файл предназначен для ИИ-агента, которому вместе с ним передали исходный код системы сервисного центра. Выполни интеграцию Install-IOS Partner API в существующий проект. Не создавай отдельный демонстрационный проект, если в текущую систему можно встроиться штатно.

**Если интеграция уже работает**, не внедряй её заново: сначала найди существующие API-клиент, обработчик webhook, обновление заявки, показ реквизитов и клиентский кэш. Основная миграция этой редакции описана в разделах «Блокировка и бесплатная автозамена», «Алгоритм обновления без залипания блокировки» и в `ACCOUNT_ACCESS_AND_REPLACEMENT.md`. Сохрани действующую оплату, историю, права доступа и ручные перевыдачи.

## Готовая команда для запуска работы

Владелец проекта может написать агенту:

> Изучи весь мой проект и файл `AI_INTEGRATION_TASK.md`. Сначала кратко опиши найденный стек, текущую архитектуру заказов и точные места интеграции. Затем реализуй интеграцию по этому заданию, не помещая секреты в код и не выполняя реальные производственные операции без моего подтверждения. Запусти локальные тесты и передай мне понятный чек-лист ручной проверки в sandbox.

## Результат, который требуется получить

Добавь в систему сервисного центра серверную интеграцию, через которую оператор сможет:

1. Получать доступные каталоги и приложения Install-IOS.
2. Выбирать одно или несколько приложений.
3. Создавать заявку с уникальным номером из системы сервисного центра.
4. Использовать автоматический выбор подходящих учёток либо вручную указать каталог.
5. Получать нейтральную клиентскую страницу, QR-код, основные и резервные ссылки установки.
6. Временно показывать логин и пароль нужной учётки без долговременного хранения.
7. Сначала включать ожидание кода, затем просить клиента запросить код на iPhone и показывать только свежий код.
8. Видеть срок заявки, состояние, расчётную сумму, задолженность и аванс.
9. Безопасно повторять запросы после сетевых ошибок без дублей заказов и начислений.
10. Закрывать только затронутые группы при отзыве доступа, сохраняя рабочие приложения той же заявки.
11. Получать бесплатные автоматические замены внутри прежней заявки и показывать новые ссылки без расходования ручного лимита.
12. Перед каждым показом заново получать действующие реквизиты, чтобы изменение пароля владельцем не оставляло клиенту старую копию.

## Источники истины

- Базовый API: `https://api.installios.ru/v1`
- Документация: `https://api.installios.ru/docs`
- OpenAPI 3.1: `https://api.installios.ru/openapi.json`
- Резервный маршрут: `https://installios.ru/api/partner/v1`

Перед реализацией загрузи актуальную OpenAPI-схему и справку; в архиве также есть проверенный снимок `openapi.json` и `API_DOCUMENTATION.html`. Живая схема определяет формы запросов/ответов; эта инструкция и справка дополняют её алгоритмами и webhook. При противоречии проверь актуальный контракт, не угадывай. Не выдумывай методы и события вроде `credentials.updated` или отдельного «запуска автозамены»: их нет. Снимок в ZIP не обновляется после скачивания.

## Сначала изучи проект

До изменений:

1. Определи язык, фреймворк, способ конфигурации, HTTP-клиент, базу данных и тестовый стек.
2. Найди текущие модели клиентов, заказов, сотрудников и приложений.
3. Найди серверные контроллеры, фоновые задачи и интерфейс оператора.
4. Проверь, есть ли централизованное хранение секретов и маскирование чувствительных данных в логах.
5. Реши, где хранить безопасные метаданные Install-IOS: внутренний `order_id`, внешний номер, статусы, сроки и идентификаторы групп.
6. Покажи владельцу короткий план и задай вопросы только по решениям, которые невозможно определить из проекта.

Не проси владельца вставлять API-ключ в чат. После подготовки кода объясни, в какую серверную переменную окружения его записать.

## Конфигурация

Используй серверные переменные окружения:

```dotenv
INSTALLIOS_API_BASE=https://api.installios.ru/v1
INSTALLIOS_API_KEY=<основной API-ключ партнёра>
INSTALLIOS_WEBHOOK_SECRET=<только если подключён webhook>
```

API-ключ передаётся одним из способов:

```http
Authorization: Bearer <INSTALLIOS_API_KEY>
```

Предпочитай `Authorization: Bearer`. Все обращения с ключом должны выполняться только сервер-сервер. Никогда не отправляй API-ключ в браузер или мобильное приложение.

## Обязательные методы клиента

Реализуй типизированный клиент либо изолированный модуль со следующими операциями:

| Операция | Метод и путь |
|---|---|
| Проверка API | `GET /health` |
| Список каталогов | `GET /catalogs` |
| Приложения каталога | `GET /catalogs/{catalog_id}/applications` |
| Создание заявки | `POST /orders` |
| Получение заявки | `GET /orders/{order_id}` |
| Последние заявки | `GET /orders?limit=...` |
| Временные реквизиты | `GET /orders/{order_id}/credentials` |
| Разрешение на повторную выдачу | `POST /orders/{order_id}/set_extend` |
| Фактическая повторная выдача | `POST /orders/{order_id}/extend` |
| Включение ожидания кода | `POST /orders/{order_id}/code-waits` |
| Проверка ожидания | `GET /code-waits/{wait_id}` |
| Долг и аванс | `GET /balance` |
| Финансовый журнал | `GET /ledger` |

Для диагностической связности добавляй уникальный безопасный `X-Request-ID`. Не помещай в него персональные данные или секреты.

## Каталоги и приложения

Реализуй в системе партнёра **один общий каталог**, а не отдельную витрину первого или выбранного источника.

1. Получи полный список доступных источников через `GET /catalogs`.
2. Для **каждого** полученного `catalog.id` вызови `GET /catalogs/{catalog_id}/applications?include_icons=1`. Не ограничивайся первым каталогом и не используй заранее зашитый список. Параметр необязательный: без него сервер возвращает прежний ответ только с `id` и `name`.
3. Собери объединение всех ответов строго по `applications[].id`. Одинаковый `id` из двух или более каталогов — один товар в интерфейсе. Для объединённой карточки сохрани первый непустой `icon_url`.
4. Не объединяй и не различай приложения по названию, версии, внутреннему UID, позиции в массиве или `catalog_id`. Название предназначено для отображения, `app_...` — непрозрачный логический идентификатор продукта.
5. Для локальной диагностики можешь хранить рядом с товаром множество `catalog_ids`, в которых он встретился, но не показывай технические каталоги клиенту.
6. В режиме `source_mode: auto` передавай только `application.id`, без `catalog_id`. Install-IOS сам выберет доступный источник и Apple-учётку. `catalog_id` передавай только при осознанном ручном режиме.
7. При `include_icons=1` API передаёт `icon_url` как абсолютный HTTPS-адрес либо `null`. Разреши `https://installios.ru` в `img-src` вашей CSP. При отсутствии или ошибке загрузки иконки показывай аккуратный fallback, не скрывай приложение.

Пример логики синхронизации:

```text
catalogs = GET /catalogs -> data.catalogs
next_by_id = empty map

for catalog in catalogs:
    rows = GET /catalogs/{catalog.id}/applications -> data.applications
    for application in rows:
        if application.id not in next_by_id:
            next_by_id[application.id] = {
                id: application.id,
                name: application.name,
                icon_url: application.icon_url,
                catalog_ids: []
            }
        else if next_by_id[application.id].icon_url is empty:
            next_by_id[application.id].icon_url = application.icon_url
        add catalog.id to next_by_id[application.id].catalog_ids

atomically replace visible catalog with values(next_by_id)
```

Синхронизируй каталог при запуске сервера, вручную по кнопке оператора и фоново каждые 5–10 минут. Строй новый снимок отдельно и заменяй рабочий кэш только после успешной загрузки **всех** каталогов. Если любой запрос завершился ошибкой, сохрани последний полностью исправный снимок и повтори синхронизацию позже. На первом запуске без исправного снимка покажи временную недоступность, а не неполный список.

При первом запуске выполни полную синхронизацию всех доступных каталогов. Не делай вывод о недоступности приложения по неполному или неактуальному снимку. `applications_count` относится к отдельному источнику; размер общей витрины равен числу уникальных `application.id`, а не сумме счётчиков каталогов.

Install-IOS возвращает одинаковый логический ID для одного клиентского приложения, присутствующего в разных каталогах. Если один источник будет отключён, приложение должно остаться в витрине, пока тот же ID приходит хотя бы из одного другого доступного источника. При создании заявки всегда используй ID из последнего успешно собранного снимка каталога.

## Создание заявки

Пример тела:

```json
{
  "external_order_id": "SC-10482",
  "source_mode": "auto",
  "applications": [
    {"id": "app_example"}
  ]
}
```

Правила:

- `external_order_id` — существующий уникальный номер заказа сервисного центра, от 1 до 160 символов. Один и тот же бизнес-заказ всегда повторяй с тем же значением.
- `source_mode: auto` — рекомендуемый режим: Install-IOS назначает общей заявке следующий доступный каталог по кругу. Если приложения нет в основном каталоге заявки, используется следующий подходящий каталог.
- `source_mode: manual` — у каждого приложения обязательно передай `catalog_id`.
- В `auto` разрешено передать `catalog_id` как предпочтение.
- Если один бизнес-заказ отправляется несколькими API-заявками, формируй `external_order_id` по шаблону `<service>-<base-uuid>-1`, `<service>-<base-uuid>-2`, `<service>-<base-uuid>-3`. Подставь собственный короткий префикс интегрируемого сервиса и один стабильный UUID клиентской покупки. Не отправляй заполнители из примера буквально и не зашивай название какого-либо другого партнёра. Все части получают один основной каталог; новая общая заявка продвигает ротацию только один раз.
- Повтор `POST /orders` с прежним `external_order_id` должен возвращать существующую заявку, а не создавать новую.
- Сроки зависят от настроек партнёра и операции. Используй `expires_at`, `initial_ttl_seconds`, `code_window_ttl_seconds`, `order_expires_at` и, когда есть, `groups[].expires_at`. Не переноси политику розничного сайта/бота «6 часов после оплаты» на Partner API. Повторные запросы не добавляют время.
- Повторный доступ двухэтапный. `POST /orders/{order_id}/set_extend` открывает 48-часовое разрешение от текущего момента, не меняя ссылки, реквизиты и текущий срок. Клиент может использовать разрешение сразу. Когда клиент готов устанавливать, `POST /orders/{order_id}/extend` расходует это разрешение, повторно выдаёт всю логическую заявку на 12 часов и восстанавливает три успешных получения свежего кода. Всего на покупку доступны две фактические перевыдачи сверх первоначальной; перед каждой нужен отдельный `set_extend`.
- В прежнем полном ручном режиме сервер сначала выбирает другой подходящий каталог, при допустимом fallback пересоздаёт исходный. ID заявки сохраняется. Не распространяй прежнюю стабильность ID групп на индивидуальную, частичную или автоматическую замену: там группа может разделиться, а `group_id` измениться. Всегда используй свежий состав ответа.
- Прямые ссылки и реквизиты после успешной повторной выдачи меняются. Обязательно замени их данными нового ответа и повторно вызови `GET /orders/{order_id}/credentials`. Старые ссылки сервер отключит после создания новых.
- `set_extend` идемпотентен в пределах активного разрешения. Если оно истекло до фактической выдачи, разрешение можно открыть снова, пока остались перевыдачи. `extend` без подготовки, включая повтор после первой выдачи, возвращает `409 extension_not_prepared`, с истёкшим разрешением — `410 extension_window_expired`. После двух выдач оба метода возвращают `409 already_extended`. После фактической выдачи ожидание кода не сокращает 12 часов.

Сохрани безопасные метаданные ответа: `id`, `external_order_id`, `status`, `expires_at`, `initial_ttl_seconds`, `code_window_ttl_seconds`, `code_window_started_at`, `can_prepare_extend`, `extend_prepared`, `extend_ready_until`, `can_extend`, `repeat_count`, `repeat_limit`, `repeat_remaining`, ID групп и состояние биллинга. Не логируй временные ссылки целиком. Сроки не хардкодь: API является источником истины.

В ответе заявки используй:

- `fallback_url` — готовая нейтральная страница для клиента;
- `qr_url` — QR-код перехода;
- `groups[].applications[].links.main` — основная ссылка приложения;
- `groups[].applications[].links.reserve` — резервная ссылка.

## Реквизиты

`GET /orders/{order_id}/credentials` возвращает временные логины и пароли по группам заявки.

Обязательные ограничения:

- не записывай ответ в базу, файлы, кэш, аналитику, трассировку, Sentry или логи;
- не включай его в резервные копии;
- не возвращай реквизиты из публичного API своего проекта без авторизации;
- обрабатывай их только в оперативной памяти и показывай только уполномоченному оператору;
- установи `Cache-Control: no-store` на собственный ответ интерфейсу;
- очищай значение из состояния интерфейса после закрытия, завершения или истечения заявки.

Перед каждым показом клиенту/оператору получи свежие `GET /orders/{order_id}` и `GET /orders/{order_id}/credentials`. Новый пароль той же исходной Apple-учётки появляется в действующих выдачах после повторного запроса; уже показанный на устройстве текст сам не изменится. Отдельного webhook о смене пароля нет. Обновляй при открытии/возврате к карточке и перед показом реквизитов; не опрашивай credentials каждую секунду (для endpoint действует лимит 30 запросов в минуту, учитывай общий трафик партнёра и `Retry-After`).

Сопоставляй `data.groups[].id` из credentials с **текущими** `data.groups[].id` заявки, не по индексу массива, названию каталога или старому login. При замене группы старые реквизиты нужно удалить из памяти. Для отозванной группы ответ может быть HTTP 200, но `login` и `password` — пустые строки, а `access_revoked=true`. Это запрет показа, а не повод достать старый пароль из кэша.

Изменение логина/замена учётки каталога не означает автоматическую подмену исходной учётки старой выдачи. Не пытайся самостоятельно «склеивать» Apple-аккаунты по email. Не вызывай Apple password-change API: интеграция занимается чтением разрешённых реквизитов, а не сменой пароля у Apple.

## Блокировка и бесплатная автозамена — обязательная поддержка

Блокировка Apple-учётки закрывает её старые ссылки и ожидания кодов на сервере, независимо от готовности сайта партнёра. История и деньги сохраняются; другие группы заявки остаются рабочими. Разблокировка не восстанавливает прежние отозванные ссылки.

Обработай точные поля и ошибки:

| Где | Что обработать |
|---|---|
| `GET /orders/{order_id}` → `data.groups[]` | `access_revoked`, `access_revocation_reason` (`account_blocked`), `access_message`; у приложений этой группы `links: null` |
| `GET /orders/{order_id}/credentials` → `data.groups[]` | Те же признаки отзыва; обязательные `login`/`password` остаются строками, но становятся пустыми |
| `POST /orders/{order_id}/code-waits` | HTTP 409 и `error.code=access_revoked`: не запускать бесконечные повторы ожидания |
| `GET /code-waits/{wait_id}` | Конечный `status=cancelled`, а при наличии — `access_revoked`, `access_revocation_reason`, `access_message`; старое ожидание больше не использовать |
| `GET /orders/{order_id}` → `data.automatic_replacement` | Необязательный объект: `status` = `pending` / `partial` / `replaced`, числовые `pending`/`replaced`, `limit_consumed: false`, `applications[]` |
| `automatic_replacement.applications[]` | `application_id`, `status` = `pending` / `replaced`, `expires_at`; актуальные ссылки находятся не здесь, а в свежих `groups[].applications[].links` |

Сервис сам подбирает замену для действующих боевых API-выдач со статусом `active`. Приложения одного набора могут оказаться в разных разрешённых рабочих каталогах. Новая заявка не создаётся, автозамена не расходует две обычные перевыдачи. Ручной лимит не вычисляй по числу webhook: бери актуальные поля API.

Срок не продлевается: сохраняется оставшееся время, с округлением вниз до минуты; выдачи с остатком меньше 65 секунд не заменяются. Не устанавливай новый локальный таймер на 12 часов или 6 часов при автозамене.

Если подходящего каталога нет, серверная очередь ждёт его до прежнего срока. Покажи «Подбираем замену» для конкретного приложения, оставь рабочие приложения доступными и продолжай ограниченный polling заявки с backoff и `Retry-After`. Нет отдельного endpoint запуска/повтора автозамены. Не создавай `POST /orders`, не вызывай автоматически `set_extend`/`extend` и не трать ручной лимит вместо ожидания.

Истёкшие, завершённые, sandbox-выдачи и выдачи отключённых партнёров не заменяются. Запрошенные/одобренные/погашенные возвраты (`pending`/`approved`/`settled`) исключены. Для них не обещай автоматическую замену. Sandbox не доказывает работу production-автозамены: её состояния проверяются локальными mock/fixtures, без блокировки реальной учётки.

## Алгоритм обновления без залипания блокировки

1. Храни привязку своей покупки к прежним `order_id`/`external_order_id`. Перед показом, при возврате на страницу, после webhook и в polling получай свежую заявку. Для логической покупки с несколькими техническими заказами обновляй нужные части, не предполагая, что GET одной части вернёт группы всех остальных.
2. Рассматривай успешный GET как новый **полный снимок** запрошенной заявки. Атомарно замени текущие группы, приложения, ссылки, статусы и сроки. Не дописывай новые группы к старым, не используй `oldRevoked || newRevoked` и не оставляй флаг «заблокирован весь заказ» от прошлого состояния.
3. Идентификатор приложения бери из `groups[].applications[].id`. В `automatic_replacement.applications[]` и webhook то же назначение имеет `application_id`. Не ищи несуществующее `groups[].applications[].application_id` и не сопоставляй по названию. Группы и их порядок могут измениться.
4. Удаляй старые ссылки, коды, ожидания и реквизиты удалённых/заменённых групп. `links:null` не заменяй предыдущим значением. Останови polling только старого ожидания, а не всей заявки. Если ответ старого HTTP-запроса пришёл позже нового, не позволяй ему откатить состояние: используй локальный номер поколения запроса/снимка и при необходимости повторный GET.
5. Для каждой текущей группы заново вычисляй доступность: нет отзыва, не истёк её актуальный срок, у конкретного приложения есть разрешённые ссылки. При наличии `groups[].expires_at` используй его, иначе общий `expires_at`. Не используй `status=waiting_for_code` или один только `automatic_replacement.status=replaced` как доказательство доступности.
6. После успешной замены показывай новые ссылки из новой группы и убирай старое сообщение о блокировке у этой позиции. Если позже блокируется новая учётка, свежие признаки отзыва снова имеют приоритет. Отсутствие необязательного `automatic_replacement` в новом ответе не должно сохранять устаревший баннер из предыдущего снимка.
7. Перед показом отдельно загрузи credentials, проверь актуальные ID групп и признаки отзыва и замени значения в памяти. Не сохраняй и не восстанавливай пароль при ошибке, пустой строке или удалённой группе. При гонке замены между двумя GET обнови заявку ещё раз; не привязывай ответ credentials к уже несуществующей группе.
8. Для кода новой группы нужна **новая явная попытка** с её свежим `group_id` и новым ключом этой попытки. Сетевые повторы одной и той же попытки используют прежний `Idempotency-Key`. Старый `wait_id` не переиспользуется. Автоматически запрашивать новые коды без действия клиента не нужно.
9. При сетевой ошибке не возвращай ранее отозванные ссылки/реквизиты. Покажи временную недоступность проверки, ограничь повторы, сохрани историю и повтори чтение безопасно. Факт ошибки не означает, что сервер отменил блокировку или уже выполнил замену.

Рекомендуемая схема: webhook и polling вызывают одну coalesced-задачу refresh на заказ. Если событие пришло во время refresh, отметь заказ dirty и перечитай ещё раз, а не теряй событие. Не держи клиентскую блокировку навсегда после единственной ошибки или прежнего `access_revoked=true`.

## Событие автозамены

Для каждой успешно заменённой позиции используется существующее событие **`order.extended`**, а не новый тип события. Дополнительные поля в JSON-теле события:

```json
{
  "automatic": true,
  "reason": "account_blocked",
  "limit_consumed": false,
  "automatic_replacement_id": "auto_example",
  "application_id": "app_example",
  "group_id": "grp_replacement_example",
  "expires_at": 1800003600
}
```

Это сокращённый фрагмент, не полный webhook. Сохрани поддержку старых ручных `order.extended`, где этих полей нет. `automatic_replacement_id` не заменяет заголовок `X-InstallIOS-Event` для идемпотентности доставки.

Webhook — сигнал перечитать `GET /orders/{order_id}` и при необходимости credentials, а не готовая структура новых групп/паролей. Повторные или запоздавшие события не должны возвращать старые ссылки, списывать ручной лимит или повторять выдачу. Проверь HMAC, надёжно запиши событие, затем обновляй состояние с безопасным повтором задачи. Не отмечай refresh завершённым только потому, что сохранил ID webhook.

Для смены пароля и самого отзыва доступа отдельные webhook не добавлены. Поэтому обновление перед показом и polling активной карточки нужны даже при настроенных webhook. Отсутствие webhook не блокирует интеграцию.

## Сообщение о блокировке от доверенного партнёра — только по разрешению

Если владелец Install-IOS включил функцию, свежий GET группы содержит `can_report_blocked:true` и `account_report_reference`. Покажи отдельное подтверждаемое действие оператора. Только по нему отправляй `POST /orders/{order_id}/groups/{group_id}/report-blocked` с единственным полем `{"account_report_reference":"значение из свежей группы"}`. Это действие реально блокирует общую учётку, поэтому не вызывай его автоматически при timeout, неверном пароле или ради теста.

При `409 stale_account_reference` перечитай заявку, не подставляй старую ссылку на учётку. `403 account_reporting_forbidden` / `sandbox_reporting_forbidden` означает отсутствие разрешения; `409 credentials_unavailable` — требуется свежая проверка выдачи; `410 order_expired` — срок истёк. В sandbox рабочие учётки блокировать нельзя. Отсутствие этой функции не мешает принимать блокировки и автозамены, инициированные владельцем.

## Правильный порядок получения кода

Для каждой нужной группы заявки:

1. Возьми `group_id` из `groups[].id`.
2. Создай и сохрани стабильный `Idempotency-Key` именно для этой попытки ожидания.
3. Вызови `POST /orders/{order_id}/code-waits` с телом `{"group_id":"..."}`.
4. Дождись ответа со статусом `active`.
5. Сразу замени сохранённый срок заявки значением `order_expires_at` из ответа. Учитывай индивидуальный срок группы и серверные настройки; не рассчитывай новый дедлайн самостоятельно.
6. Только теперь покажи оператору сообщение: клиент может вводить реквизиты и запрашивать код на iPhone.
7. Проверяй `GET /code-waits/{wait_id}` не чаще одного раза в секунду либо обрабатывай webhook `code.ready`.
8. При `succeeded` немедленно покажи `code` и обратный отсчёт `valid_for_seconds`.
9. После окончания `valid_for_seconds` удали код из интерфейса и памяти.
10. Обработай конечные `code_expired`, `timed_out` и `cancelled`: останови старый polling и удали код. При отмене/отзыве сначала перечитай заявку; новую попытку разрешай только для текущей рабочей группы, а не для заблокированной старой.

Ожидание активно до пяти минут. Код считается свежим не более 60 секунд. Для одной группы разрешены три успешных получения. Несколько клиентов могут ожидать код одновременно — искусственную очередь на стороне сервисного центра создавать не нужно.

## Повторный доступ к установке

1. Для оператора покажи действие `Разрешить повторную выдачу`, пока API возвращает `can_prepare_extend: true`. Покажи остаток `repeat_remaining` из общего лимита `repeat_limit` (2); `repeat_count` — использованные выдачи всей покупки, включая её части. `extended_at` — время последней успешной перевыдачи.
2. По этому действию вызови `POST /orders/{order_id}/set_extend`. При успехе покажи клиенту, до какого времени он может начать установку, используя `extend_ready_until`.
3. Пока `extend_prepared: true`, покажи отдельную клиентскую кнопку `Начать установку`. Не вызывай `extend` заранее.
4. По нажатию клиента вызови `POST /orders/{order_id}/extend` без тела и защити кнопку от двойного нажатия.
5. При `repeat_applied: true` обнови срок заявки и всех её частей, замени прямые ссылки из ответа и повторно получи реквизиты каждой группы.
6. `repeat_mode=alternative` означает, что все группы переведены на другие каталоги и учётки; `same_catalog_fallback` — что использован исходный каталог с актуальными реквизитами; `mixed` — оба варианта в одной логической заявке.
7. После повторной выдачи снова используй обычный порядок ожидания кода. Для каждой группы доступны три новых успешных получения, а срок остаётся 12 часов от `extend`.
8. Для `extend`: `409 extension_not_prepared` требует нового решения оператора и `set_extend`; `410 extension_window_expired` позволяет оператору заново разрешить выдачу через `set_extend`; `409 already_extended` означает, что обе перевыдачи использованы. Не подготавливай следующую выдачу автоматически при обработке ошибки.
9. Не реализуй повторную выдачу как новый `POST /orders`: это нарушит историю, ротацию каталогов и расчёт.

Отличай три ручных режима от бесплатной серверной автозамены. Полная выдача без тела сохраняет прежнее поведение. Для opt-in `allow_partial:true` следуй `PARTIAL_REISSUE.md`: проверяй `repeat_result`, `retry_allowed`, `issued_now` и незавершённую попытку, а не только HTTP 200. Для одного приложения передавай `application_id` в ОБОИХ вызовах и следуй `APPLICATION_REISSUE.md`: используй индивидуальные `reissue`/`application_reissue`, новые группы и их сроки. Две ручные попытки нельзя списывать за `automatic:true`.

## Повторы, таймауты и ошибки

- Установи конечные таймауты соединения и чтения; запрос не должен зависать бесконечно.
- При сетевой ошибке или HTTP `500/503` повторяй через 2, 5, 15 и 30 секунд.
- При `429` соблюдай `Retry-After`.
- `409` с `error.code=access_revoked` — конечный запрет старой попытки: прекрати её и обнови заявку. `cancelled` не является HTTP-ошибкой и тоже требует остановки старого polling. Не лечи эти состояния сменой idempotency key или новой покупкой.
- Повтор создания заказа выполняй с тем же `external_order_id`.
- Повтор запуска ожидания выполняй с тем же `Idempotency-Key`.
- Если ответ повторной выдачи потерялся, сначала получи `GET /orders/{order_id}` и проверь `repeat_count` и состояние. Не запускай автоматически новую пару `set_extend` → `extend`: она может расходовать следующую перевыдачу. Для нового цикла требуется новое решение оператора.
- Не повторяй автоматически запрос, если результат мог быть создан, но ты собираешься изменить ключ идемпотентности.
- Отображай пользователю понятные сообщения для `401`, `404`, `409`, `410`, `429` и `503`, а технические детали оставляй в безопасном журнале.
- В логах допустимы только: `request_id`, HTTP-статус, длительность, безопасные внутренние ID и тип операции.

## Webhook — необязательный режим

Если владелец подтвердил webhook, создай публичный HTTPS endpoint. Проверяй подпись по точному сырому телу до JSON-разбора:

```text
X-InstallIOS-Signature = sha256=HMAC-SHA256(
  INSTALLIOS_WEBHOOK_SECRET,
  X-InstallIOS-Timestamp + "." + raw_body
)
```

- отклоняй timestamp старше пяти минут;
- сравнивай подпись constant-time функцией;
- сохраняй `X-InstallIOS-Event` в UNIQUE-поле для идемпотентности;
- отвечай 2xx только после надёжного сохранения события;
- повтор одного события не должен повторять бизнес-действие;
- примеры проверки уже находятся в каталоге `webhook/`.

Возможные события: `order.created`, `order.extended`, `order.expired`, `code.ready`, `billing.charged`, `billing.reversed`, `payment.recorded`, `order.completed`.

Если webhook пока не подключён, используй polling. Не блокируй запуск всей интеграции из-за отсутствия webhook.

## Деньги

- `GET /balance` возвращает финансовое состояние в рублях: `net_rubles` — итоговый баланс, `debt_rubles` — задолженность, `advance_rubles` — аванс, `currency` — `RUB`.
- Остальные денежные поля с суффиксом `_kopecks` передаются в копейках: `10000` означает 100 ₽.
- В sandbox поле `would_charge_kopecks` показывает расчёт, но реальный долг не возникает.
- В рабочем режиме каждое приложение начисляется отдельно при первом переходе по его основной или резервной ссылке установки.
- Повторные переходы по одному приложению не создают новый долг; успешный код является только контрольной метрикой.
- `billing.charged` может прийти несколько раз для одной заявки, максимум один раз на приложение. Обрабатывай событие идемпотентно по `X-InstallIOS-Event`.
- Частичная оплата уменьшает долг; переплата становится авансом.
- Не вычисляй задолженность самостоятельно, если доступен `GET /balance`.

## Интерфейс оператора

Встрой интерфейс в существующий стиль проекта. Минимально нужны:

1. Поиск и выбор приложений из единого списка, объединённого по `application.id`.
2. Переключатель `Автоматический выбор` / `Выбрать каталог вручную`.
3. Кнопка создания заявки с защитой от двойного нажатия.
4. Номер, срок и статус заявки.
5. Кнопки открыть/скопировать клиентскую страницу, QR, основную и резервную ссылку.
6. Защищённый блок временных реквизитов.
7. Отдельная кнопка `Включить ожидание кода` для каждой группы.
8. Состояние ожидания, свежий код и обратный отсчёт.
9. Два действия повторной выдачи: операторское `Разрешить` через `set_extend` и клиентское `Начать установку` через `extend`; после фактического ответа обновляются срок, прямые ссылки и реквизиты.
10. Понятное различие sandbox и рабочего режима.
11. Состояние каждого приложения: доступно, подбирается замена, доступ отозван, срок истёк. Рабочие позиции не скрываются из-за одной заблокированной группы.
12. После новой выдачи или смены пароля отображаются текущие данные, а прежний блокирующий баннер исчезает только у действительно восстановленной позиции.

Не показывай API-ключ и webhook-секрет ни одному пользователю интерфейса.

## Что нужно реализовать в коде

- изолированный серверный API-клиент;
- конфигурацию через переменные окружения;
- адаптацию к существующим моделям заказов и правам доступа;
- контроллеры/сервисы и интерфейс оператора;
- безопасное хранение только разрешённых метаданных;
- защиту от дублей и повторов;
- структурированные логи с редактированием чувствительных полей;
- unit-тесты клиента и бизнес-логики;
- интеграционные тесты с mock HTTP либо sandbox;
- короткую локальную инструкцию запуска и отката изменений.

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

## Обязательные проверки перед сдачей

Локальными mock-тестами и разрешёнными sandbox-проверками докажи владельцу следующие факты. Автоматическую замену, блокировку и смену пароля проверяй на синтетических ответах: не блокируй боевые учётки ради теста.

1. `/health` отвечает успешно.
2. Каталоги и приложения загружаются.
3. Загружаются приложения каждого каталога, а одинаковые `application.id` отображаются одной карточкой.
4. Частичная ошибка синхронизации не затирает последний полностью исправный каталог.
5. Заказ в `source_mode: auto` отправляет `application.id` без закрепления `catalog_id`.
6. Повтор создания с одним `external_order_id` не создаёт дубль.
7. Заявка содержит клиентскую страницу, QR и обе ссылки.
8. Реквизиты не появляются в базе, логах и кэше.
9. Ожидание включается до запроса кода.
10. Повтор ожидания с одним `Idempotency-Key` не создаёт дубль.
11. Polling идёт не чаще раза в секунду и завершается по конечному состоянию.
12. Код исчезает после окончания срока свежести.
13. `429`, `409`, `410` и временная недоступность обрабатываются без падения интерфейса.
14. Двойное нажатие кнопки не создаёт второй заказ.
15. В sandbox расчёт отображается, но реальный долг не начисляется.
16. Секреты отсутствуют в Git diff, сборочных артефактах, логах и клиентском JavaScript.
17. Если подключён webhook — неверная подпись отклоняется, а повтор события не дублирует действие.
18. Полная ручная выдача, индивидуальная и opt-in частичная не смешиваются: сохраняются их разные счётчики, разрешения и правила повторов. Не предполагается одинаковая ошибка при повторе всех режимов: завершённый индивидуальный/частичный запрос может вернуть прежний результат с `issued_now:0`.
19. При блокировке одной из двух групп только её ссылки и реквизиты скрыты; вторая остаётся рабочей. `links:null` не заменяется старым URL.
20. HTTP 200 credentials с пустыми `login`/`password` и `access_revoked:true` не восстанавливает сохранённый пароль.
21. `409 access_revoked` и `cancelled` прекращают старое ожидание; polling заявки для подбора замены продолжается с ограничением нагрузки.
22. `automatic_replacement.status=pending/partial/replaced` обрабатывается на уровне приложений. Новые группы и ссылки заменяют старую структуру, а не добавляются к ней.
23. Поле `automatic_replacement` может отсутствовать; отсутствие не оставляет старый баннер навсегда. Поздний ответ HTTP/старый webhook не откатывает свежие ссылки.
24. После `order.extended` с `automatic:true`, `reason:account_blocked`, `limit_consumed:false` выполняется GET, а не новая заявка или `extend`. Повтор события не списывает попытки.
25. При отсутствии webhook те же состояния обнаруживаются polling и при открытии карточки. При отсутствии каталога показано ожидание, а не обещанная гарантированная замена.
26. После смены пароля свежий credentials показывает новое значение в памяти; старое не берётся из БД/кэша. Нового события смены пароля не ожидаем.
27. После замены старый wait/code удалён, новая явная попытка использует новый `group_id`; здоровые ожидания других групп не сбрасываются.
28. Истечение/завершение и исключённые возвраты не вызывают новую покупку, автоматическую ручную выдачу или восстановление старой ссылки.
29. В fixtures с разными `groups[].expires_at` таймеры не заменяются общими 6/12 часами; автозамена не продлевает исходный срок.
30. Вся логика проверяется без реальных оплат, отзывов учёток, кодов, возвратов и сообщений клиентам.

Не запускай полный тест получения реального кода без участия владельца и iPhone. Не переключай партнёра из sandbox в рабочий режим — это делает только владелец Install-IOS после проверки.

## Формат отчёта владельцу

После реализации сообщи:

- какие файлы изменены;
- где указать переменные окружения;
- какие миграции требуются;
- какие автоматические тесты прошли;
- как пройти ручной sandbox-тест;
- как откатить изменение;
- какие решения требуют участия владельца, например webhook URL или список исходящих IP.

Не включай в отчёт реальные API-ключи, webhook-секреты, логины, пароли, коды или полные временные ссылки.


Optional partial reissue: see PARTIAL_REISSUE.md. Enable only after handling repeat_result per item; legacy default is unchanged.


Отдельная перевыдача приложения: см. [APPLICATION_REISSUE.md](APPLICATION_REISSUE.md). Новое поле application_id в обоих вызовах; старые вызовы сохраняются.
