# Архитектура

Краткая карта системы. Решения и их причины — в [ADR](adr/), план — в [дорожной карте](roadmap.md).

## Модули

Модульный монолит на Laravel 13 (ADR-0001). Модули лежат в `app-modules/`, пространство имён
`Modules\<Module>`. Публичный API модуля — `Contracts` и `Events`; остальное внутреннее.

| Модуль | Отвечает за | Этап |
|---|---|---|
| `kernel` | общее ядро: контекст тенанта, роли БД и RLS, ошибки API, деньги, время, маскирование ПДн | 0 ✅ |
| `tenancy` | партнёры, тенанты, бренды, юрлица, точки, кассы | 0 ✅ |
| `programs` | программы лояльности и их настройки | 1 ✅, 3 |
| `ledger` | учёт баллов: счета, проводки, партии, резервы | 1 ✅ |
| `members` | участники, зашифрованные ПДн, карты, одноразовые коды для кассы | 2 ✅ |
| `consents` | документы согласий и журнал согласий | 2 ✅ |
| `verification` | коды подтверждения, лимиты, защита от SMS pumping | 2 ✅ |
| `catalog` | товары и категории, ограничения и исключения, МРЦ | 3 ✅ |
| `rules` | правила программы и их снапшоты, движок расчёта чека; акции, промокоды, уровни | 3 🟡 |
| `tiers` | уровни участников: статистика покупок по дням, квалификация, мягкое понижение, ручная фиксация | 3 ✅ |
| `bonuses` | бонусы вне чеков: welcome, «приведи друга», день рождения | 3 ✅ |
| `outbox` | интеграционные события: outbox в транзакции изменения, relay к потребителям | 5 ✅ |
| `webhooks` | эндпоинты вебхуков, подписанные доставки с повторами, журнал | 5 🟡 |
| `access` | ключи Runtime API, OAuth-клиенты партнёров и ключи мерчантов для Management API, аутентификация, идемпотентность, лимиты | 4–5 🟡 |
| `management` | Management API: мерчанты партнёров, провижининг программ и организации, акции, участники, вебхуки, журнал аудита | 5 🟡 |
| `audit` | журнал аудита: неизменяемые записи действий без ПДн и секретов, выгрузка | 5 ✅ |
| `processing` | чеки и кассовый протокол: расчёт, резерв, подтверждение, отмена, возвраты | 4 🟡 |
| `identity` | сотрудники платформы, партнёров и мерчантов: учётные записи, членство в командах, роли и права, сеансы, журнал безопасности | 6 🟡 |
| `cabinet` | кабинеты на Filament поверх контрактов: консоль платформы, кабинет мерчанта (позже — партнёра) | 6 🟡 |
| `communications` | каналы, шаблоны, триггеры | 7 |
| `reporting` | отчёты и аналитика | 8 |
| `billing` | тарифы и счета | 10 |

Зависимости разрешены только в сторону ядра и публичных API других модулей; карта разрешённых зависимостей —
`deptrac.php`.

## Изоляция тенантов

```
HTTP-запрос / задача очереди
   │ ResetTenantContext (глобальный middleware) — контекст пуст
   │ аутентификация ключа кассы / партнёра → TenantContext::set(tenant_id)
   ▼
TenantContext ──► Context (логи, задачи очереди)
   │           └► PostgreSQL: set_config('app.tenant_id', …) в сессии соединения pgsql
   ▼
Eloquent: TenantScope (where tenant_id = …)      ← слой 1, приложение
PostgreSQL: RLS tenant_isolation для loyal_rls_tenant ← слой 2, база
```

- Таблица тенанта создаётся так: `TenantSchema::tenantKey($table)` (колонка `tenant_id` и ключ
  `(tenant_id, id)`), ссылки внутри тенанта — `TenantSchema::foreignWithinTenant()`, после создания —
  `Rls::enable('<table>')`. Архитектурный тест проверяет, что RLS включён на каждой таблице с `tenant_id`
  и что уникальные индексы начинаются с `tenant_id`.
- Модель таблицы тенанта наследует `Modules\Kernel\Database\TenantModel`.
- Контекст нельзя менять внутри транзакции: откат вернул бы старое значение в сессии PostgreSQL.

## Леджер баллов

Контракт — `Modules\Ledger\Contracts\Ledger` (операции) и `LedgerMaintenance` (работа по времени);
подробности модели — ADR-0003.

```
начисление ──► pending ──(активация)──► available ──(резерв)──► held ──(проведение)──► redemption
                                            │                     └──(отмена, TTL)──► available
                                            └──(сгорание)──► breakage
возврат товара: restore (redemption ──► available), reversal (available/pending ──► issuance)
```

- Каждая операция: блокировка трёх счетов участника (всегда в одном порядке) → проверка ключа
  идемпотентности → активация и сгорание наступивших по сроку партий → проводки → партии и их журнал.
- Партии тратятся от ближайшего сгорания; при отмене баллы возвращаются в те же партии.
- Плановые команды (`ledger:release-expired-holds`, `ledger:activate-due` — ежеминутно,
  `ledger:expire-due` — раз в 5 минут, `ledger:reconcile` — ежедневно в 03:00) находят работу системной
  ролью, а выполняют её от имени тенанта. Локально планировщик запускается `php artisan schedule:work`.
- `php artisan ledger:benchmark` — базовый замер горячего пути на локальной базе.

## Правила и расчёт чека

Контракты модуля `rules` — `ProgramRules` (базовые правила), `Campaigns` (акции), `PromoCodes`,
`CampaignUsage` (бюджеты и лимиты), `RuleSets` (снапшоты) и `Calculator` (движок); подробности — ADR-0007.

```
ProgramRules::publish ──┐                          rule_snapshots (v1, v2, … неизменяемы):
Campaigns::publish /    ├──► SnapshotPublisher ──► базовые правила + id версий активных акций
  activate / pause /    │    (блокировка программы,  │
  archive ──────────────┘     проверка набора)       │ RuleSets::effective(program, время покупки)
                                                     ▼
касса ──► строки + Catalog::resolve ──► Calculator(RuleSet, Basket, MemberSnapshot, CampaignState)
            промокоды ──► PromoCodes::check ─┐                          ▲
            участник ──► CampaignUsage::state ┴─────────────────────────┘
                                                     │
          CalculationResult: построчно скидка, списание, начисление; применённые акции; объяснение
                                                     │ подтверждение чека (этап 4)
                        CampaignUsage::record, PromoCodes::reserve/confirm; отмена — revert/release
```

- Конвейер движка: проверка и исключения каталога → выбор акций → скидки акций → бесплатные единицы по
  картам штампов → списание баллов → базовое начисление (процент уровня) → баллы акций → штампы.
- Движок не обращается к БД: результат зависит только от снапшота, чека, участника и состояния акций,
  поэтому расчёт повторяем, а «золотые» векторы (`app-modules/rules/tests/golden`) проверяют и порт на
  другой язык.
- Все суммы на уровне чека округляются один раз и распределяются по строкам методом наибольшего остатка:
  построчные значения всегда в сумме дают итог.
- `php artisan rules:benchmark` — замер движка на синтетическом чеке (без БД).
- Уровни (модуль `tiers`, ADR-0007): касса передаёт движку уровень участника (`Tiers::current`), после
  подтверждения чека сообщает оплаченную деньгами сумму (`recordPurchase`), при возврате и аннулировании —
  возвращённую (`recordReturn`); `tiers:review` ежечасно понижает уровни с истёкшим удержанием на ступень.
- Бонусы (модуль `bonuses`, ADR-0007): регистрация (`MemberEnrolled`) создаёт welcome- и реферальные
  награды, подтверждённый чек начисляет ждущие награды покупателя (`purchaseConfirmed`), возврат чека
  целиком и аннулирование их забирают (`purchaseCancelled`); `bonuses:birthdays` ежечасно начисляет баллы
  ко дню рождения.

## Кассовый протокол

Контракт — `Modules\Processing\Contracts\Checkout`; решения — ADR-0009. Маршруты `/api/v1/receipts…`
закрыты ключом кассы (`api.key:receipts`), лимитом (`throttle:runtime`) и, для изменений,
идемпотентностью (`api.idempotent`).

```
касса                    processing                                   леджер / правила
─────                    ──────────                                   ────────────────
receipts:calculate ───►  участник, снапшот, каталог, промокоды ──────► Calculator (без изменений)
POST receipts ────────►  пересчёт + резерв ──────────────────────────► hold, CampaignUsage::record,
                         чек reserved (30 мин)                          PromoCodes::reserve
печать чека
:confirm ─────────────►  чек confirmed ──────────────────────────────► capture, accrue (активация,
                                                                        сгорание), PromoCodes::confirm
:cancel ──────────────►  cancelled (резерв) / voided (подтверждённый) ► release | reverse + restore, revert
/returns ─────────────►  доля эффектов возвращённых количеств ───────► reverseAccrual, restore, revert
```

Остальные эндпоинты Runtime API: участники (`members:lookup`, `POST /members`, `members:send-code`,
коды приложения), коды подтверждения (`verifications/{id}:verify`, `receipts:send-redemption-code`),
баланс и история (`members/{id}/balance`, `members/{id}/history`), офлайн-пакеты (`receipts:batch`,
`receipt-batches/{id}`; обработка — задача в очереди `offline`).

## Сотрудники и доступ

Модуль `identity` (ADR-0013). Учётная запись одна на человека (`users`, e-mail зашифрован, поиск по слепому индексу
области `platform`); членство — на трёх уровнях: операторы платформы (`platform_staff`), команды партнёров
(`partner_memberships`) и команды мерчантов (`tenant_memberships`, под RLS).

```
запрос кабинета ──► сессия Laravel (зашифрованная cookie __Host-…)
                     │ identity.session:<panel> ── staff_sessions: открыт? простой панели? 12 часов?
                     ▼
                 StaffAccess::merchant(user, merchant)          (не трогает контекст тенанта)
                     1. запись активна, мерчант не закрыт
                     2. прямое членство (staff_tenant_memberships) — отключённое запрещает
                     3. иначе роль в команде активного партнёра мерчанта → роль мерчанта
                     4. (6d) доступ поддержки
                     ▼
                 MerchantAccess{role, via, permissions, readOnly}   readOnly — мерчант приостановлен
```

- Роли фиксированы, права — перечисления (`MerchantPermission`, `PartnerPermission`, `PlatformPermission`);
  чувствительные права требуют свежего подтверждения 2FA. Правила команд (последний владелец, своё членство,
  `team.manage_owners`) проверяет домен, а не интерфейс; командой мерчанта управляют только его собственные члены.
- Операторы платформы не состоят в командах партнёров и мерчантов (разделение обязанностей).
- Вход: Argon2id, политика паролей без правил состава, лимиты попыток без блокировки записи (на e-mail с адреса и на
  адрес; ключи — HMAC e-mail), лимит ошибок второго фактора, без remember-me; журнал безопасности учётной записи —
  `user_security_events`. Страницы с сессией вне разработки требуют жёстких настроек cookie
  (`RequireHardenedSessions` в группе `web`).
- Командная строка до кабинетов: `staff:create`, `staff:grant`, `staff:revoke`, `staff:disable`.

## Кабинеты

Модуль `cabinet` (ADR-0014): панели Filament над контрактами модулей, без ресурсов над чужими моделями.

```
/merchant/{id}/… ──► панель: identity.hardened (настройки cookie), cookie, сессия, CSRF,
                     │ заголовки безопасности (security.headers:panel)
                     │ Authenticate + identity.session:merchant        ← сотрудник и его сеанс
                     │ IdentifyTenant: MerchantWorkspace из Merchants   ← мерчант из URL (иначе 404)
                     │   canAccessTenant → StaffAccess::merchant
                     ▼ ApplyMerchantContext: TenantContext::set(id), CabinetPrincipal
                 страница / таблица Filament ──► контракты модулей (под RLS этого мерчанта)
запрос Livewire ──► состояние Livewire сброшено (ResetLivewireState); страницы нет на этом хосте или компоненты
                    разных страниц → 404 (RefuseUnroutedUpdates); иначе те же проверки повторяются
                    (постоянные middleware)
```

- `Kit` — общее для всех панелей: вход с обязательной 2FA и лимитами платформы, профиль, «Мои сеансы», «События
  безопасности», `CabinetPanelGate`, постраничный вывод `CursorPage` (`PagesByCursor`); `Platform` — консоль
  (`/console`, вне разработки — на своём хосте `CONSOLE_DOMAIN`), `Merchant` — кабинет мерчанта (на хосте `APP_URL`).
- Кабинеты ничего не отправляют во внешние сервисы (шрифт Filament, аватары с инициалами) и не держат данные
  тенантов вне RLS (без глобального поиска, уведомлений в базе, импорта и экспорта Filament).
- Изменение данных или чужого доступа из кабинета — `GuardedAction`: право при показе и при выполнении (отказ — 403
  в журнале аудита), второй фактор для изменений консоли и чувствительных изменений, операция контракта, запись
  `CabinetAction` с объявленным вводом (ADR-0010, ADR-0014); своя учётная запись — в журнале безопасности.

## SDK и песочница

- **SDK** (ADR-0011) лежат в `sdk/`: `sdk/generator` (свой генератор в закрытом мире), `sdk/php` (`loyal/sdk`, PHP 8.2+,
  без зависимостей), `sdk/typescript` (`@loyal/sdk`, ESM, Node 22+). Из спецификаций генерируются типы, методы и
  метаданные операций и коды ошибок (`composer sdk:generate`, сгенерированное коммитится); ручное ядро отвечает за
  аутентификацию, идемпотентность, повторы, ошибки, пагинацию и проверку вебхуков. Поведение обоих SDK одинаково и
  закреплено контрактом `sdk/README.md` и эталонными векторами `sdk/conformance`, которые проверяются по
  спецификациям. PHP SDK проверяется и сквозь приложение: `Tests\Support\Sdk\KernelTransport` отправляет его
  запросы в ядро Laravel в тесте.
- **Песочница** (ADR-0012) — отдельное развёртывание той же сборки с `LOYAL_SANDBOX=true`: учётные данные с маркером
  `test_` (и в хешах), двойники контрактов `RealWorldEffect`, только тестовые телефоны и e-mail, код `000000`,
  заголовок `Loyal-Environment`. Тесты режима песочницы — `tests/Sandbox`.

## Соглашения

- Деньги и баллы — целые минимальные единицы (`Money`, `IntMath`, `Allocator`); float запрещён.
- Код на query builder читает строки через `Modules\Kernel\Database\Row` (типизированный доступ к колонкам),
  а время пишет через `Modules\Kernel\Time\SqlTime` (микросекунды и смещение не теряются).
- Неизменяемые колонки и append-only таблицы защищаются триггерами (`Immutability::columns`,
  `Immutability::appendOnly`).
- Время — только через `Modules\Kernel\Time\Clock`; хранение — `timestamptz` с микросекундами.
- Ошибки API — `application/problem+json` (`ProblemDetails`, доменные ошибки наследуют `ProblemException`).
- Логи проходят маскирование ПДн (`RedactSensitiveData` на каждом канале).
- Код и идентификаторы — на английском, документация и тексты интерфейса — на русском.
