# ADR-0015. Консоль платформы: статусы мерчантов и партнёров, доступ поддержки, состояние системы

- Статус: принято; реализуется на этапе 6 — ✅ 6d-1 (партнёры, мерчанты и их статусы), ✅ 6d-2 (ключи в консоли);
  6d-3 (доступ поддержки), 6d-4 (состояние системы, журнал платформы, учётные записи) — в работе
- Дата: 2026-10-03

## Контекст

Операторам платформы нужна консоль: заводить партнёров и мерчантов, приостанавливать и закрывать их, видеть ключи
доступа и отзывать их, помогать мерчанту в его кабинете, следить за состоянием системы. Статусы мерчантов (`active`,
`suspended`, `closed`) и партнёров (`active`, `suspended`) были в базе с этапа 0, но значили мало: ключ кассы
закрытого мерчанта работал, приостановленный мерчант менял данные через Management API. Владелец продукта выбрал
(вопрос 4 плана этапа 6): приостановленный мерчант только читает, а его кассы и виджеты работают; закрытый получает
отказ везде; консоль не выпускает ключи; «вход от имени» — доступ поддержки под своим именем.

## Решение

### Статусы

- **Мерчант `suspended`** (приостановлен платформой, например за неоплату).
  - Кабинет мерчанта — только чтение (ADR-0013), кроме отзыва ключей.
  - В Management API любое изменение получает 403 `merchant_suspended`, кроме отзыва ключа кассы
    (`RequireMerchant::WRITES_WHILE_SUSPENDED`): он только защищает мерчанта. Отказ пишется в журнал мерчанта, кто бы ни
    спрашивал — и партнёр, и ключ мерчанта.
  - Runtime API работает: кассы начисляют и списывают баллы, участники регистрируются.
- **Мерчант `closed`** (договор расторгнут).
  - Кабинет отвечает 404, как будто мерчанта нет.
  - В Management API партнёр и ключ мерчанта получают 403 `merchant_not_accessible`; такой отказ остаётся в журнале
    вызывающего, как отказ в чужом мерчанте.
  - Ключи мерчанта и касс перестают проходить проверку (401).
  - Данные сохраняются, и мерчанта можно открыть снова.
- **Переходы.** Каждый — одно условное обновление из своего статуса, поэтому два оператора не перешагнут через
  изменение друг друга:
  - приостановить можно только работающего;
  - снять приостановку — только с приостановленного;
  - открыть снова — только закрытого;
  - закрыть — любого незакрытого.

  Повтор того же перехода возвращает мерчанта как есть, а переход из чужого статуса получает отказ (`InvalidMerchant`).
  Так снятие приостановки никогда не откроет мерчанта, которого другой оператор только что закрыл.
- **Открытый мерчант** — `active` или `suspended`. Доступ проверяется по этому списку (`MerchantData::isOpen()`), а не
  как «не закрыт», поэтому статус, который добавят позже, сначала будет отказом везде.
- **Ключ кассы работает, пока его касса включена.** Отключение кассы или закрытие точки останавливает её ключи;
  включение кассы возвращает их. Открытые чеки отключённой кассы она уже не подтвердит и не отменит: их резерв
  истечёт сам.
- **Партнёр `suspended`.**
  - Новые токены ему не выдаются сразу, а выданные перестают приниматься в течение минуты (кэш проверки).
  - Его сотрудники сразу теряют доступ к его мерчантам (ADR-0013).
  - Сами мерчанты партнёра продолжают работать: их команды, ключи и кассы.
  - Кабинет партнёра (6i) станет только для чтения.
- **Где проверяется.**
  - `api_key_candidates` (SECURITY DEFINER) находит ключ кассы вместе со статусами мерчанта и кассы. Ответ кэшируется
    на `ApiKeyService::CACHE_SECONDS`, поэтому смена статуса доходит до проверки ключей касс в течение минуты.
  - Management API читает статус мерчанта на каждом запросе (`RequireMerchant`): закрытие и приостановка действуют
    сразу, даже пока ключ мерчанта ещё в кэше проверки.
  - Кабинеты решают доступ на каждом запросе (`StaffAccess`).

### Партнёры и мерчанты в консоли (6d-1)

- **Кто видит и кто меняет.** Видят все операторы (`directory.view`). Меняют администраторы (`partners.manage`,
  `merchants.manage`) со вторым фактором, подтверждённым за последние 10 минут, как любое изменение из консоли.
- **«Партнёры».** Список новых сверху, по 50 на страницу, поиск по части названия или по id. Партнёра можно добавить,
  переименовать, приостановить (с причиной) и возобновить; ссылка «Мерчанты партнёра» открывает его мерчантов.
  Приглашение владельца партнёра откладывается до кабинета партнёра (6i): принять приглашение пока негде. До этого
  есть команда `staff:grant --partner`.
- **«Мерчанты».**
  - Поиск по части названия, по id или по идентификатору у партнёра. Фильтры: партнёр (или свои мерчанты платформы) и
    статус.
  - Фильтры приходят и в адресе страницы (ссылка от партнёра). Метка фильтра называет партнёра, даже если его нет среди
    50 последних в списке выбора.
  - Мерчанта можно добавить — своего или для партнёра, с идентификатором у партнёра.
  - Можно изменить название, страну и часовой пояс. Пояс только подставляется в форму новой точки в кабинете мерчанта;
    у программ и точек — свой.
  - Можно приостановить, снять приостановку, закрыть и открыть снова; приостановка, закрытие и повторное открытие — с
    причиной.
  - «Подробнее» показывает, сколько у мерчанта точек и касс и сколько из них работают. Эти числа читаются в контексте
    мерчанта (`TenantContext::run`) вне транзакции; участники и чеки появятся в 6g.
- **Ввод из браузера.**
  - Фильтры, пришедшие в запросе Livewire или в адресе, очищаются до строк (`Kit\Tables\FilterState`), поиск остаётся
    строкой: Filament упал бы, отрисовывая список. Так же защищены таблицы кабинета мерчанта с фильтрами («Кассы»,
    журнал аудита).
  - Символ NUL в поиске отбрасывается.
  - Названия и идентификаторы у партнёра с управляющими символами домен не принимает.
- **Контракты.**
  - `Partners`: `search`, `findMany`, `rename`, `suspend`, `activate`.
  - `Merchants`: `search(MerchantQuery)`, `createDirect`, `update(MerchantChanges)`, `suspend`, `activate`, `close`,
    `reopen`, `countOfPartners`.
  - Индексы `(created_at, id)`. Поиск по части названия читает список в этом порядке: справочник небольшой, а
    триграммный индекс потребовал бы расширения PostgreSQL.
- **Курсоры.** Курсор страницы проверяется (`Cursor::timeAndId`): время ровно в том виде, в каком его пишет платформа
  (UTC, год, который принимает PostgreSQL), и UUID. Чужой курсор — 400 `invalid_cursor`, а не ошибка базы.

### Журнал

- **Названия действий:**
  - `partners.store`, `partners.update`, `partners.suspend`, `partners.activate`;
  - `merchants.store` (как маршрут Management API);
  - `merchants.update`, `merchants.suspend`, `merchants.activate` (снятие приостановки), `merchants.close`,
    `merchants.reopen`.
- **Что пишется.** Запись ложится в журнал платформы с выбранными значениями и причиной: партнёр нового мерчанта,
  часовой пояс и страна, причина приостановки, закрытия и повторного открытия. Названий в журнале нет: мерчант или
  партнёр, названный по имени владельца (ИП), называет человека, а журнал нельзя чистить (ADR-0010).
- **Копии.** Изменение партнёра копируется в журнал партнёра, изменение мерчанта — в журнал мерчанта, новый мерчант
  партнёра — в журнал партнёра. Копии идут без введённого, без адреса и браузера оператора (ADR-0010).
- **Оператор в чужих журналах.** Журналы мерчантов и партнёров — в кабинете и в `GET /audit-entries` — называют
  оператора платформы только так: без id учётной записи, адреса и браузера. Всё это остаётся в журнале платформы.
- **Без лимита частоты.** Копии из консоли не ограничены числом в час, в отличие от копий кабинета мерчанта
  (`GuardedAction::perHour`). Операторы — доверенные сотрудники со вторым фактором, а массовая работа (приостановить
  неплательщиков в конце месяца) не должна упираться в лимит.

### Ключи в консоли (6d-2)

- **Консоль видит и отзывает, но не выпускает.** Учётные данные выдаёт тот, кто ими пользуется: партнёр — в своём
  кабинете (6i, а до него — команда платформы), мерчант — в своём. Архитектурный тест не даёт консоли собрать команду
  выпуска.
- **«Ключи доступа партнёра»** (из «Партнёров») — OAuth-клиенты партнёра: название, публичный `client_id`, права,
  время выпуска и последнего токена, состояние. Секретов нет. Отозвать может администратор (`partners.manage`) со
  вторым фактором:
  - клиент больше не получает токены;
  - выданные токены удаляются и забываются в кэше проверки, поэтому перестают приниматься со следующего запроса;
  - клиент ищется по партнёру страницы (`ManagementCredentials::revokeClientOf`): чужого клиента так не отозвать.
- **«Ключи мерчанта»** (из «Мерчантов») — ключи мерчанта и ключи его касс в одной таблице: вид, название, начало ключа,
  касса, права, срок, последнее использование ключа кассы, состояние. Читаются и отзываются в контексте мерчанта.
  Отзывает администратор (`merchants.manage`) со вторым фактором; ключ перестаёт работать в течение минуты.
- **Журнал.** Отзыв клиента — `oauth-clients.revoke`, ключа мерчанта и кассы — `merchant-keys.revoke` и
  `api-keys.revoke`, как в кабинете мерчанта. В объекте записи — партнёр или мерчант и ключ. Копия идёт в журнал
  владельца.
- **Письмо владельцам.** Владельцам партнёра или мерчанта, чьё членство и учётная запись работают, уходит письмо
  (`TeamNotices`): что отозвано, что делать дальше и что неожиданный отзыв — повод спросить поддержку. Название ключа и
  организации — чужой текст, в письме он только текст.

### Дальше

- **6d-3.** Доступ поддержки — оператор работает в кабинете мерчанта под своим именем:
  - по выдаче с причиной, на 30 минут (не больше 120);
  - только чтение по умолчанию, полный доступ — у администраторов;
  - никогда с чувствительными правами;
  - начало, конец и действия — в журнале мерчанта.
- **6d-4.** Состояние системы (проверки модулей, запуски по расписанию, пульс), журнал платформы, учётные записи
  сотрудников (поиск, отключение, сброс 2FA, обезличивание).

## Последствия

- Новая операция Management API, которая меняет данные мерчанта, получает отказ у приостановленного мерчанта сама.
  Тест обходит все такие маршруты. Исключение добавляется только в `RequireMerchant::WRITES_WHILE_SUSPENDED`, с
  причиной.
- Смена статуса мерчанта или кассы доходит до касс в течение минуты, до Management API и кабинетов — сразу.
- Фоновые работы закрытого мерчанта продолжаются: баллы сгорают, уровни пересматриваются, а события об этом уходят
  его вебхукам. Если мерчанта откроют снова, его данные верны. Остановить такие работы можно будет вместе с удалением
  данных закрытых мерчантов, которого пока нет в плане.
- Тесты справочника ищут по слову своего теста: интеграционные тесты оставляют партнёров и мерчантов в тестовой базе
  до следующего запуска.
