# Changelog

Формат — [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/). Версии появятся к пилоту.

## [Unreleased]

### Этап 6d-1. Консоль платформы: партнёры, мерчанты и статусы

- Статусы мерчантов действуют везде (ADR-0015):
  - приостановленный мерчант только читает: в Management API любое изменение получает 403 `merchant_suspended`
    (отказ пишется в журнал мерчанта), кроме отзыва ключа кассы, а кассы продолжают работать;
  - закрытый недоступен: кабинет — 404, Management API — 403 `merchant_not_accessible`, его ключи не принимаются;
  - ключи отключённых касс больше не проходят проверку Runtime API;
  - до проверки ключей касс смена статуса доходит в течение минуты, до Management API и кабинетов — сразу;
  - открытым считается только `active` и `suspended` (`MerchantData::isOpen()`), а не всё, кроме `closed`.
- «Партнёры» в консоли: поиск, добавление, переименование, приостановка с причиной и возобновление.
- «Мерчанты» в консоли:
  - поиск по названию, id и идентификатору у партнёра, фильтры по партнёру (или своим мерчантам платформы) и статусу,
    в том числе по ссылке;
  - добавление своего мерчанта или мерчанта партнёра, исправление названия, часового пояса и страны;
  - приостановка, снятие приостановки, закрытие и повторное открытие — каждое только из своего статуса;
  - число точек и касс.

  Видят все операторы, меняют администраторы со вторым фактором.
- Журнал аудита:
  - действия `partners.*` и `merchants.*` консоли пишутся в журнал платформы — с выбранными значениями и причиной, но
    без названий: у ИП это персональные данные;
  - изменения партнёра и мерчанта и новый мерчант партнёра копируются в их журналы, без введённого, адреса и браузера;
  - журналы мерчантов и партнёров, в том числе в `GET /audit-entries`, называют оператора платформы без id, адреса и
    браузера;
  - схема `AuditAction` и SDK обновлены.
- Модуль tenancy:
  - `Partners::search`, `findMany`, `rename`, `suspend`, `activate`;
  - `Merchants::search`, `createDirect`, `update`, `suspend`, `activate`, `close`, `reopen`, `countOfPartners`;
  - названия и идентификаторы у партнёра с управляющими символами не принимаются;
  - индексы `(created_at, id)`.
- Поддельный курсор страницы в Management API теперь получает 400 `invalid_cursor` вместо ошибки базы
  (`Cursor::timeAndId`). Это касается мерчантов партнёра, истории баллов участника, доставок вебхука и журнала аудита,
  в том числе курсора с годом 0000 или чужим часовым поясом.
- Фильтры таблиц кабинетов с фильтрами («Мерчанты» консоли, «Кассы» и журнал аудита мерчанта), пришедшие из запроса или
  ссылки, очищаются от всего, кроме строк (`Kit\Tables\FilterState`), а поиск остаётся строкой: поддельный запрос
  больше не роняет страницу.

### Этап 6c-4. Кабинет мерчанта: вебхуки

- «Вебхуки» (ADR-0014): владельцы и администраторы, в том числе партнёра, со вторым фактором добавляют вебхук на все
  события или на выбранные (адрес проверяется у поля той же проверкой, что в домене, — `WebhookEndpoints::checkUrl`),
  отключают его с причиной, включают и выпускают новый секрет подписи; секрет показывается один раз. «Доставки»
  вебхука — сначала новые, по 50 на страницу, с числом попыток, кодом ответа и ошибкой последней попытки; доставку
  можно поставить в очередь снова, кроме доставок отключённого вебхука. Остальным ролям страница не показывается (в
  адресе вебхука может быть секрет получателя), приостановленному мерчанту — тоже: управление вебхуками — запись.
- Журнал аудита: имена маршрутов Management API для вебхуков; в записях кабинета — id и типы событий, но не адрес и не
  причина. Адрес вебхука (`url`) журнал теперь скрывает и в записях Management API.
- Показ секрета один раз вынесен в `Kit\Pages\Concerns\RevealsSecretOnce` (его используют ключи и вебхуки).
- После изменения строки на странице с постраничным выводом (`PagesByCursor`) таблица сразу показывает новое
  состояние: `GuardedAction` забывает загруженную страницу записей.

### Этап 6c-3. Кабинет мерчанта: ключи

- «Ключи касс» и «Ключи мерчанта» (ADR-0014): список ключей с правами, сроком, тем, кто выпустил, и (у ключей касс)
  последним использованием, без секретов. Выпускают владельцы и администраторы, в том числе администраторы партнёра,
  со вторым фактором: ключ кассы — для работающей кассы и активной программы, ключ мерчанта — с любыми правами на
  данные мерчанта, кроме прав партнёра на мерчантов; последний день работы — до пяти лет вперёд. Новый ключ
  показывается один раз и не остаётся ни в состоянии страницы, ни в сессии, ни в журнале.
- Новое право `keys.revoke` (у владельцев и администраторов): отзыв ключа остаётся и у приостановленного мерчанта,
  у которого остальное только для чтения, — утёкший ключ можно остановить.
- «Закрыть доступ» в «Команде» — участнику и сотруднику партнёра — показывает действующие ключи, которые выпустил этот
  человек (и ключи касс, выпущенные через API его ключом мерчанта), и может отозвать их в той же транзакции.
- Модуль доступа: кто выпустил ключ кассы, ключ мерчанта и OAuth-клиента (`issued_by_user_id`) и каким ключом
  мерчанта выпущен ключ кассы через Management API (`issued_by_merchant_key_id`), оба неизменяемые; `ApiKeys::ofTenant`,
  `ApiKeys::issuedBy`, `ManagementCredentials::merchantKeys`, `merchantKeysIssuedBy` (`ApiKeyInfo`, `MerchantKeyInfo`).
- Журнал аудита: `merchant-keys.store`, `merchant-keys.revoke` в схеме `AuditAction`, SDK перегенерированы.
- Тесты кабинетов проверяют окно открытого действия встроенными проверками Filament (`assertMountedActionModalSee`):
  окно приходит отдельным фрагментом ответа, и проверка экранирования в окнах 6c-2 раньше в него не смотрела.

### Этап 6c-2. Кабинет мерчанта: организация

- «Точки», «Кассы», «Бренды», «Юрлица» (ADR-0014): владельцы и администраторы добавляют точки, меняют их (кроме кода)
  и закрывают навсегда — кассы закрытой точки отключаются, новые ей не добавить, а её кассы не включить; добавляют кассы
  всех видов (включая веб-кассу), отключают и включают их; добавляют бренды и юрлица. Остальные в мерчанте только
  смотрят; у приостановленного мерчанта — только просмотр.
- В контракте `Organization`: `updateLocation` (`LocationChanges`, все поля без умолчаний), `closeLocation`,
  `enableTerminal`, `allTerminals`, виды касс `TERMINAL_KINDS`; у `TerminalData` появилось название; ИНН, КПП и ОГРН с
  переводом строки в конце — 422, а не ошибка базы. Management API: добавить кассу в закрытую точку нельзя — 422
  `invalid_organization` (описано в спецификации).
- Отключённая касса не открывает новых чеков, но её ключи пока проходят проверку Runtime API (уже открытые чеки,
  регистрация участников, коды); отказ на входе — в 6d.
- Журнал аудита: действия `locations.update`, `locations.close`, `terminals.enable` в схеме `AuditAction`, SDK
  перегенерированы; в записи — только идентификаторы и выбранные значения, без названий, адресов и ИНН.
- `GuardsCabinetPage` по имени вызывает только публичную фабрику действия без аргументов и отклоняет действие таблицы с
  ключом не той формы: подделанное открытое действие (`resolveAction`, `getAction`, ключ-массив) больше не роняет
  страницу ошибкой 500 при следующем запросе.

### Этап 6c-1. Кабинет мерчанта: команда и журнал аудита

- «Команда» (ADR-0014): участники, сотрудники партнёра с доступом через партнёра и ждущие приглашения в одной
  таблице; пригласить, изменить роль, закрыть и открыть доступ, убрать отключённого из команды, отозвать приглашение,
  закрыть доступ сотруднику партнёра (копия записи — в журнал партнёра). Правила владельцев и «не над собой» — в
  домене; сотрудник партнёра команду видит, но не меняет. Новое в модуле сотрудников: `StaffAccess::partnerStaffOf`,
  `TenantMemberships::remove`.
- «Журнал аудита»: записи мерчанта сначала новые, с русскими названиями действий и именами людей, фильтры по периоду,
  действию, каналу и сотруднику, подробности записи. `AuditQuery` получил канал, исполнителя и порядок «сначала
  новые» (поправка к ADR-0010), индекс по исполнителю.
- Действия `team.invite`, `team.invitation-revoke`, `team.role-change`, `team.disable`, `team.enable`, `team.deny`,
  `team.remove` в схеме `AuditAction`; SDK перегенерированы.
- По итогам ревью: закрытый доступ сотрудника партнёра виден как «Закрыт сотруднику партнёра» и снимается только
  возвратом доступа через партнёра (не «Открыть доступ», которое превращало запрет в членство аналитика); каждое
  изменение членства сотрудника партнёра копируется в журнал партнёра, не больше 30 таких изменений в час от человека
  (`GuardedAction::perHour`); `TenantMemberships::remove` активного членства — 409 `membership_active`; операторы
  платформы в журнале мерчанта без имени, адреса и браузера; выбранный период виден над таблицей; индексы журнала
  (по исполнителю и по каналу) строятся без блокировки записи. Каждая страница кабинетов подключает
  `GuardsCabinetPage`: подделанный запрос к приватному методу страницы или к действию строки без строки больше не
  даёт ошибку 500.

### Этап 6b-4. Приглашения

- Приглашения в консоль и в команды мерчантов (ADR-0013): ссылка только письмом на приглашённый адрес, 72 часа и
  один раз, новое приглашение на тот же адрес заменяет прежнее; не больше 20 приглашений в час от одного человека;
  ответ пригласившему один и тот же, есть ли у адреса учётная запись. Адрес хранится зашифрованным и стирается после
  принятия или отзыва, старые приглашения удаляются через 30 дней (`identity:prune-invitations`). Приглашения
  мерчантов лежат под RLS (`tenant_invitations`, функция `tenant_invitation_candidates`).
- Страница приглашения в кабинетах (ADR-0014): ссылка обменивается на сессию, адрес страницы секрета не содержит;
  новый человек задаёт имя и пароль, существующая учётная запись принимает как есть; правила команды и права
  пригласившего проверяются при принятии; принятие — в журнале безопасности и в журнале аудита команды.
- Консоль: «Приглашения в консоль» — пригласить оператора, отозвать приглашение (`operators.invite`,
  `operators.invitation-revoke`, `invitations.accept` в схеме `AuditAction`, SDK перегенерированы). Письмо-приглашение
  на русском; название мерчанта в нём — текст, а не разметка (ссылку под чужим текстом через название не подсунуть).
- Внутренние коды ошибок `invalid_invitation`, `invitation_not_found`, `too_many_invitations`.
- По итогам ревью: администратор без права на владельцев не заменит приглашение владельца; оператор не меняет роль
  по ссылке, а снятие с роли отзывает его ждущие приглашения; закрытый мерчант не принимает; страница принимает
  только показанное приглашение; переход по ссылке работает и при `SameSite=Strict` и не сбрасывает CSRF-токен
  открытых страниц; одновременные приглашения одного адреса не падают с ошибкой сервера; в записи о принятии нет
  партнёра; текстовая часть письма без лишнего экранирования.

### Этап 6b-3. Почта сотрудникам и забытый пароль

- «Забыли пароль?» на странице входа каждого кабинета (ADR-0013, ADR-0014): один и тот же ответ для любого адреса,
  письмо отправляет зашифрованная задача очереди; ссылка — только на адрес самой записи, которая может входить в этот
  кабинет, не больше 3 в час на запись и 2 запросов в минуту с одного адреса; действует 60 минут и один раз, новая
  ссылка или смена пароля отменяют прежнюю. Ссылка подписана, называет запись по id и строится от `APP_URL` и
  `CONSOLE_DOMAIN`, а не от заголовка `Host`. Новый пароль — по политике платформы, все сеансы завершаются.
- Свои страницы запроса и смены пароля вместо страниц Filament: те различали известный и неизвестный адрес и
  сохраняли пароль в обход политики. Страница со ссылкой не отправляет свой адрес другим сайтам.
- Консоль: «Отправить ссылку для смены пароля» у сотрудника платформы (`accounts.password-reset-link` в журнале аудита
  и схеме `AuditAction`, SDK перегенерированы); после сброса 2FA человек получает письмо.
- Письма сотрудникам — уведомления на русском (`identity::mail`, строки шаблона Laravel в `lang/ru.json`), в очереди,
  зашифрованы, уходят после фиксации транзакции. В журнале безопасности — событие `password_reset_link_sent`; сброс
  пароля по ссылке пишется с адресом и браузером.
- Код ошибки `invalid_reset_link` (внутренний).
- По итогам ревью: ссылка применяется под блокировкой записи (дважды одной ссылкой пароль не сменить), отключение и
  обезличивание отменяют ссылку навсегда, отказ администратору больше не гасит ссылку, которую человек запросил сам;
  поля пароля очищаются и при ожидании из-за лимита; ссылка учитывает путь в `APP_URL`; вне разработки письма
  сотрудникам не отдаются драйверам `log` и `array`; текст ошибки упавшей задачи очереди хранится без личных данных
  (поправка к ADR-0008); «мин.» вместо «минут» в письме, своя концовка письма от администратора.

### Этап 6b-2. Защищённые действия кабинетов, аудит, сотрудники платформы

- Защищённые действия кабинетов (`GuardedAction`, ADR-0014): право проверяется при показе и при выполнении,
  отказ — в журнал аудита как 403 (не больше 10 за 15 минут от одного человека), в том числе на подделанный запрос,
  который Filament отклонил бы молча; изменение в консоли и чувствительное изменение в кабинете подтверждается кодом
  второго фактора, если он не вводился последние 10 минут; ошибка домена — уведомление с русским названием кода; в
  журнал — только объявленный ввод. Действие не открывается по ссылке, форма называет того, кого касается.
- Журнал аудита принимает действия кабинетов (поправка к ADR-0010): словарь `CabinetAction` в схеме `AuditAction`
  Management API, исполнители `user` и `platform_staff`, записи консоли — без мерчанта и партнёра, действия над
  людьми мерчанта или партнёра — копией без введённых данных и в их журналы. SDK перегенерированы.
- Консоль: «Сотрудники платформы» — роли, отзыв доступа к консоли, отключение и включение учётной записи, сброс 2FA
  с причиной. В модуле сотрудников — `AccountAdministration` (`accounts.manage`, не над собой, последний
  администратор остаётся, в том числе при `staff:disable`), `StaffAccess::teamsOf` и `deniedMerchantsOf`; блокировки
  берутся в одном порядке, права действующего оператора проверяются повторно под ними.
- Русские названия кодов ошибок (`cabinet::problems`, совпадают с каталогами API) и сообщения валидации
  (`lang/ru`); API по-прежнему отвечают на английском (поправка к ADR-0006).
- Фильтр логов скрывает пароли, коды восстановления, коды подтверждения (`mfaCode`, `verification_code`) и
  секреты форм и в camelCase (`currentPassword`), и по окончаниям `_password`, `_pin`, `_secret`, `_token`.
- Исправлен тест чека, который зависел от длительности прогона: время «из будущего» бралось при загрузке набора.

### Этап 6b-1. Кабинеты на Filament: каркас, вход с 2FA, сеансы

- Filament 5.9 (Livewire 4) и модуль `cabinet` (ADR-0014): консоль платформы `/console` и кабинет мерчанта
  `/merchant/{id}` поверх контрактов модулей; мерчант берётся из URL, доступ проверяется на каждом запросе страницы и
  каждом запросе Livewire, после чего запрос работает под RLS этого мерчанта; чужой мерчант — 404. Состояние Livewire
  сбрасывается в начале каждого запроса; обновление страницы, которой нет на этом хосте, и запрос с компонентами
  разных страниц отклоняются — иначе Livewire выполнил бы их без проверок или в контексте другой страницы.
- Консоль вне разработки — только на своём хосте (`CONSOLE_DOMAIN`), кабинеты — на хосте `APP_URL`.
- Кабинет мерчанта открыт членам его команды и сотрудникам активного партнёра с доступом к данным мерчантов, если
  команда мерчанта не запретила им доступ; в переключателе — мерчанты своих команд, затем мерчанты партнёров, без
  закрытых и запретивших. `StaffAccess::deniedMerchantsOf`.
- Вход во все кабинеты: обязательная двухфакторная аутентификация (приложение-аутентификатор и коды
  восстановления; код принимается ±30 секунд и один раз), лимиты платформы вместо лимита Filament на всех шагах, на
  шаге кода пароль проверяется только у той же учётной записи, без «Запомнить меня», неверный код — в журнале
  безопасности, вход со вторым фактором отмечает сеанс подтверждённым; события 2FA (включение, выключение,
  использование кода восстановления, неверный код в профиле) пишутся в журнал. Новые коды восстановления — только
  с повторным подключением приложения: Filament выпускал их по одному паролю.
- Профиль (имя, e-mail маской, смена пароля с политикой платформы — этот браузер остаётся в системе, остальные сеансы
  завершаются), «Мои сеансы» (завершить один или все, кроме текущего), «События безопасности» с постраничным выводом;
  время — в поясе кабинетов с его указанием.
- Заголовки безопасности страниц кабинетов (`security.headers`, по HTTPS — HSTS), ответы Livewire не превращаются в
  problem+json, аватары с инициалами и шрифт Filament вместо внешних сервисов.
- `Merchants::findMany`; `PanelGate` решает о тенантах; псевдонимы middleware в контракте `StaffSessions`;
  `LocalCabinetSeeder` — демо-записи для локальной разработки.

### Этап 6a. Сотрудники: учётные записи, членство, доступ

- Модуль `identity` (ADR-0013): учётные записи сотрудников вместо стандартных таблиц Laravel — e-mail зашифрован
  ключом ПДн, поиск по слепому индексу, наружу только маска; Argon2id; токены сброса пароля в кэше по id записи;
  remember-me нет.
- Членство на трёх уровнях: операторы платформы, команды партнёров, команды мерчантов (под RLS; до выбора мерчанта —
  функция `staff_tenant_memberships`). Фиксированные роли и права, утверждённые владельцем продукта; сотрудники
  партнёра работают у его мерчантов по своей роли, отключённое членство мерчанта запрещает и такой доступ.
- Правила команд в домене: изменения — с `team.manage`, владельцы — только с `team.manage_owners`, своё членство не
  меняет никто, владелец с работающей учётной записью остаётся всегда (и последний администратор платформы);
  сотрудники партнёра командой мерчанта не управляют; операторы платформы не состоят в командах.
- Политика паролей (12–128 символов, список распространённых паролей и раскладок, без частей e-mail и имени); смена
  пароля отменяет ссылку сброса, пересчёт хеша при входе не возвращает старый пароль.
- Лимиты попыток входа без блокировки записи: 5 в минуту на e-mail с адреса и 20 в минуту с адреса, попытка
  считается до проверки пароля (ключи — HMAC e-mail); второй фактор — 5 попыток за 15 минут и 20 неудач в сутки;
  неизвестная запись стоит той же проверки хеша, неудачная проверка длится не меньше 400 мс.
- Реестр сеансов `staff_sessions`: абсолютный срок 12 часов, простой 30 минут в консоли и 120 в кабинетах, опросы не
  продлевают сеанс, вход поверх открытого сеанса закрывает его; middleware `identity.session:<panel>`; журнал
  безопасности учётных записей (append-only); обезличивание сотрудника снимает его роли и удаляет сеансы.
- Команды `staff:create`, `staff:grant`, `staff:revoke`, `staff:disable`, `identity:prune-sessions` (ежедневно).
- Вне локальной разработки и тестов страницы с сессией не обслуживаются при небезопасных настройках (нужны
  шифрование данных сессии, cookie `Secure`, `HttpOnly`, `SameSite` lax/strict, имя `__Host-…`, выключенный
  `APP_DEBUG`); проверка идёт на запросе, поэтому команды и API от неё не зависят. Доверенные прокси задаются
  `TRUSTED_PROXIES`; неаутентифицированный запрос без страницы входа получает 401 вместо ошибки 500 (`route('login')`).
- Ошибки БД в логах без значений запроса (имён, хешей паролей, шифртекстов): параметры маскируются в тексте
  `QueryException`, редактор логов скрывает строку `DETAIL`, значения в тексте ошибки PostgreSQL и хеши паролей, а
  исключение в контексте записи превращается в текст без аргументов стека (ADR-0008). Вход, проверенный до смены
  пароля, получает уже завершённый сеанс.
- Архитектурные тесты: системное соединение — только в перечисленных файлах; внутренние коды ошибок —
  `Tests\Support\ProblemCodes::INTERNAL`, без устаревших записей.

### Этап 5.6 (часть 4). PHP и TypeScript SDK

- PHP SDK `loyal/sdk` (`sdk/php`, PHP 8.2+, без зависимостей во время выполнения; curl, адаптер PSR-18) и TypeScript SDK
  `@loyal/sdk` (`sdk/typescript`, ESM, Node 22+, без зависимостей; `typescript` 6.0.3 — единственная зависимость разработки)
  поверх сгенерированного кода: клиенты Runtime и Management API, ключи идемпотентности и безопасные повторы, токены
  партнёра с обновлением, выбор мерчанта, типизированные ошибки с кодами каталога, «мог ли вызов быть обработан» для
  всего вызова, перебор страниц, проверка подписи вебхуков, маскирование секретов, помощники для тестов и песочницы.
- Контракт поведения `sdk/README.md` и эталонные векторы `sdk/conformance` (HTTP и вебхуки) — общие для обоих SDK,
  векторы проверяются по спецификациям; тесты платформы для подписи вебхуков читают те же векторы.
- Сквозные тесты PHP SDK через приложение (`Tests\Support\Sdk\KernelTransport`): быстрый старт партнёра
  (`sdk/php/examples/quickstart.php`), касса, повтор по ключу, страницы, ошибки, песочница со списанием по коду `000000`,
  подпись настоящей доставки вебхука.
- `composer check` проверяет оба SDK; CI — PHP SDK на PHP 8.2 и TypeScript SDK; Deptrac и архитектурный тест не дают
  SDK зависеть от платформы.

### Этап 5.6 (часть 3). Песочница для интеграторов

- Песочница — отдельное развёртывание той же сборки с `LOYAL_SANDBOX=true` (ADR-0012): учётные данные с маркером
  `test_` (`lk_test_…`, `lc_test_…`, `lat_test_…`, `lm_test_…`), учётные данные другого окружения — 401
  `environment_mismatch`; в каждом ответе заголовок `Loyal-Environment: live|sandbox`.
- В песочнице ничего не уходит людям: контракты `RealWorldEffect` подменяются двойниками (сейчас — отправка кодов),
  тест проверяет каждый такой контракт; принимаются только тестовые телефоны +7 900 000-00-00 … 99-99 (код всегда
  `000000`) и e-mail зарезервированных доменов, иначе 422 `sandbox_test_data_required`.
- `.env.sandbox.example`, `docs/api/sandbox.md` для интеграторов, `docs/dev/sandbox.md`, тесты режима песочницы в
  `tests/Sandbox`.

### Этап 5.6 (часть 2). Генератор SDK

- Генератор `sdk/generator` в закрытом мире: из спецификаций — типы, методы и метаданные операций, коды ошибок для
  PHP и TypeScript SDK (ADR-0011); сгенерированный код коммитится, тест следит за его актуальностью.

### Этап 5.6 (часть 1). Каталог ошибок в спецификациях

- Корневой `x-problem-codes` в обеих спецификациях: каждый код ошибки со статусом и названием; `OpenApiContract`
  проверяет по нему каждый ответ problem+json в тестах, архитектурный тест — коды в исходниках. Описаны восемь кодов,
  которых не было в спецификациях (`verification_suspended`, `invalid_phone_number`, `card_unavailable`,
  `consent_document_unavailable`, `program_unavailable`, `point_type_unavailable`, `forbidden`, `internal_error`);
  `idempotency_key_reused` в Management API — 422, как и отдаётся.
- Операции Runtime API описывают 401, 403 и 429; расчёт чека и поиск участника помечены `x-safe-to-retry`.
  Версии спецификаций — 1.1.0.

### Этап 5d. Журнал аудита

- Модуль `audit` (ADR-0010): неизменяемый журнал действий (append-only для всех ролей), записи мерчанта под RLS
  его тенанта, действия партнёра вне мерчантов видит только системная роль; контракт `AuditLog` для кабинетов.
- Management API пишет в журнал каждое изменение (с кодом ответа и ошибки), отказы 403, выдачу токенов, чтение
  данных участников и выгрузку кодов промокодов; запрос сохраняется без ПДн и секретов, с обрезкой больших
  значений. Ошибка записи в журнал не ломает выполненное действие.
- Выгрузка: `GET /api/management/v1/audit-entries` (новый скоуп `audit`; курсор, период, операция) и команда
  `audit:export` (NDJSON) для оператора платформы.
- `SensitiveDataRedactor` скрывает также `client_secret`, имя, фамилию, отчество и дату рождения (ADR-0008).
- В спецификации Management API ошибка `invalid_cursor` описана как 400, как её и отдаёт API.
- После ревью: аутентификация Management API отделена от проверки скоупа (`management.scope`) и выбора мерчанта
  (`management.merchant`), которые идут после лимита, — отказы 403 расходуют лимит и не могут без конца писать в
  журнал; `Loyal-Merchant` на операциях партнёра вне мерчантов игнорируется, их записи остаются партнёру;
  исполнитель OAuth-клиента — публичный `client_id`; телефоны маскируются во всех формах, которые принимает
  платформа, и в числах, `external_id` участника скрывается; курсор с невозможным временем — 400; индексы для
  фильтра по операции; `created` у корректировок баллов и партий промокодов; список операций (`AuditAction`) в
  спецификации и архитектурный тест маршрутов Management API.

### Этап 5c (часть 2). Акции, промокоды и участники через Management API

- Акции программы: черновик, изменение, публикация версии, запуск, пауза, архив.
- Промокоды: общие, партии уникальных с выгрузкой кодов, персональные для участника, отключение.
- Участники для бэк-офиса: поиск, карточка (маскированные контакты), история баллов, корректировки с кодом
  причины (с `Idempotency-Key` повтор не создаёт второй корректировки), фиксация уровня, заморозка,
  блокировка, снятие ограничений, обезличивание, бонусы участника. Операции леджера записывают учётные
  данные как исполнителя (`api_client`).

### Этап 5c (часть 1). Провижининг через Management API

- Программы, типы баллов (контракты `Programs`, `PointTypes`), документы согласий, черновик правил программы
  целиком, публикация и список версий.
- Организационная структура (контракт `Organization`): бренды, юрлица, точки, кассы и их отключение; ключи
  Runtime API касс и их отзыв.
- Сквозной тест критерия этапа 5: партнёр создаёт мерчанта и проводит чек только через API.

### Этап 5b. Партнёры и основа Management API

- OAuth 2.0 client credentials для партнёров: `POST /api/management/v1/oauth/token` (Basic или поля тела,
  ошибки по RFC 6749), непрозрачные токены на час; выбор мерчанта заголовком `Loyal-Merchant` только
  среди мерчантов партнёра; ключи мерчантов `lm_...`; скоупы и лимиты; команды `partners:create`,
  `oauth-clients:issue`, `oauth-clients:revoke`, `merchant-keys:issue`, `oauth-tokens:prune`.
- Модуль `management` и спецификация `docs/api/management-v1.yaml`: мерчанты партнёра (создание с
  `external_id`, список, просмотр), каталог событий, эндпоинты вебхуков и их доставки.
- Контрактная проверка тестов и архитектурный тест маршрутов работают для обеих спецификаций.

### Этап 5a. Outbox и вебхуки

- Доменные события кассы (`ReceiptConfirmed`, `ReceiptCancelled`, `ReceiptVoided`, `ReceiptReturned`),
  уровней (`TierChanged`) и бонусов (`BonusGranted`, `BonusReversed`) — внутри транзакций изменений.
- Модуль `outbox`: интеграционные события в той же транзакции, каталог типов с версиями, relay
  `outbox:relay` (каждые 5 секунд) с отказом после 10 неудач, `outbox:prune`.
- Модуль `webhooks`: эндпоинты с подпиской на события и зашифрованным секретом, доставки с подписью
  Standard Webhooks, повторы до ~2,6 суток, отключение по `410 Gone`, журнал попыток, повторная отправка,
  ротация секрета, защита от SSRF; `webhooks:deliver`, `webhooks:add`.
- `docs/api/webhooks.md` — каталог событий, семантика доставки и проверка подписи;
  `docs/examples/webhook-receiver.php` — пример получателя, проверяемый тестами.
- Исправлено: леджер запоминал id системных счетов типа баллов сразу после их создания, ещё до коммита;
  если первая операция типа баллов откатывалась (например, из-за нехватки баллов), воркер продолжал
  ссылаться на несуществующие счета. Теперь id запоминаются после коммита (регрессионный тест). Ошибку
  нашёл тест инвариантов леджера, который теперь работает на замороженных часах и с большим числом сидов.

### Этап 3c (часть 4). Штампы — этап 3 завершён

- Карты штампов в правилах программы (`stamp_cards`, до 5): свой тип баллов леджера для штампов, товары —
  условием по строке, цель N, срок жизни штампов.
- Движок: полная карта делает бесплатными самые дешёвые единицы товаров карты после скидок акций (не ниже
  нижней границы строки), за остальные целые единицы начисляются штампы; `stamps` в результате расчёта,
  `stamps.rewarded` и `stamps.earned` в объяснении; золотой вектор 18 и property-тест инвариантов. В правилах
  векторов появилось `"stamp_cards": []`, в результатах — `"stamps": []`, прочее не изменилось.
- Касса: резерв штампов бесплатных единиц с чеком, проведение и начисление при подтверждении, возврат при
  отмене, аннулировании и возвратах; офлайн-чеки только копят штампы.
- Runtime API: `stamps` в чеке, возврате и профиле участника.

### Этап 3c (часть 3). Политики сгорания баллов

- `LifecycleRules`: сгорание через N дней после активации (как раньше), в календарную дату (день и месяц
  через 0–5 лет) и после N дней без покупок; формат `lifecycle` в правилах программы и у каждого бонуса —
  `activation_delay_days`, `expiration_policy`, `expiration_days`, `expiration_date`, `expiration_years`.
  В правилах золотых векторов изменился раздел `lifecycle`, результаты не изменились.
- Леджер: скользящие партии (`sliding_expiry`) и `extendExpiry` — подтверждённая покупка продлевает срок
  всех таких баллов участника; уже просроченные не возвращаются.
- Касса начисляет и возвращает в новую партию по политике программы и продлевает срок после покупки.

### Этап 3c (часть 2). Бонусы: welcome, «приведи друга», день рождения

- Бонусы в правилах программы (`bonuses`): welcome-бонус сразу или после первой покупки от минимальной
  суммы, «приведи друга» с наградами обоим и месячным лимитом, бонус ко дню рождения за N дней до даты
  участникам со стажем; у каждого — свои задержка активации и срок жизни.
- Модуль `bonuses`: награды с зафиксированными условиями, начисление через леджер, сторно при возврате
  чека целиком или аннулировании, `bonuses:birthdays` ежечасно.
- Участники: собственный реферальный код у каждого, регистрация по коду пригласившего
  (`invalid_referral_code`), событие `MemberEnrolled`, поиск именинников по индексу.
- Реестр активных программ для системных задач (`ProgramRegistry`).
- Движок: переменная условий `member.birthday_offset`; золотой вектор 17. В правилах всех векторов
  появилось `"bonuses"`, результаты не изменились.
- Runtime API: `referral_code` при регистрации и в профиле участника.

### Этап 3c (часть 1). Уровни участников

- Уровни в правилах программы: до 10 уровней с порогами по сумме оплат деньгами и числу чеков за
  скользящее окно или календарный год, свой процент начисления у каждого уровня, срок удержания.
- Движок начисляет по проценту уровня участника (без уровня — начального), множители акций умножают его;
  в объяснении `accrual.base` указан уровень; `member.tier` в условиях акций — действующий уровень.
  Золотой вектор 16; в правилах векторов 01–15 появилось `"tiers": null`, результаты не изменились.
- Модуль `tiers`: дневная статистика покупок, повышение сразу после подтверждённой покупки, мягкое
  понижение на ступень после срока удержания (`tiers:review` ежечасно), ручная фиксация уровня до даты.
- Касса сообщает подтверждённые покупки, возвраты и аннулирования; `members:lookup` и регистрация
  участника возвращают уровень и сколько не хватает до следующего (`tier` в схеме `Member`).
- Перенос в дорожной карте: объединение дублей участников — в этап 6 (6.6), режим скидок, кратных
  количеству, — к кассовым интеграциям (9.2).

### Этап 4d (часть 2). Производительность и нагрузочный тест

- Меньше запросов к БД: расчёт чека 10 → 6, регистрация 32 → 20, подтверждение 33 → 25.
- Баланс участника одним запросом; наступившие партии одним запросом; id системных счетов и собранные
  снапшоты правил запоминаются в долгоживущем воркере.
- С чеком хранятся точность баллов и сроки активации и сгорания начисления: подтверждению и просмотру
  не нужен снапшот правил.
- Сценарий k6 `tests/load/checkout.js`, команда `processing:load-fixtures`, `docs/dev/load-testing.md`.

### Этап 4d (часть 1). Спецификация Runtime API

- `docs/api/runtime-v1.yaml` — OpenAPI 3.1 для всех 16 операций Runtime API, схемы и каталог ошибок.
- Контрактные проверки: каждый ответ API и тело каждого успешного запроса в тестах сверяются со
  спецификацией; архитектурный тест сравнивает маршруты и операции.

### Этап 4c. Участники и балансы через API, подтверждение списания, офлайн-пакеты

- Runtime API участников: `members:lookup`, регистрация с подтверждением телефона, коды для приложения,
  баланс и история баллов.
- Подтверждение списания кодом (политика программы `redemption.confirmation`): отправка кода с привязкой
  к кассе и баллам, проверка отдельным вызовом, потребление при регистрации чека.
- Антифрод: пауза списания после смены телефона, суточный лимит чеков со списанием, ручной ввод требует
  кода.
- Офлайн-пакеты `receipts:batch` в отдельной очереди: только начисление по правилам момента покупки без
  скидочных акций, результат по каждому чеку.

### Этап 4b. Модель чека и кассовый протокол

- Модуль `processing`: чеки и строки со снапшотом правил и полным расчётом, естественный ключ кассы,
  фискальные признаки, неизменяемые данные покупки.
- Runtime API: `POST /api/v1/receipts:calculate`, `POST /api/v1/receipts` (резерв или сразу подтверждение),
  `:confirm`, `:cancel` (отмена резерва или аннулирование), возвраты, просмотр чека.
- Резерв берёт баллы (холд), бюджеты и лимиты акций и промокоды; подтверждение проводит холд и начисляет
  баллы с активацией и сгоранием; возвраты отдают накопительные доли эффектов строк; истечение резервов
  `receipts:expire-reservations`.
- Модуль `tenancy`: контракт `TerminalDirectory`; `access` использует контракты программ и касс.
- ADR-0009: кассовый протокол.

### Этап 4a. Фундамент Runtime API

- Модуль `access`: ключи касс с привязкой к программе (хранится только SHA-256 секрета, поиск по префиксу
  через узкую SECURITY DEFINER-функцию), скоупы, срок действия, отзыв; команды `api-keys:issue`,
  `api-keys:revoke`.
- Middleware `api.key` (аутентификация и контекст тенанта) и `api.idempotent` (идемпотентность в одной
  транзакции с работой запроса, повтор ответа, 422 при другом запросе с тем же ключом), очистка
  `idempotency:prune`.
- Лимиты запросов на ключ и тенант с ответом 429 в формате problem+json и заголовками `RateLimit-*`.
- Этап 3c (уровни и механики) перенесён после этапа 4: механики считаются от подтверждённых покупок.

### Этап 3b. Акции и промокоды

- Акции как данные: условия на строгом подмножестве JsonLogic с белым списком переменных, выбор строк,
  эффекты (скидка процентом, суммой на чек или единицу, спеццена, баллы процентом, фиксом, множитель),
  приоритеты, группы совмещения «первая» и «лучшая», эксклюзивность, расписание, каналы, точки,
  «только участникам», бюджеты и лимиты на участника.
- Жизненный цикл акций: черновик, неизменяемые версии, активация, пауза, архив; снапшот программы хранит
  ссылки на версии активных акций, несовместимые акции не попадают в правила.
- Учёт бюджетов и лимитов (`CampaignUsage`) с атомарным списанием при подтверждении и возвратом при отмене.
- Промокоды: общие, уникальные (массовая генерация), персональные; проверка с причинами отказа, резерв,
  подтверждение и освобождение по ссылке на чек.
- Движок: скидки акций в конвейере с нижними границами строк, баллы акций, применённые акции в результате,
  новые коды объяснения; 6 новых «золотых» векторов, property-тесты акций, команда `rules:benchmark`.
- Ядро: `Allocator::cappedLargestRemainder`; быстрый путь `IntMath` и `Allocator` на нативных целых
  (в 10 раз быстрее, результат совпадает с точной арифметикой).
- ADR-0007 уточнён: эффекты — закрытый набор типов вместо формул ExpressionLanguage.

### Этап 3a. Каталог, правила программы и расчёт чека

- Модуль `catalog`: идемпотентный импорт категорий и товаров по кодам мерчанта; ограниченные товары,
  исключения из начисления, списания и акций с наследованием от категорий-предков; минимальная цена единицы.
- Модуль `rules`: черновик правил программы и публикация в неизменяемые версии (`rule_snapshots`) с хэшем
  содержимого; выбор версии, действовавшей в момент покупки.
- Движок расчёта чека: лимиты списания (доля, остаток на единицу, МРЦ, минимальная сумма чека), начисление
  на оплаченное деньгами с одним округлением на чек, распределение по строкам, объяснение расчёта.
- «Золотые» векторы расчёта с ручным расчётом и property-тесты движка.
- Леджер: контракт `PointTypeDirectory`; ядро: `Row::nullableInt`.

### Этап 2. Участники, идентификация и согласия

- Модуль `members`: регистрация с согласиями, подтверждением телефона и картой в одной транзакции; поиск
  по телефону, карте, внешнему ID и одноразовому коду; смена телефона с подтверждением; статусы;
  обезличивание; выпуск, привязка и блокировка карт.
- Модуль `consents`: версии документов и неизменяемый журнал согласий с доказательствами.
- Модуль `verification`: коды подтверждения с лимитами, паузами повторной отправки, дневным лимитом тенанта,
  автоматической остановкой при SMS pumping и привязкой к контексту операции.
- Ядро: `PhoneNumber`, `EmailAddress`, шифрование ПДн (`PiiCipher`), слепой индекс (`BlindIndex`),
  команда `pii:generate-keys`; контракт `ProgramDirectory` модуля программ.

### Этап 1. Леджер баллов

- Модуль `programs`: программы лояльности с неизменяемыми часовым поясом и валютой.
- Модуль `ledger`: типы баллов, счета участника и системные счета, проводки двойной записи, партии со
  сроками активации и сгорания, резервы, журнал движений партий.
- Операции: начисление, резерв, полное и частичное проведение, отмена, восстановление после возврата,
  сторно начисления с политиками для потраченных баллов, долг и его погашение, ручные корректировки.
- Гарантии в PostgreSQL: сбалансированность транзакций, append-only, запрет отрицательного баланса,
  неизменяемые условия типа баллов, составные внешние ключи.
- Команды `ledger:activate-due`, `ledger:expire-due`, `ledger:release-expired-holds`, `ledger:reconcile`
  (по расписанию) и `ledger:benchmark`.
- Ядро: `Immutability`, `Row`, `SqlTime`, курсорная пагинация.
- Тесты: рандомизированная проверка инвариантов, параллельные списания, интеграционные тесты команд.

### Этап 0. Фундамент

- Окружение разработки: PHP 8.5 и PostgreSQL 17 в Laragon, скрипты `scripts/dev`.
- Laravel 13, модульный монолит (`internachi/modular`), модули `kernel` и `tenancy`.
- Качество: Pint со `strict_types`, Larastan уровня 8, Deptrac с картой зависимостей модулей, Pest 5 и
  архитектурные тесты.
- Роли PostgreSQL (`owner`, `app`, `system`), команда `db:bootstrap`, Row-Level Security на всех таблицах
  тенантов, составные внешние ключи внутри тенанта.
- Контекст тенанта: синхронизация с сессией PostgreSQL, передача в задачи очереди, сброс между запросами.
- Ошибки API в формате RFC 9457, `X-Request-Id`, маскирование ПДн в логах.
- `Money`, точная целочисленная арифметика и распределение сумм методом наибольшего остатка.
- Оргструктура тенанта: партнёры, тенанты, бренды, юрлица, точки продаж, кассы.
- GitLab CI: статический анализ и тесты на PHP 8.5 + PostgreSQL 17.
- Документация: дорожная карта, ADR 0001–0008, архитектура, инструкция по окружению.
