# Лидерра — Полная техническая и функциональная документация (v8.5)

> **Версия:** 8.5 от 07.05.2026 (реализация 27 решений аудита C из реестра v1.12).
> **Базовая версия:** 8.4 от 06.05.2026 (финал, все 13 разделов плана переписаны).
> **Рабочее название продукта:** **Лидерра**.
> **Статус:** Полное ТЗ для разработки SaaS-платформы. **68 продуктовых решений зафиксировано** (30 в v8.3 + 11 в v1.10 + 27 в v1.12). См. блок «Что нового в v8.5» ниже.
> **Готовность:** 100% по архитектурным решениям; брендинг готов (см. brandbook.md v1.1). Все P2 закрыты повторно (v1.12). Истинных P0-блокеров остался **1**: Б-1 (реквизиты юр. лица, ждут ООО). **8 P0 аудита C закрыты в v1.12** — триггер фазы 1 (`composer create-project`) разблокирован архитектурно.
> **Структура:** этот файл — основной narrative + **11 приложений** + **brandbook.md v1.1** + **Прил. Н (Tooling_v8_3.md)** + **корневой CLAUDE.md** + **дизайн-бриф для дизайнера** (см. раздел 28).

## Что нового в v8.5 относительно v8.4 (07.05.2026)

> Этот блок описывает правки v8.4 → v8.5. Источники: реестр v1.12 §13.10 (27 закрытий аудита C от 07.05.2026), schema.sql v8.5 (коммит `038a884`), CHANGELOG_schema.md §Y.

**8 P0 — реализация в schema.sql v8.5 + правки narrative ниже:**

- **Биз-17 (см. §10.X ниже)** — `projects.assignment_strategy VARCHAR(32) DEFAULT 'manual'` + CHECK `IN ('manual','round_robin','least_loaded')`. MVP = manual (паритет с оригиналом). Round-robin/least-loaded зарезервированы для Post-MVP — реализация через `project_user_assignments` (CTO-16) + cron-балансировку.
- **Биз-18 (§12.5.5)** — TTFR-SLA: `projects.ttfr_target_minutes INT DEFAULT 15`. Алерт при просрочке через event bus + UI badge на карточке. Default 15 мин — комфортный baseline для pay-per-lead-сегмента.
- **Биз-19 (§10.X)** — антифрод-дедуп по phone в окне 24 ч. `deals.duplicate_of_id BIGINT` (без FK — партиционированная таблица), дубль помечается, **НЕ списывается с баланса**.
- **CTO-13 (§22.X + Прил. И)** — обязательный e2e-тест `SET LOCAL app.current_tenant_id` через PgBouncer transaction-pooling в спринте 1. Без прохождения теста (auto-commit, reuse соединения, job retry) триггер фазы 1 не открывается.
- **OPEN-И-13 (§22.X + §23.10.X)** — Yandex 360 SSO для админов SaaS: OIDC + JIT-provisioning. Локальный 2FA выключен (заменён на 2FA провайдера IDP). Fallback — break-glass-аккаунт `super_admin` с force-2FA на случай недоступности IDP. `saas_admin_users.sso_provider` + `saas_admin_users.is_break_glass`.
- **OPEN-И-14 (§22.6)** — defense-in-depth: WITH CHECK на политики `deal_tag_pivot` и `saas_invoice_items` (закрывает INSERT-обход RLS) + REVOKE ALL на 6 saas-таблицах от `crm_app_user` (`saas_admin_users`, `saas_admin_sessions`, `saas_admin_audit_log`, `incidents_log`, `pd_subject_requests`, `impersonation_tokens`).
- **OPEN-И-15 (§14.X + §22.8 + Прил. И)** — append-only audit hash chain. `log_hash BYTEA` на 5 audit-таблицах, 10 триггеров (5 BEFORE INSERT для `audit_chain_hash()` + 5 BEFORE UPDATE/DELETE для `audit_block_mutation()`), новая роль `crm_audit_writer` (только INSERT). Cron `audit:verify-chain` пересчитывает SHA-256 chain раз в сутки.
- **OPEN-И-16 (§22.X)** — Sentry PII-scrubbing: whitelist + regex маска `phone/email/password/secret/token/api_key` во всех контекстах (request, breadcrumbs, exception args). Конфигурация в Laravel `config/sentry.php` `before_send`. Закрывает 152-ФЗ ст.6 риск.

**12 P1 — реализация в фазах 1–2:**

- **Биз-20 (§17.X)** — Telegram-бот для нотификаций менеджерам в спринте 9. `users.telegram_user_id` + `tenants.telegram_bot_token` (зашифровано). Подключение через `/start` команду.
- **Биз-21 (§19.10.X)** — generic outbound `marketing.conversion` event для интеграций с Я.Директ Offline + VK Ads Goals. Расширение whitelist событий §19.10.2 без новой таблицы.
- **Биз-22 (§10.X)** — простой lead-scoring: `lead_score = supplier.quality_score × (time_in_form_seconds / 60)`, clamped в `[0, 99.99]`. Без ML. Триггер `calc_lead_score()` BEFORE INSERT/UPDATE на `deals`.
- **CTO-14 (§12.5.5)** — UTM-поля для когортной аналитики: `deals.utm_source/utm_medium/utm_campaign/utm_content` + индекс `(tenant_id, utm_source)`.
- **CTO-15 (§22.X + §23.10.X)** — two-person impersonation для тенантов с `pd_subject_request.processing_restricted=TRUE` ИЛИ `chargeback_unrecovered_rub > 0`. `impersonation_tokens.second_approver_id` + `second_approval_at` (роль `compliance` обязательна).
- **CTO-16 (§10.X)** — `project_user_assignments(project_id, user_id, skills JSONB)` — пул менеджеров проекта для `assignment_strategy IN ('round_robin','least_loaded')` Post-MVP.
- **OPEN-И-17 (§22.X + Прил. И)** — TTL 365 дней на API-ключах. Cron `secrets:notify-expiring` уведомляет за 30 дней до expiry. UI «продлить» ставит `+365d`.
- **OPEN-И-18 (§19.10.X)** — DNS-rebinding защита для outbound webhook: resolve→pin IP→connect, blacklist RFC1918 на pinned IP, ≤3 redirect, max body 1 MB.
- **OPEN-И-19 (§19.3)** — hard limit 10 ключей: `tenants.api_key_limit INT DEFAULT 5` + `api_keys.expires_at NOT NULL DEFAULT NOW() + 365d`. Защита от DoS-через-ключи.
- **OPEN-И-20 (§14.X)** — S3 presigned URL TTL 24 ч + триггер `report_jobs_log_export()` AFTER INSERT → `pd_processing_log` action='exported' (152-ФЗ ст.18 ч.2).
- **OPEN-И-21 (§22.X + Прил. И)** — Anti-DDoS стек: Nginx `limit_req_zone` 1000 RPS глобально + Yandex SmartCaptcha на `/register` + disposable-email-blacklist (~50К доменов).
- **Ю-9 (§22.X)** — hard-блок impersonation для всех ролей кроме `compliance` при `pd_subject_request.processing_restricted=TRUE` (152-ФЗ ст.21 ч.5). Реализация совместно с CTO-15 — единый guard в `SaasAdminAuthService`.

**7 P2 — реализация в фазах 1–3:**

- **Биз-23 (§10.X)** — `deals.region_code VARCHAR(8)` + `deals.city VARCHAR(100)` + автоопределение по prefix phone через `PhonePrefixService`.
- **Биз-24 (§17.X)** — алерт через 48 ч в админке + email finance при просрочке `waiting_payment` → `paid` (cron `payments:notify-stale`).
- **OPEN-И-22 (§23.7 + Прил. И)** — per-tenant DEK в Yandex KMS, encryption envelope для бэкапов. Crypto-shred при удалении тенанта (destroy DEK).
- **OPEN-И-23 (§22.8)** — роль `crm_audit_writer` (write-only) уже создана в OPEN-И-15. Здесь — отдельный пункт реестра для прослеживаемости.
- **OPEN-И-24 (Прил. И)** — документированная процедура `pg_dump prod → pg_anonymizer → restore staging`. Автоматизация в CI. Расширение `pg_anonymizer` ставится в фазе 3 (Прил. Н).
- **OPEN-И-25 (§10.X + §17.X)** — cron `leads:escalate-stale` каждые 30 мин: если `deals.assigned_at + 4h < NOW()` AND status NOT IN (closed, rejected) → reassign + email + `escalated_count++`.
- **OPEN-И-26 (§10.4 + schema)** — закомментированный DDL `call_recordings(...)` (10 строк) в `db/schema.sql` — задел под Биз-12 Post-MVP.

**Изменённые разделы narrative (in-place ниже):**

- **§7.1 Карта таблиц** — обновлены счётчики (53→**54 таблицы** +1 `project_user_assignments`, 86→**91 индекс** +5, 33→**34 RLS-политики** +1 + WITH CHECK на 2, 34→**35 ENABLE RLS** +1, 3→**4 роли БД** +1 `crm_audit_writer`, 0→**12 триггеров**, 0→**4 функции**).
- **§10.X (новый «v8.5: Антифрод дублей + автораспределение + scoring + регион + эскалация»)** — Биз-17/19/22/23, CTO-16, OPEN-И-25/26.
- **§12.5.5 Расширенная аналитика** — Биз-18 (TTFR-SLA + alert), CTO-14 (UTM cohort).
- **§14.X (новый «v8.5: Append-only audit hash chain + export log»)** — OPEN-И-15, OPEN-И-20.
- **§17.X (новый «v8.5: Telegram + escalation + late payment alert»)** — Биз-20, Биз-24, OPEN-И-25.
- **§19.10.X (новый «v8.5: DNS-rebinding pin-IP + marketing.conversion»)** — Биз-21, OPEN-И-18.
- **§22.X (новый «v8.5: SSO + SET LOCAL test + Sentry PII + WITH CHECK + Anti-DDoS + TTL secrets + audit hash chain»)** — OPEN-И-13/14/15/16/17/21, Ю-9, CTO-13, CTO-15.
- **§23.10.X (новый «v8.5: Break-glass + two-person impersonation»)** — OPEN-И-13, CTO-15.
- **Прил. И (Runbook)** — обновлён по OPEN-И-21/22/24/25, CTO-13.

## Что нового в v8.4 относительно v8.3 (06.05.2026)

> Этот блок описывает правки v8.3 → v8.4, выполненные 06.05.2026 в одну сессию. Все 13 разделов плана v8.4 переписаны. Источники: реестр v1.10, Прил. М v1.1, брендбук v1.1, schema.sql v8.4.

**Закрыто 11 вопросов с дефолтами (реестр v1.10, 06.05.2026):**

- **Биз-6** — режим «по запросу» (15 партий аудита crm.bp-gr.ru уже сделано 04–05.05).
- **Биз-10** — модель напоминаний: отдельная таблица `reminders` минимальный паритет (text + remind_at + created_by + assignee_id NULL + completed_at + is_sent + sent_at). DDL — Прил. М v1.1 §3.5.1.
- **Биз-11** — один кошелёк в рублях (vs 4 счётчика оригинала).
- **Биз-12** — телефония: Post-MVP, на MVP не делаем.
- **Биз-13** — magic-link 24ч для менеджеров-подрядчиков (спринт 11, ~0.3 спринта).
- **Биз-14** — TTL для soft-deleted проектов: 180 дней + cron `projects:purge-deleted` disabled до Б-1.
- **Биз-15** — OSINT wizard «Рекомендации источников»: не делаем на MVP, в backlog.
- **Биз-16** — `tenants.desired_daily_numbers INT NULL` (спринт 1).
- **OPEN-К-1** — грант Cloud Boost: отложено до стабилизации.
- **OPEN-К-7** — CVoS (commitment): отложено до стабилизации нагрузки.
- **OPEN-И-12** — контакты эскалации в Notion private / 1Password DevOps до DO-4.

**Переписаны разделы narrative (in-place):**

- **§1.4 + §1.4.1** — добавлены 7 конкурентных преимуществ vs оригинал (outbound webhook, аудит мутаций, Kanban DnD, real-time push, кастомизация дашборда, REST API, security-differentiator).
- **§1.5** — формат ingress (webhook/manual/CSV), 3 чекбокса click-wrap при регистрации.
- **§14 Activity Log** — расширен список отслеживаемых событий (+ project_changed, phone_changed, phones_changed, contact_name_changed, reminder_completed, restored). Новый §14.7 «Конкурентное преимущество vs оригинал» — оригинал не логирует смену ответственного/проекта/телефона (партия 11.4 аудита).
- **§17.8** — новый подраздел «Тихие часы» с паритетом партии 13.2.1 (start_hour 0..23, end_hour 0..23, минимум 3 часа, общий timezone тенанта).
- **§18.4 Безопасность** — расширен: §18.4.4 «Уведомления о подозрительной активности» (6 типов email-алертов), §18.4.5 «Конкурентное преимущество vs оригинал» — таблица 7 функций безопасности, отсутствующих в crm.bp-gr.ru (партии 14.3.2–14.3.4). Явное упоминание Биз-9 (2FA на всех тарифах).
- **§19.10 Outbound webhook** — переписан полностью: статус MVP (был v2 «не реализуется»), 7 линий доказательств отсутствия в оригинале (партии 9–15), привязка к OPEN-И-2 (amoCRM в спринте 14–15, Bitrix24/RetailCRM Post-MVP). Подразделы 19.10.1–19.10.10: формат запроса, HMAC SHA-256, retry-логика (7 попыток до 24ч), управление в кабинете, защита от SSRF.
- **§22 Безопасность** — расширен CSP в §22.2 (полная политика). Новый §22.11 «Защита от prompt injection в DOM» — 4 уровня защиты против атаки, найденной во всех страницах оригинала (Прил. М §6, маркеры `claude-agent-stop-container/glow-border/animation-styles`). Новый §22.12 «Антипаттерны оригинала» — фиксация 3 практик, которые мы строго не повторяем (пароль plaintext в `<input type="text">`, API credentials в text, AI-маркеры) + code review checklist.
- **§7 Сущности и схема БД** — синхронизация с фактическим `schema.sql` v8.4. §7.1 — обновлены счётчики (51→**53 таблицы**, 81→**86 индексов**, 31→**34 RLS-политики на 34 защищённых таблицах** с учётом hotfix Z.5). Карта таблиц расширена до полного списка (включая `tenant_custom_domains`, `user_recovery_codes`, `user_sessions`, `project_suppliers`, `project_limit_adjustments`, `incidents_log`, `report_jobs`, `document_sequences`, `outbound_webhook_*`). DDL `deals` в §7.3 — удалены `reminder_text/reminder_at/idx_deals_reminder` (синхронизация с v8.3). Добавлены DDL `reminders` v8.3, `outbound_webhook_subscriptions`, `outbound_webhook_deliveries`. **schema.sql v8.3 → v8.4:** +2 таблицы, +5 индексов, +3 RLS (2 outbound_webhook_* + 1 deal_tag_pivot hotfix). История — [`db/CHANGELOG_schema.md`](../db/CHANGELOG_schema.md) §Z.
- **§5 Источник данных — Webhook** — §5.1 описан как «3 канала ingress» (webhook ~95%, manual ~3%, CSV ~2% от трафика). Добавлен явный блок про outbound webhook как уникальное преимущество (ссылка на §19.10 + 7 линий доказательств).
- **§8 Воронка и статусы** — §8.1 расширен: 14 статусов с явным разделением 6 системных vs 8 настраиваемых, цвета hex из брендбука. §8.2 явно фиксирует free state machine (CTO-2) с 4-пунктным обоснованием. §8.5 переписан в 4 подсекции: что можно настроить (только название на MVP, цвет/порядок Post-MVP), примеры переименования, резолв через `tenant_status_overrides` с Redis-кешем, UX inline-редактирования.
- **§9 Мои Проекты** — большой раздел. Расширен с 5 до 10 подсекций: §9.2 — таблица с лимитами/регионами/днями, §9.4 — карточка проекта (11 секций паритета с оригиналом), §9.5 — `EffectiveLimitCalculator` (главное расхождение №2 с оригиналом, см. Прил. М §3.2), §9.6 — `project_suppliers` m2m, §9.7 — soft-delete + retention 180 дней (Биз-14), §9.8 — capabilities поставщиков (B1/B2/B3, partia 13.3.5), §9.9 — расширенные действия, §9.10 — окно обслуживания (паритет не делаем).
- **§12 Дашборд** — добавлен §12.5.6 «Конверсия проектов» — паритет с оригиналом (партия 12.1): 17 колонок (id/тег/проект/статус/источник/обработано + 11 status-колонок), формат `N (XX.XX%)`, monotone per column, total-row, фильтры. Наши расширения: экспорт XLSX через `report_jobs` + drill-down из ячейки. Добавлены 2 эндпоинта в §12.6.
- **§26 Дизайн-система** — полностью переписан под брендбук v1.1: палитра Teal `#0F6E56` (вместо `#3B82F6`), сетка 4px (вместо 8px), JetBrains Mono для кода, маскирование credentials (`type="password"` + toggle).
- **§23.10 Админка SaaS** — расширен с 5 до 10 подсекций. Новые: §23.10.5 «Желаемое × факт сегодня» (Биз-16, цветовое кодирование 🟢🟡🔴), §23.10.7 «Поставщики и себестоимость» (Ю-2, дашборд маржи + сверка счетов от поставщика), §23.10.8 «Журнал инцидентов» (`incidents_log`, OPEN-Д-5 + OPEN-И-1, 8 типов × 4 severity, 24-часовой SLA уведомления РКН для `data_breach` по 152-ФЗ ст.18.1 ч.3.1), §23.10.9 «Workflow обращений субъектов ПДн» (state machine 6 статусов, 30-дневный SLA, привязка к `incidents_log`), §23.10.10 «Конкурентное преимущество vs оригинал». Полная спецификация — Прил. Г + Прил. Д.

**Все 13 разделов плана v8.4 переписаны 06.05.2026.** Финализация (переименование файла, обновление кросс-ссылок, удаление промежуточного плана) выполнена в той же сессии.

---

## Что нового в v8.3 относительно v8.2.1

В сессии 04.05.2026 проведено интервью с заказчиком, в результате зафиксировано **30 продуктовых решений**. Полный конспект сессии — в файле `Konspekt_sessii_04_05_2026_intervyu.md`. Краткая сводка по разделам:

### Облачная инфраструктура (DO-1 → ✅ закрыт; OPEN-К-2..8 закрыты)

- **DO-1:** провайдер — **Yandex Cloud**, регион `ru-central1` (Москва). Альтернативный план B (Selectel) — не выбран. См. **Приложение К**.
- **OPEN-К-3:** **single-AZ + multi-AZ-ready фундамент**: на старте одна зона (`ru-central1-a`), но создаются 3 подсети (по одной в каждой зоне), Terraform параметризован, бэкапы в Object Storage — миграция на multi-AZ при росте займёт 3–5 рабочих дней без даунтайма.
- **OPEN-К-4:** backup-стратегия — **PITR + Object Storage внутри Yandex** (без cross-cloud).
- **OPEN-К-6:** **DPA (Data Processing Agreement) с Yandex Cloud** — задача юристу (152-ФЗ ст.6 ч.3).
- **OPEN-К-8:** disaster recovery target — **RPO=1ч, RTO=4ч** (single-AZ при правильных бэкапах укладывается с запасом).
- **OPEN-К-1, К-7:** грант Cloud Boost и резервирование CVoS — **отложено** до стабилизации нагрузки.

### Биллинг (Б-2..6 → ✅; Б-1 — ждёт юр. лица)

- **Б-2:** формат выгрузки в 1С — **1С:Предприятие 8.3 + XML**.
- **Б-3:** минимальная сумма пополнения — **100 ₽** (дефолт schema.sql).
- **Б-4:** округление при конвертации ₽→лиды — **вниз** (50₽ остаток на балансе при 5050₽ → 100 лидов). Стандарт рынка, безопасно для отчётности.
- **Б-5:** реестр УПД бухгалтеру — **XLSX по email раз в месяц**.
- **Б-6:** промокоды и реферальная программа — **нет на MVP**, Post-MVP по запросу маркетинга.

### CTO / архитектура (CTO-6..11 → все ✅)

- **CTO-6:** **retry отчётов из UI** для `report_jobs(status='failed')` — кнопка «↻ Повторить» с защитой: максимум 3 retry на исходную failed-задачу, окно 7 дней, только владелец отчёта (не impersonation).
- **CTO-7:** лимит одновременных отчётов на тенанта — **3** (DoS-защита).
- **CTO-8:** партиционирование журналов (`webhook_log`, `activity_log`, `balance_transactions`) **включается на пороге 1М строк** в таблице, не сразу.
- **CTO-9:** **PgBouncer на MVP сразу** (transaction pooling между Laravel и PostgreSQL).
- **CTO-10:** `report_jobs.file_deleted` — **не добавляем**, статус остаётся `done` после `expires_at`, наличие файла проверяется по факту.
- **CTO-11:** **кнопки «Отменить» pending-платежа в UI клиента — нет**, cron самовосстанавливается через 30 минут.

### Бизнес / продакт (Биз-5, 7, 9 → ✅; Биз-6 = «есть, идёт»)

- **Биз-5:** helpdesk — **JivoSite** (внешний JS-snippet, передача атрибутов пользователя через `jivo_api`).
- **Биз-6:** доступы к crm.bp-gr.ru для сбора webhook — **есть**, скриншоты собираются параллельно через Claude в Chrome.
- **Биз-7:** восстановление отвергнутых лидов — **лайт-версия**: автоматическое восстановление при пополнении баланса с нулевого, **без UI лога отказанных** в кабинете клиента (UI только в админке SaaS). +0.5–0.7 спринта.
- **Биз-9:** **2FA доступна на всех тарифах** (снято с фичи `pro+`, является базовой возможностью аккаунта).

### Дизайн / маркетинг

- **Диз-2 ✅:** название платформы — **Лидерра**. Логотип «Поток» (3 круга в бирюзовой палитре). Брендбук v1.0 — отдельный файл `brandbook.md`. Доменное имя — **liderra.ru** (на регистрацию после Б-1).
- **Диз-1 🔧:** HTML/CSS/JS прототипы 8 экранов — в работе у Claude (Приложение Л будет готово отдельной сессией).
- **Диз-3 ⏸:** контакты подвала — после Б-1.
- **Диз-4 ✅:** контент лендинга и FAQ — **«Lorem ipsum-стиль»** на MVP, реальный контент к запуску от маркетолога.

### DevOps / эксплуатация

- **DO-2 ⏸:** домен и wildcard SSL — `liderra.ru` после Б-1.
- **DO-3 ✅:** **IP allow-list для админки SaaS — НЕТ**, вместо него **5 компенсирующих мер**: (1) rate-limit на /admin/login (5 попыток / 15 мин с IP), (2) captcha после 2 неудач, (3) email-уведомление админу при входе с нового устройства/IP, (4) `auth_log` со всеми попытками, (5) Sentry/Slack-алерт при подозрительной активности (>20 неудач/час с разных IP).
- **DO-4 ⏸:** список сотрудников SaaS с ролями — после Б-1.
- **DO-5 ✅:** **SSO для админов SaaS — Yandex 360** (нативно к Yandex Cloud).

### Юридические (Ю-6, Ю-7 → ✅)

- **Ю-6:** Cloudflare → **Yandex CDN** (закрыто фактом выбора Yandex Cloud в DO-1).
- **Ю-7 — Sentry:** **Sentry self-hosted в Yandex Cloud** (~5 тыс ₽/мес, 1 VM + storage).
- **Ю-7 — Mailgun:** **Unisender Go** в режиме SMTP-relay (Laravel `Mail::send()` без переписывания, только смена DSN).

### Решения внутри приложений

- **OPEN-Д-1 ✅:** `pd_subject_requests` дополняется полем `processing_restricted BOOLEAN DEFAULT FALSE` — флаг полной остановки обработки ПДн по требованию субъекта (ст.21 152-ФЗ).
- **OPEN-Д-5 / OPEN-И-1 ✅:** создаём таблицу `incidents_log(id, type, severity, started_at, resolved_at, summary, root_cause, postmortem_url, related_pd_subject_request_ids)` — единый журнал инцидентов для compliance и runbook.
- **OPEN-Ж-3 ✅:** способ принятия оферты — **Click-wrap**: 3 чекбокса при регистрации (с офертой согласен / с Политикой согласен / даю согласие на ПДн). Юридически достаточно, технически просто.
- **OPEN-И-2 ✅:** интеграция с внешними CRM — **Уровень 1 (webhook outbound) на MVP** + архитектурный задел для Уровня 2 (готовые коннекторы). **amoCRM-коннектор — в спринте 14–15** как пилотный. Bitrix24, RetailCRM — Post-MVP по запросу клиентов.

### Что осталось от заказчика

| Приоритет | Что | Откуда зависит |
|---|---|---|
| **P0** | **Б-1** — реквизиты юр. лица | регистрация ООО в процессе |
| P1 | Диз-3 — контакты подвала | после Б-1 |
| P1 | DO-2 — покупка домена `liderra.ru` + SSL | после Б-1 |
| P1 | DO-4 — список сотрудников SaaS с ролями | после Б-1 |
| Юристу | редактура Прил. Ж, Прил. З; согласование DPA с Yandex; зеркальная гарантия по Ю-8 | независимо |

### Что осталось мне (Claude)

| Задача | Что | Статус |
|---|---|---|
| Диз-1 | HTML/CSS/JS прототипы 8 экранов + 3 экрана ошибок → **Приложение Л** | в очереди (отдельная сессия) |
| Полный апдейт narrative по 30 решениям | Раскрыть каждое решение в соответствующих главах §5, §13, §15, §20, §22, §23, §26 | в очереди (отдельная сессия — будет v8.4) |
| Патч Прил. Г, Д, Е, Ж, З, И | Внести точечные правки по решениям интервью | этой сессии (см. файлы) |

> Эта v8.3 — **транзитная версия**: главные решения зафиксированы в шапке (этот раздел), точечные правки внесены в приложения, **полное переписывание глав narrative — в v8.4**. Разработчики могут начинать работу: список решений в этом разделе **обязателен к чтению**, дальнейший narrative ниже **отражает архитектуру до интервью** и при противоречиях с этим разделом — приоритет за этим разделом.

## Что нового в v8.2.1 относительно v8.2

Точечные правки по результатам аудита связности документации (04.05.2026), без архитектурных изменений:

- **§20.5 (биллинг)** — таблица переходов `saas_transactions` дополнена явным переходом `success → refunded` для внешнего chargeback (Ю-3). Раньше переход был описан текстом в §20.6.1, но отсутствовал в сводной таблице переходов §20.5 — выровнено с State_machines §1.
- **§22.9.5 (152-ФЗ)** — DDL `pd_subject_requests` в комментарии к полю `status` дополнен пропущенным значением `identity_verification` (промежуточный статус «ждём подтверждения личности от заявителя»). Полный enum из 6 значений теперь соответствует Прил. Д §3.

## Что нового в v8.2 относительно v8.1

Версия 8.2 не вносит архитектурных изменений — это **расширение пакета документации** четырьмя новыми приложениями, которые закрывают оставшиеся пробелы и юридические задачи v8.1:

- **Приложение Д — `Workflow_pd_subject_requests_v8_1.md`** — полный workflow обработки обращений субъектов ПДн: state machine, временна́я раскадровка внутри 30-дневного дедлайна 152-ФЗ, процедура проверки личности заявителя, фаза уведомления вторичных операторов в трёхзвенной цепочке, 7 шаблонов писем. Закрывает пробел 🟠 №7 из конспекта анализа v8.0.
- **Приложение Ж — `Oferta_i_Politika_v8_1.md`** — структурный шаблон публичной оферты и Политики конфиденциальности с учётом реселлерской модели, трёхзвенной цепочки ПДн, chargeback workflow, impersonation. Структурно закрывает Ю-5, Ю-2-доп, Ю-3-юр, Ю-8 — ждёт юр. редактуры.
- **Приложение З — `Uvedomlenie_RKN_v8_1.md`** — шаблон уведомления Роскомнадзора по форме Приказа РКН №180 от 28.10.2022, с заполненной матрицей 7 целей обработки по ч. 3.1 ст. 22 ФЗ-152. Структурно закрывает Ю-4.
- **Приложение И — `Runbook_ekspluatatsii_v8_1.md`** — практический runbook для on-call дежурного: 9 типовых инцидентов с диагностикой, действиями и эскалацией. Закрывает пробел 🟡 №11.

**Точечные правки в narrative-файле (без изменения архитектуры):**

- Раздел 22.9.4 — ремарка про шаблон уведомления РКН заменена ссылкой на Приложение З.
- Раздел 22.9.5 — ремарка про workflow `pd_subject_requests` заменена ссылкой на Приложение Д.
- Раздел 25.6 — упоминание «Runbook (документ с типовыми инцидентами)» заменено ссылкой на Приложение И.
- Раздел 28 — таблица приложений расширена до 9 строк.

> Что было нового в v8.1 относительно v8.0 (для исторической справки) — см. ниже.

## Что было нового в v8.1 относительно v8.0

Версия 8.1 фиксирует **архитектурную модель** проекта (это **реселлер** crm.bp-gr.ru, не самостоятельная CRM) и закрывает **11 P0-блокеров** разработки.

**Архитектурное (ключевое):**

- **Реселлерская модель** (Ю-2): мы покупаем лиды у crm.bp-gr.ru и перепродаём тенантам. Поток: клиент создаёт проект на нашем портале → мы передаём его в crm.bp-gr.ru → они присылают лиды webhook-ом → мы передаём клиенту. Договор подписан, мы — корп. клиент crm.bp-gr.ru.
- **Самостоятельный оператор ПДн в трёхзвенной цепочке** (Ю-2): физлицо → crm.bp-gr.ru → мы → тенант. Каждый — отдельный оператор. Источник заявок не контролируется нами (зависит от тенанта).
- **Себестоимость лидов** в БД (Ю-2): новые таблицы `supplier_lead_costs` (партиционированная синхронно с `deals`) и `supplier_invoices`. Закупочная цена — единая константа в `system_settings.supplier_default_cost_rub`.

**Биллинг (CTO-1, CTO-2, CTO-3, Ю-3, Биз-1, Биз-2):**

- `tariff_subscriptions.status` расширен до 5 значений: `scheduled / active / superseded / expired / cancelled`. Смена тарифа — через scheduled-запись, активация в 00:00 МСК (фиксированно МСК) через cron `tariffs:apply-scheduled`. История тарифов хранится бессрочно.
- При истечении подписки — **жёсткая блокировка тенанта** (`tenants.status='suspended'`, webhook → 402, лиды теряются). Намеренная асимметрия с per_lead+нулевым балансом.
- Pending транзакции: 30 минут soft + polling-fallback от cron, 24 часа hard timeout. Переход `failed → success` запрещён.
- **Chargeback workflow**: webhook `refund.succeeded` без `refund_requests` → автосоздание записи. Защита от минусового баланса через новое поле `tenants.chargeback_unrecovered_rub`.
- Отмены подписки клиентом нет — уход через смену тарифа на `start` или удаление аккаунта.
- 4 стартовых тарифа-заглушки (`start/basic/pro/enterprise`).

**Безопасность (CTO-4, CTO-5, Ю-1):**

- `users.notification_preferences` — JSONB матрица 8 типов × 3 канала вместо 4 BOOLEAN. Звук — отдельный `sound_enabled BOOLEAN`.
- **PostgreSQL Row-Level Security включён сразу на MVP**. 34 политики на 34 защищённых таблицах (1:1; включая `saas_invoice_items` через `invoice_id` JOIN и `deal_tag_pivot` через `tag_id` JOIN — паттерн для pivot/детальных таблиц без `tenant_id`), 3 роли БД (`crm_app_user`, `crm_admin_user` BYPASSRLS, `crm_migrator` BYPASSRLS). Изоляция тенантов теперь четырёхуровневая.
- **Impersonation** «Войти как клиент» — workflow с одноразовым кодом по email тенанту. 15 минут TTL, 5 попыток, сессия 1 час, чёрный список write-действий, email клиенту по завершении.

**Документация:**

- 5 приложений-артефактов: единая `schema.sql`, ER-диаграмма (6 доменных подграфов), state-machines, спецификация админки SaaS, сводный список открытых вопросов к заказчику. Подробнее — раздел 28.

> Что было нового в v8.0 относительно v7.0 (для исторической справки): перевод в SaaS-формат, два уровня биллинга, кошельковая модель, расширяемые тарифы и юрлица, Webhook-приёмник через Redis Queue, партиционирование `deals`, стартовый бонус, удаление неактивных через 12 месяцев, разделы по безопасности и инфраструктуре. Полный текст — в архиве v8.0.

---

## Содержание

0. Глоссарий
1. Обзор системы *(v8.1: реселлерская модель в 1.5, биллинг с себестоимостью в 1.6)*
2. Архитектура и стек
3. Мультитенантность *(v8.1: RLS активный в 3.2, 3 роли БД)*
4. Жизненный цикл клиента SaaS
5. Источник данных (Webhook от crm.bp-gr.ru) *(v8.1: запись supplier_lead_costs в 5.5)*
6. CSV-импорт (опциональный модуль)
7. Сущности и схема БД *(v8.1: ссылка на полную ER в Приложении Б; новые поля в DDL)*
8. Воронка продаж и статусы
9. Мои Проекты
10. Мои Сделки (таблица)
11. Kanban-доска
12. Дашборд и аналитика
13. Отчёты
14. Журнал действий
15. Шаблоны комментариев
16. Теги и метки
17. Напоминания и Push-уведомления
18. Профиль и аккаунт *(v8.1: JSONB матрица уведомлений в 18.5)*
19. Исходящий REST API
20. SaaS-биллинг (уровень 1) *(v8.1: 4 заглушечных тарифа в 20.2.2; 5 состояний enum в 20.2.3; жизненный цикл pending в 20.5; chargeback в 20.6.1; новый раздел 20.12 «Себестоимость лидов»)*
21. Лид-биллинг (уровень 2)
22. Безопасность *(v8.1: 22.6 RLS-тесты; 22.9 расширен — самостоятельный оператор, pd_subject_requests, impersonation в 22.9.6)*
23. Инфраструктура и деплой *(v8.1: новые cron в 23.5; новый раздел 23.10 «Админка SaaS»)*
24. Тестирование
25. Мониторинг и SLA
26. Дизайн-система
27. Чек-лист готовности к разработке *(v8.1: 27.1 дополнен решениями P0-блока; 27.2 со ссылкой на Приложение Е; 27.3 пересмотрен — спринты 2/3/8/9/14 утяжелены)*
28. **Приложения** *(новое в v8.1)* — список 5 артефактов

---

# 0. Глоссарий

| Термин | Расшифровка |
|---|---|
| **SaaS** | Software as a Service — программное обеспечение как услуга. Платформа предоставляется по подписке/предоплате. |
| **Тенант (Tenant)** | Клиент SaaS — отдельное юридическое или физическое лицо, использующее платформу. Данные одного тенанта изолированы от данных других. |
| **crm.bp-gr.ru** | Оригинальная CRM-система, которая является **источником данных** для нашего аналога. Лиды поступают в crm.bp-gr.ru от поставщиков B1/B2/B3, а оттуда — в наш аналог через Webhook. |
| **Webhook** | HTTP POST-запрос с JSON-телом, отправляемый crm.bp-gr.ru на URL аналога при поступлении нового лида. |
| **Лид** | Запись о потенциальном клиенте: телефонный номер + контекст (проект, тег, время поступления). В нашей системе лид становится «сделкой» (deal). |
| **Сделка (Deal)** | Запись в воронке продаж: лид + статус + история взаимодействий. |
| **Проект** | Группировка сделок по источнику/кампании. Названия пишутся в crm.bp-gr.ru с префиксами `B2_` и `B3_` — наш аналог их обрезает и склеивает в один проект. |
| **B1 / B2 / B3** | Три типа поставщиков лидов в оригинальной crm.bp-gr.ru. **В нашем аналоге** все три источника объединены в один — префиксы обрезаются на приёме. |
| **ГЦК** | Городской Центр Колл (внутренний термин crm.bp-gr.ru). Используется как название внутреннего баланса лидов: «Баланс ГЦК = X лидов». |
| **КЦ** | Колл-центр. «Диалоги КЦ» — счётчик доступных диалогов (часть баланса). |
| **Воронка продаж** | Последовательность статусов, через которые проходит сделка: Новые → Просмотрено → Проработан → ... → Оплачено / Закрыто / Конечный недозвон. Всего 14 статусов. |
| **Slug статуса** | Технический идентификатор статуса на латинице (`new`, `viewed`, `final_missed`). Хранится в БД, не зависит от языка интерфейса. |
| **Kanban** | Альтернативный вид сделок: колонки = статусы воронки, карточки = сделки, drag-and-drop для смены статуса. |
| **Trial / Стартовый бонус** | Бесплатные лиды, начисляемые новому тенанту при регистрации. По умолчанию 50, настраивается в `system_settings.trial_bonus_leads`. |
| **УПД** | Универсальный передаточный документ — российский закрывающий документ, объединяющий счёт-фактуру и акт выполненных работ. |
| **СЧФ / ДОП** | Функции УПД: СЧФ = счёт-фактура + первичка; ДОП = только первичка (без НДС). |
| **ОКПД2** | Общероссийский классификатор продукции по видам экономической деятельности — обязательный код для УПД. |
| **152-ФЗ** | Федеральный закон «О персональных данных». Регулирует хранение, обработку, удаление ПДн. |
| **2FA / TOTP** | Двухфакторная аутентификация. TOTP = Time-based One-Time Password (Google Authenticator, Яндекс.Ключ). |
| **CSRF** | Cross-Site Request Forgery — атака на сессию пользователя через сторонний сайт. Защита — токен в meta-теге. |
| **PITR** | Point-In-Time Recovery — восстановление БД на любую минуту в прошлом. WAL-G делает это для PostgreSQL. |
| **Idempotency / Идемпотентность** | Свойство операции: повторный вызов с теми же параметрами даёт тот же результат, без побочных эффектов. Реализуется через UNIQUE-индексы и UPSERT. |
| **Driver / Strategy pattern** | Архитектурный паттерн: общий интерфейс + взаимозаменяемые реализации. Используем для платёжных шлюзов и SMS-провайдеров. |
| **Глобальный scope (Eloquent)** | Механизм Laravel: автоматически добавляет условие к каждому запросу к модели (например, `WHERE tenant_id = ?`) — защита от утечки данных между тенантами. |
| **Soft delete** | Логическое удаление: запись помечается `deleted_at = NOW()`, но физически остаётся в БД. Отделяется от полного удаления. |
| **Anonymization** | Анонимизация ПДн: ФИО → "Удалённый пользователь", email → "deleted_id@deleted.local", телефон → "00000000000". Применяется при удалении пользователя по 152-ФЗ. |
| **Партиционирование** | Физическое разделение большой таблицы на куски (партиции) по диапазону значений. У нас — по `received_at` помесячно. Запросы за период читают только нужные партиции. |
| **MRR** | Monthly Recurring Revenue — ежемесячная регулярная выручка. Метрика SaaS. У нас не классический MRR (нет подписок), а сумма пополнений баланса. |
| **CAC** | Customer Acquisition Cost — стоимость привлечения одного клиента. |
| **API-ключ** | Секретная строка, по которой внешняя система авторизуется в нашем REST API. Хранится в БД только в виде хеша; оригинал показывается пользователю один раз при создании. |
| **Webhook-токен** | Уникальная секретная строка в URL Webhook (`/webhook/{tenant_secret_token}`). Используется для идентификации тенанта и защиты от посторонних запросов. |

---

# 1. Обзор системы

## 1.1. Что это такое

Аналог CRM-системы **crm.bp-gr.ru** в формате **SaaS-платформы**. Клиенты регистрируются самостоятельно, получают свой поддомен и личный кабинет, подключают свой аккаунт crm.bp-gr.ru как источник данных, и работают с лидами в нашем интерфейсе.

## 1.2. Для кого

- **Менеджеры продаж**, работающие с лидами (телефонными номерами): принимают лид → проводят по воронке продаж → фиксируют результат
- **Руководители отделов продаж**: следят за конверсией, эффективностью менеджеров, объёмом работы
- **Администраторы организаций**, использующих crm.bp-gr.ru для генерации лидов и нуждающихся в улучшенном функционале управления (Kanban, расширенная аналитика, шаблоны, теги, REST API, 2FA)

## 1.3. Что система делает

1. **Принимает лиды** от crm.bp-gr.ru через Webhook (POST JSON)
2. **Хранит сделки** в воронке продаж из 14 статусов
3. **Списывает баланс** клиента: 1 лид = -1 от баланса ГЦК
4. **Предоставляет интерфейсы** для работы со сделками: таблица, Kanban, поиск, фильтры, экспорт
5. **Считает аналитику**: дашборд, графики, отчёты, конверсия, тепловая карта активности
6. **Принимает оплату** от клиентов: карты, кошельки, банковский перевод; выставляет счета и УПД
7. **Уведомляет**: Push в браузер, email, напоминания по сделкам
8. **Предоставляет REST API** для интеграции с AmoCRM, Битрикс24, собственными системами клиента

## 1.4. Что система НЕ делает

- **Не собирает лиды самостоятельно** — данные приходят только из crm.bp-gr.ru
- **Не звонит и не делает рассылки** — для этого клиент использует интеграции (Mango, Скорозвон, Unisender)
- **Не работает голосовой робот HNS** — функционал не воспроизводится
- **Не интегрируется с Telegram-ботом** — заменён Push-уведомлениями + email
- **Нет ИИ-автопополнения баланса** — пополнение только вручную клиентом
- **Нет встроенной телефонии** (Биз-12 — Post-MVP, добавим при первом запросе клиента)
- **Нет OSINT-wizard «Рекомендации источников»** (Биз-15 — Post-MVP, в backlog)

## 1.4.1. Конкурентные преимущества vs оригинал crm.bp-gr.ru

По итогам аудита оригинала (15 партий, 04–05.05.2026, Прил. М) зафиксировано **7 функциональных преимуществ** Лидерры:

1. **Outbound webhook к тенанту** — оригинал только принимает webhook от поставщиков; у нас — двусторонний (тенант может настроить outbound в свою CRM). 7 линий доказательств отсутствия в оригинале (Прил. М §13, партия 15).
2. **Полный аудит мутаций сделки** — оригинал не логирует смену ответственного / проекта / телефона / суммы; у нас `activity_log` фиксирует все изменения (раздел 14, Прил. М §11.4 п.2, партия 12).
3. **Kanban-доска с drag-and-drop** — у оригинала только табличный вид; у нас drag-and-drop с optimistic UI и виртуализацией (раздел 11).
4. **Real-time push-уведомления** — у оригинала обновления только перезагрузкой страницы; у нас Web Push (VAPID) + snackbar в кабинете (раздел 17).
5. **Дашборд с настраиваемыми виджетами** — у оригинала фиксированный дашборд с 5 KPI; у нас — чекбоксы «показать/скрыть» в настройках (Q-Диз-F/Диз-10, Post-MVP DnD конструктор).
6. **REST API для CRM-интеграций** — у оригинала нет; у нас amoCRM-коннектор в спринте 14–15, Bitrix24/RetailCRM Post-MVP (OPEN-И-2).
7. **Безопасность аккаунта (security-differentiator):** 2FA на всех тарифах (Биз-9), журнал входов (`auth_log`), список активных сессий (`user_sessions`), recovery codes (`user_recovery_codes`) — у оригинала **ничего из этого нет** (Прил. М §2.19, партия 14).

Эти преимущества — основа маркетингового позиционирования Лидерры.

## 1.5. Как клиент попадает в систему

1. Открывает `https://liderra.ru/register` (домен после Б-1)
2. Заполняет форму: email, пароль, **3 чекбокса click-wrap** — оферта / Политика конфиденциальности / согласие на обработку ПДн (OPEN-Ж-3)
3. Подтверждает email через ссылку (24 ч TTL)
4. Автоматически создаётся **тенант** + **стартовый бонус 50 лидов**
5. **Создаёт проект** в нашем интерфейсе — выбирает один из трёх форматов ingress: **webhook** (основной, см. раздел 5), **manual** (ручное создание сделок, раздел 10.7), **CSV-импорт** (опциональный модуль, раздел 6). Для webhook-проектов мы передаём проект в crm.bp-gr.ru через API.
6. crm.bp-gr.ru начинает собирать по проекту лиды и присылает их нам webhook-ом, мы передаём клиенту через интерфейс
7. Когда стартовый бонус заканчивается — пополняет баланс (минимум 100 ₽, Б-3) через карту/кошелёк/банк. перевод. Округление вниз при ₽→лиды (Б-4).

> **Архитектурная модель v8.1 (Ю-2):** мы — реселлер crm.bp-gr.ru. Договор с ними подписан, мы — официальный корпоративный клиент. Мы покупаем у них лиды по фиксированной цене (`system_settings.supplier_default_cost_rub`) и перепродаём тенантам по тарифу. Источник заявок физлиц (где именно они оставляют контакты — на сайтах самой crm.bp-gr.ru, наших тенантов, или третьих сторон) — не контролируется нами и зависит от тенанта.

## 1.6. Бизнес-модель

| Источник дохода | Описание |
|---|---|
| **Лид-биллинг** | Клиент пополняет баланс ГЦК (например, 5000 ₽ → 100 лидов по 50 ₽). Каждый принятый Webhook списывает 1 лид. |
| **Тариф per_lead** | Стандартная цена за лид зависит от тарифа (например, Старт = 50 ₽/лид, Профессиональный = 30 ₽/лид). |
| **Тариф monthly** | Месячная подписка с пакетом лидов в комплекте (например, 5000 ₽/мес = 200 лидов; сверх — 30 ₽/лид). Опциональная модель. |
| **Тариф enterprise** | Индивидуальные условия для крупных клиентов, фиксируются в `tariff_subscriptions.custom_overrides`. |

**Себестоимость лидов (v8.1, Ю-2):** на каждый принятый лид мы платим crm.bp-gr.ru единую закупочную цену (snapshot цены на момент получения сохраняется в `supplier_lead_costs`). Маржа = тариф клиента − закупочная цена. Считается в реальном времени, отчёты — в админке SaaS (раздел 24, §4.9). Счета от crm.bp-gr.ru фиксируются в `supplier_invoices` с возможностью автоматической сверки `discrepancy_rub`.

**Возвраты:**

- **Кнопки «Отменить подписку» нет** (Биз-2). Уход из системы: переход на тариф `start` (per_lead, без абонплаты), удаление аккаунта (152-ФЗ §18.6), или естественное истечение по `expires_at`.
- **Возврат денег** возможен только по обращению клиента, через админа SaaS (раздел 20.6).
- **Chargeback** от платёжного шлюза без нашей инициативы (Ю-3) — обрабатывается автоматически с защитой от ухода баланса в минус (см. 20.6).

---

# 2. Архитектура и стек

## 2.1. Целевой стек

| Слой | Технология | Версия | Обоснование |
|---|---|---|---|
| **Backend Framework** | PHP + Laravel | 11.x | LTS, активная экосистема, встроенные Queue/Events/Notifications/Sanctum |
| **PHP** | PHP | 8.3 | Производительность, типизация, JIT |
| **Frontend Framework** | Vue.js | 3.4+ | Composition API, лучшая производительность, активная поддержка (Vue 2 — EOL декабрь 2023) |
| **State Management** | Pinia | 2.x | Стандарт для Vue 3, проще Vuex |
| **Router** | Vue Router | 4.x | Совместим с Vue 3 |
| **UI Library** | Vuetify | 3.x | Material Design, готовые компоненты, поддержка тем |
| **HTTP Client** | Axios | 1.x | Стандарт; поддержка interceptors для CSRF/auth |
| **Build Tool** | Vite | 5.x | Dev-сервер <1 сек, в 10–20× быстрее Webpack |
| **Type System** | TypeScript | 5.x | Опционально, но рекомендуется для крупного проекта |
| **СУБД** | PostgreSQL | 16 | Партиционирование, JSON, FTS, мультимастер; лучше MySQL для 10М+ строк |
| **Очередь / Кеш** | Redis | 7.x | Laravel Queue (драйвер Redis), кеш сессий, rate limiting |
| **Мониторинг очередей** | Laravel Horizon | latest | Дашборд для очередей: метрики, ретраи, фейлы |
| **Веб-сервер** | Nginx | 1.24+ | Прокси, SSL termination, rate limit на L7 |
| **PHP-сервер** | PHP-FPM | 8.3 | Стандарт для Laravel |
| **Аутентификация** | Laravel Sanctum | 4.x | Простой API token-auth + SPA cookies |
| **Поиск** | PostgreSQL FTS | builtin | Хватит до миллионов записей; на этапе масштабирования — Meilisearch / Elasticsearch |
| **Хранилище файлов** | S3-совместимое | MinIO (dev) / Yandex Object Storage (prod, по DO-1) | PDF счетов/УПД, аватары, экспортированные отчёты |
| **Email** | SMTP-relay | Unisender Go (по Ю-7) | Транзакционные письма (регистрация, счета, напоминания) |
| **Бэкапы** | WAL-G | latest | PITR, инкрементальные бэкапы PostgreSQL в S3 |
| **CI/CD** | GitHub Actions / GitLab CI | — | Тесты на каждый PR, автодеплой на staging |
| **Контейнеризация** | Docker + Docker Compose | latest | Dev и prod окружения |
| **Оркестрация (опционально)** | Kubernetes | 1.29+ | На этапе роста для авто-скейлинга воркеров |
| **APM / Errors** | Sentry | self-hosted или SaaS | Трекинг исключений, performance monitoring |
| **Метрики** | Prometheus + Grafana | latest | Метрики приложения, БД, очередей, инфраструктуры |

## 2.2. Архитектурные принципы

1. **Stateless backend**: PHP-FPM воркеры не хранят состояние между запросами. Любая нода может обработать любой запрос.
2. **Очередь как буфер**: тяжёлые операции (импорт CSV, генерация отчётов, отправка email) — в очередь, не в HTTP-цикл.
3. **Идемпотентность**: Webhook-приёмник, шлюзовые callbacks, импорт CSV — повторный вызов с теми же данными даёт тот же результат.
4. **Изоляция тенантов**: каждый запрос привязан к `tenant_id`, проверка через middleware + global scope.
5. **Расширяемость через Driver pattern**: платёжные шлюзы, SMS-провайдеры, источники лидов — все через интерфейсы, новый поставщик добавляется без правок ядра.
6. **Документация контракта**: OpenAPI 3.1 для всех публичных эндпоинтов; авто-генерация TypeScript-клиента для фронтенда.
7. **Observability**: логи структурированные (JSON), каждый запрос имеет `trace_id`, распространяющийся между сервисами.
8. **Security by default**: HTTPS only, CSP, HSTS, secure cookies, шифрование чувствительных полей в БД.

## 2.3. Уровни приложения (C4 — Container view)

```
┌──────────────────────────────────────────────────────────────────────┐
│                         КЛИЕНТЫ И ИСТОЧНИКИ                          │
└──────────────────────────────────────────────────────────────────────┘
   │ Webhook        │ Браузер           │ Мобильное          │ API-
   │ POST           │ HTTPS             │ устройство         │ интегр.
   │                │                   │                    │
   ▼                ▼                   ▼                    ▼
┌──────────────────────────────────────────────────────────────────────┐
│                            NGINX (L7 LB)                             │
│           SSL termination, rate limiting, маршрутизация              │
└──────────────────────────────────────────────────────────────────────┘
   │                                            │
   ▼                                            ▼
┌─────────────────────────┐         ┌─────────────────────────────────┐
│  Webhook Receiver       │         │     SPA Frontend (Vue 3)         │
│  (PHP-FPM, lightweight) │         │     Vuetify 3 + Pinia + Vite    │
│  Валидация → Redis      │         │     Загружается из CDN/Nginx    │
└──────────┬──────────────┘         └─────────────────────────────────┘
           │                                    │ Axios
           │                                    ▼
           │                          ┌─────────────────────────┐
           │                          │   Laravel API Backend   │
           │                          │   (PHP-FPM кластер)     │
           │                          │   - REST API            │
           │                          │   - Аутентификация      │
           │                          │   - Бизнес-логика       │
           │                          └────┬────────────┬───────┘
           │                               │            │
           ▼                               ▼            ▼
┌─────────────────────────┐         ┌──────────┐  ┌──────────────┐
│      Redis Cluster      │         │PostgreSQL│  │  S3 Storage  │
│  - Очереди (webhooks,   │◄────────┤   16     │  │  - PDF счета │
│    default, reports)    │  Воркер │ (master+ │  │  - Аватары   │
│  - Кеш                  │         │ replica) │  │  - Экспорты  │
│  - Rate limit counters  │         └────┬─────┘  └──────────────┘
│  - Сессии               │              │
└──────────┬──────────────┘              │ WAL-G
           │                             ▼
           ▼                       ┌──────────────┐
┌─────────────────────────┐        │ S3 (бэкапы)  │
│   Queue Workers          │        │ + гео-копия  │
│   (Supervisor, N штук)   │        └──────────────┘
│   - ProcessWebhookJob    │
│   - SendNotificationJob  │
│   - GenerateReportJob    │
│   - ImportCsvJob (опц.)  │
└──────────────────────────┘
           │
           ▼
┌─────────────────────────────────────────────────────────────────────┐
│            ВНЕШНИЕ СЕРВИСЫ                                          │
│   ЮKassa  │  Robokassa  │  Tinkoff  │  SMTP  │  Sentry  │  ...     │
└─────────────────────────────────────────────────────────────────────┘
```

## 2.4. Поток данных: от Webhook до сделки

```
1. crm.bp-gr.ru → POST https://liderra.ru/webhook/{tenant_token}
                  Content-Type: application/json
                  Body: {vid, project, tag, phone, phones, time}

2. Nginx → проверяет SSL, rate limit (100 RPS на токен) → проксирует на PHP-FPM

3. WebhookController:
   a. Извлекает tenant по токену из URL                        (~5 мс)
   b. Проверяет Webhook не отключён в настройках тенанта       (~1 мс)
   c. Валидирует JSON-схему (vid, project, phone — required)   (~2 мс)
   d. Логирует raw payload в webhook_log (для аудита)          (~5 мс)
   e. Диспетчит ProcessWebhookJob в Redis Queue                (~3 мс)
   f. Возвращает 200 OK                                        ~16 мс total

4. Queue Worker (асинхронно):
   a. Берёт ProcessWebhookJob из очереди
   b. Открывает транзакцию БД
   c. SELECT FOR UPDATE баланс тенанта
   d. Если баланс=0 → пишет в rejected_deals_log → шлёт email тенанту → COMMIT
   e. Если баланс>0:
      - Обрезает префикс B2_/B3_ из project
      - findOrCreate проект (по tenant_id + clean_name)
      - Идемпотентность через advisory lock (v8.7):
        · pg_advisory_xact_lock(tenant_id ⊕ vid) — сериализует операции
        · SELECT в webhook_dedup_keys — атомарно из-за lock
        · Если найдено — UPDATE deal по composite-ключу (id, received_at)
        · Иначе — INSERT deal первым → INSERT dedup_key с deal.id
      - Уменьшает баланс на 1 (только для новой сделки)
      - Пишет BalanceTransaction (-1 лид)
      - Записывает событие deal.created в activity_log
   f. COMMIT
   g. Диспетчит SendNotificationJob (push/email о новом лиде, если включено)

5. Если воркер упал — Job уходит в retry queue (3 попытки с задержкой 60 сек).
   После 3 неудач — в failed_jobs, alert в Sentry.
```

## 2.5. Базовый принцип масштабирования

| Узкое место | Как масштабируется |
|---|---|
| **HTTP-приём** | Горизонтально: добавляем PHP-FPM ноды за Nginx-балансировщиком |
| **Воркеры очередей** | Горизонтально: добавляем процессы Supervisor; Laravel Horizon балансирует |
| **БД на чтение** | Read-replica PostgreSQL; Laravel умеет роутить SELECT-ы на реплики |
| **БД на запись** | Партиционирование по `received_at`; в дальнейшем — шардирование по `tenant_id` |
| **Поиск** | На малых объёмах — PostgreSQL FTS; на больших — выделенный Meilisearch/Elasticsearch |
| **Push-уведомления** | Очередь Redis; внешний сервис (FCM) держит коннекты |

---

# 3. Мультитенантность

## 3.1. Модель изоляции данных

**Выбранная модель:** Single Database, Shared Schema с обязательным полем `tenant_id` в каждой бизнес-таблице + партиционирование `deals` по `received_at`.

**Почему не Schema-per-tenant**: при тысячах тенантов миграции становятся очень медленными (применяются к каждой схеме отдельно), плюс агрегированные SaaS-отчёты (общая выручка, общее число активных тенантов) превращаются в `UNION ALL` по тысячам схем.

**Почему не Database-per-tenant**: дороже эксплуатация (тысячи коннекшнов, тысячи бэкапов), сложнее CI/CD, агрегация невозможна без отдельного DWH.

**Shared Schema** — компромисс: одна БД, один пул соединений, простые миграции, простая агрегация. Цена — риск утечки между тенантами при ошибке в коде. Митигация — ниже.

## 3.2. Защита от утечки данных между тенантами

**Уровень 1 — Eloquent Global Scope.** Все модели с `tenant_id` наследуются от базового `TenantScopedModel`:

```php
abstract class TenantScopedModel extends Model
{
    protected static function booted(): void
    {
        static::addGlobalScope('tenant', function (Builder $builder) {
            if ($tenantId = app(TenantContext::class)->getTenantId()) {
                $builder->where($builder->getModel()->getTable() . '.tenant_id', $tenantId);
            } elseif (!app()->runningInConsole()) {
                throw new \RuntimeException('Tenant context not set');
            }
        });

        static::creating(function ($model) {
            if (!$model->tenant_id) {
                $model->tenant_id = app(TenantContext::class)->getTenantId();
            }
        });
    }
}
```

**Уровень 2 — Middleware.** Из поддомена/токена/Bearer-ключа извлекается `tenant_id`, кладётся в `TenantContext` (singleton), проверяется доступ пользователя к этому тенанту.

**Уровень 3 — Тесты изоляции.** Обязательный набор feature-тестов: «пользователь тенанта A не должен видеть данные тенанта B по любому из эндпоинтов». Покрытие — каждый защищённый ресурс.

**Уровень 4 — БД-уровень (CTO-5: ВКЛЮЧЁН на MVP).** PostgreSQL Row-Level Security:

```sql
ALTER TABLE deals ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON deals
    USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
```

Перед каждым запросом приложение делает `SET LOCAL app.current_tenant_id = X` в той же транзакции. Это защищает даже от ошибок ORM (забытый global scope, сырой SQL без `WHERE tenant_id`).

**Роли БД (раздел 13 `schema.sql` v8.1):**

- `crm_app_user` — обычная роль для клиентского приложения, на неё действуют RLS-политики;
- `crm_admin_user` — отдельная роль для админки SaaS с **`BYPASSRLS`** (админка обоснованно работает кросс-тенантно);
- `crm_migrator` — роль для миграций с `BYPASSRLS` (нужен для создания партиций `deals` и `supplier_lead_costs`).

**Очереди и cron:** все Job-классы, работающие с tenant-данными, наследуются от базового `TenantAwareJob`, который в `handle()` устанавливает `app.current_tenant_id` из своих параметров. Это закрывает риск «забыли установить контекст» для асинхронных задач.

**Cross-tenant cron** (например, `tariffs:apply-scheduled`) — разбивается на per-tenant суб-джобы с установленным контекстом.

Полный список из 34 политик на 34 защищённых таблицах (`saas_invoice_items` и `deal_tag_pivot` — через JOIN-подзапрос, у обеих нет прямого `tenant_id`) — в `db/schema.sql` v8.4.

> **Для уведомления в Роскомнадзор (Ю-4):** «изоляция данных тенантов реализована на четырёх уровнях, включая Row-Level Security в PostgreSQL» — это сильный аргумент при проверках.

## 3.3. Идентификация тенанта

Тенант определяется по поддомену, который кладётся в middleware:

```
client1.liderra.ru → TenantResolveMiddleware → SELECT * FROM tenants WHERE subdomain = 'client1' → TenantContext::set($tenant)
```

Кеш разрешения: `tenants:by_subdomain:{subdomain}` в Redis с TTL 5 минут.

**Кастомные домены** (опционально, для старших тарифов):

```sql
CREATE TABLE tenant_custom_domains (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id),
    domain          VARCHAR(255) UNIQUE NOT NULL,
    ssl_status      VARCHAR(20) DEFAULT 'pending', -- pending, active, error
    verified_at     TIMESTAMPTZ,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);
```

Клиент привязывает `crm.client1.com` → CNAME на наш сервер; мы выпускаем Let's Encrypt сертификат через ACME. Реализация — на будущее, не для MVP.

## 3.4. SaaS-уровневые сущности (общие для всех тенантов)

```sql
CREATE TABLE tenants (
    id                  BIGSERIAL PRIMARY KEY,
    subdomain           VARCHAR(63) UNIQUE NOT NULL,        -- "client1"
    organization_name   VARCHAR(255) NOT NULL,
    contact_email       VARCHAR(255) NOT NULL,
    status              VARCHAR(20) DEFAULT 'active',       -- active, suspended, pending_email_confirm, deleted
    webhook_token       VARCHAR(64) UNIQUE NOT NULL,        -- для URL Webhook
    webhook_token_rotated_at TIMESTAMPTZ,
    timezone            VARCHAR(50) DEFAULT 'Europe/Moscow',
    locale              VARCHAR(10) DEFAULT 'ru',
    -- Биллинг
    current_tariff_id   BIGINT REFERENCES tariff_plans(id),
    balance_rub         DECIMAL(12,2) DEFAULT 0,            -- денежный баланс (₽)
    balance_leads       INT DEFAULT 0,                      -- баланс лидов
    is_trial            BOOLEAN DEFAULT TRUE,               -- получает ли стартовый бонус
    trial_leads_used    INT DEFAULT 0,
    -- Chargeback (v8.1, Ю-3): непокрытая балансом часть внешнего возврата.
    -- Если > 0 → tenants.status = 'suspended', клиент видит экран погашения в /billing.
    chargeback_unrecovered_rub DECIMAL(12,2) DEFAULT 0
                        CHECK (chargeback_unrecovered_rub >= 0),
    -- Активность
    last_activity_at    TIMESTAMPTZ,                        -- для отслеживания неактивных
    last_webhook_at     TIMESTAMPTZ,
    -- Метаданные
    created_at          TIMESTAMPTZ DEFAULT NOW(),
    deleted_at          TIMESTAMPTZ                          -- soft delete
);

CREATE INDEX idx_tenants_subdomain      ON tenants(subdomain) WHERE deleted_at IS NULL;
CREATE INDEX idx_tenants_webhook_token  ON tenants(webhook_token) WHERE deleted_at IS NULL AND status = 'active';
CREATE INDEX idx_tenants_inactive       ON tenants(last_activity_at) WHERE deleted_at IS NULL;
```

```sql
CREATE TABLE system_settings (
    key             VARCHAR(100) PRIMARY KEY,
    value           TEXT NOT NULL,
    type            VARCHAR(20) DEFAULT 'string',           -- string, int, decimal, json, bool
    description     TEXT,
    updated_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_by      BIGINT
);

INSERT INTO system_settings (key, value, type, description) VALUES
    ('trial_bonus_leads',             '50',  'int', 'Стартовый бонус лидов для нового тенанта'),
    ('low_balance_threshold_leads',   '10',  'int', 'Порог email-предупреждения о низком балансе'),
    ('inactive_warn_months',          '11',  'int', 'Через сколько месяцев простоя слать предупреждение'),
    ('inactive_delete_months',        '12',  'int', 'Через сколько месяцев простоя удалять данные'),
    ('webhook_rate_limit_rps',        '100', 'int', 'Лимит запросов в секунду на токен Webhook'),
    ('api_rate_limit_per_minute',     '60',  'int', 'Лимит запросов API на ключ в минуту'),
    ('login_max_attempts',            '5',   'int', 'Макс. неудачных попыток входа в окне 15 минут'),
    ('password_min_length',           '10',  'int', 'Минимальная длина пароля'),
    ('webhook_log_retention_days',    '90',  'int', 'Сколько дней хранить raw_payload Webhook');
```

## 3.5. Tenant-уровневые сущности

Все остальные таблицы (`projects`, `deals`, `users`, `comment_templates`, `deal_tags`, `reminders`, `transactions` лид-уровня и т.д.) содержат поле `tenant_id BIGINT NOT NULL REFERENCES tenants(id)` и индекс по нему.

Подробнее в разделе 7 («Сущности и схема БД»).

---

# 4. Жизненный цикл клиента SaaS

## 4.1. Регистрация и онбординг

### Шаг 1. Открытая регистрация

URL: `https://liderra.ru/register`

**Форма:**

| Поле | Валидация |
|---|---|
| Email | required, email, unique по `tenants.contact_email` |
| Пароль | required, min:10, проверка через zxcvbn ≥ 3 |
| Желаемый поддомен | required, lowercase, [a-z0-9-]{3,30}, unique по `tenants.subdomain`, не из чёрного списка (admin, api, www, mail, ftp, billing, support и т.д.) |
| Название организации | required, max:255 |
| Согласие на обработку ПДн | required (152-ФЗ), checkbox |
| Согласие с офертой | required, checkbox |
| Согласие с Политикой конфиденциальности | required, checkbox (см. §1.5 — 3 click-wrap чекбокса, OPEN-Ж-3) |

**Чёрный список поддоменов** (хранится в `system_settings.subdomain_blacklist` как JSON):

```json
["www","api","admin","root","support","help","mail","ftp","blog","status","docs","app","dev","staging","test","beta","cdn","assets","static","billing","payments","auth","login","register"]
```

### Шаг 2. Подтверждение email

После заполнения формы:

1. Создаётся запись в `tenants` со статусом `pending_email_confirm`
2. Создаётся запись в `users` с привязкой к `tenant_id` (роль = `admin`, единственный пользователь тенанта)
3. Генерируется `email_verification_token`, хранится в `email_verifications`
4. Отправляется письмо со ссылкой `https://liderra.ru/verify-email/{token}` (TTL 24 часа)

### Шаг 3. Активация

Клиент кликает ссылку:

1. Токен валидируется (не истёк, не использован)
2. `tenants.status` → `active`
3. **Начисляется стартовый бонус**: `tenants.balance_leads += system_settings.trial_bonus_leads` (по умолчанию 50)
4. Создаётся запись в `balance_transactions`: тип `trial_bonus`, +50 лидов
5. Клиент перенаправляется на `https://{subdomain}.liderra.ru/onboarding`

### Шаг 4. Онбординг (3 шага мастера)

**Шаг 4.1. Подключение Webhook.**

Показываем клиенту:

- Его уникальный URL Webhook: `https://liderra.ru/webhook/{webhook_token}`
- Инструкцию: «Перейдите в crm.bp-gr.ru → /admin/user/api → вставьте URL → активируйте»
- Кнопку «Тест Webhook» (отправляет тестовый запрос на наш приёмник, чтобы клиент убедился, что всё работает)

**Шаг 4.2. Краткий тур по интерфейсу.**

3-4 экрана с подсказками: Дашборд, Сделки, Kanban, Биллинг. Можно пропустить.

**Шаг 4.3. Первое пополнение (опционально).**

Кнопка «Пополнить баланс» с предложением минимальной суммы. Клиент может пропустить и работать на стартовом бонусе.

## 4.2. Активная работа

```
Клиент в crm.bp-gr.ru настроил Webhook → POST на наш URL
                                ↓
                    Наша система принимает лид (см. раздел 5)
                                ↓
        ┌───────────────────────┴───────────────────────┐
        ↓                                               ↓
  balance_leads > 0                              balance_leads = 0
        ↓                                               ↓
  Создаётся сделка                          Лид отвергается, лог в
  Списывается -1 лид                        rejected_deals_log
  Менеджер работает                         Email тенанту "Пополните баланс"
        ↓
  Через несколько дней
  баланс падает < 10
        ↓
  Email предупреждение
  "Скоро лиды закончатся"
```

## 4.3. Низкий баланс и нулевой баланс

| Состояние | Действия |
|---|---|
| `balance_leads ≤ low_balance_threshold` (default: 10) | Однократный email тенанту: «У вас осталось N лидов, рекомендуем пополнить». Флаг `low_balance_notified_at` чтобы не дублировать. Сбрасывается при пополнении. |
| `balance_leads = 0` | Каждый входящий Webhook логируется в `rejected_deals_log` (с raw payload — для аналитики и ручного импорта при пополнении). Email тенанту: «Лид отвергнут, баланс пуст» (с rate-limit на email — не чаще 1 раз в час). |

**Важно:** при `balance_leads = 0` платформа продолжает полностью работать — клиент может смотреть аналитику, вести существующие сделки, управлять менеджерами. Не работает только приём новых лидов.

## 4.4. Пополнение баланса

См. раздел 20 (SaaS-биллинг).

Кратко: клиент выбирает сумму → выбирает способ оплаты (карта/кошелёк/перевод) → проводит платёж → после подтверждения от шлюза `balance_rub` увеличивается → конвертируется в лиды по текущему тарифу → `balance_leads` увеличивается.

**Конвертация лидов:** при пополнении 5000 ₽ на тарифе «Старт» (50 ₽/лид) клиент получает 100 лидов. Для тарифов с подпиской (`monthly`) — фиксированный пакет лидов в месяц, дополнительные сверх лимита по `price_per_lead`.

## 4.5. Неактивность и удаление

```
Активность отслеживается полем tenants.last_activity_at
Обновляется при: входе пользователя, любом API-запросе, приёме Webhook
                                ↓
         Cron-задача `tenants:check-inactive` (раз в сутки)
                                ↓
   ┌─ last_activity_at > 11 месяцев назад И balance_leads = 0
   │  → отправить email-предупреждение
   │  → пометить inactive_warned_at = NOW()
   │
   ├─ inactive_warned_at > 1 месяц назад (т.е. прошло 12 месяцев простоя)
   │  → soft delete тенанта (deleted_at = NOW())
   │  → анонимизация ПДн в users, deals
   │  → удаление webhook_token (Webhook перестаёт работать)
   │
   └─ deleted_at > 30 дней назад
      → физическое удаление всех данных тенанта
      → cascade delete: deals, projects, transactions, ...
      → запись в audit_log на уровне SaaS
```

**Ручное удаление по запросу клиента (152-ФЗ):**

В личном кабинете → Профиль → Удалить аккаунт. Запрос подтверждается через email-ссылку. После подтверждения — те же шаги: soft delete → 30 дней anonymization → полное удаление.

---

# 5. Источник данных — Webhook от crm.bp-gr.ru

## 5.1. Принцип

**Лидерра не собирает лиды самостоятельно** и не интегрируется с поставщиками B1/B2/B3 (см. §3.5 о структуре поставщиков в crm.bp-gr.ru). У нас три канала ingress:

| Канал | Доля трафика на MVP | Описание | Раздел |
|---|---|---|---|
| **Webhook от crm.bp-gr.ru** | ~95% (основной) | Приём POST JSON от оригинальной CRM при появлении нового лида | §5 (этот) |
| **Ручное создание** | ~3% | Менеджер заводит сделку в UI: нет лида в CRM, но есть звонок/email/чат, и его надо ввести вручную | §10.4 |
| **CSV-импорт** | ~2% | Разовая загрузка исторических лидов или массовое восстановление при сбое webhook | §6 (опциональный модуль) |

**Webhook — основной канал**, остальные дополняют его. CSV-импорт — отдельный опциональный модуль (см. §6), не зависит от webhook. Ручное создание — стандартная форма в карточке сделки (§10.4).

Каждый тенант:

1. Регистрируется в нашем аналоге → получает уникальный URL Webhook
2. Идёт в свой crm.bp-gr.ru → /admin/user/api → вставляет URL → активирует
3. Дальше каждый новый лид в crm.bp-gr.ru автоматически дублируется в Лидерра

Один тенант = одно подключение к одному аккаунту crm.bp-gr.ru.

**Исходящие события (outbound webhook) → уникальное преимущество.** В дополнение к **приёму** webhook от crm.bp-gr.ru, Лидерра умеет **отправлять** webhook во внешние системы тенанта (CRM клиента, n8n, Zapier, корпоративный data-pipeline). Подробно в [§19.10 Outbound webhook](#1910-outbound-webhook-подписка-внешних-систем-на-события). Это конкурентное преимущество №1 (см. [§1.4.1](#141-конкурентные-преимущества-vs-оригинал-crmbp-grru)) — в оригинале outbound webhook subsystem отсутствует, что подтверждено 7 независимыми линиями доказательств (см. [Прил. М §9.4.2](Analiz_originala_v8_3.md), партии 9–15).

## 5.2. Формат Webhook (вход)

**HTTP:**

```
POST https://liderra.ru/webhook/{tenant_webhook_token} HTTP/1.1
Content-Type: application/json
```

**Тело запроса:**

```json
{
  "vid": 432176649,
  "project": "B2_Caranga",
  "tag": "Caranga",
  "phone": "79000000000",
  "phones": ["79000000000"],
  "time": 1703781939
}
```

**Поля:**

| Поле | Тип | Обязательное | Описание |
|---|---|---|---|
| `vid` | int | ✅ | Уникальный ID посетителя в crm.bp-gr.ru. Используется для идемпотентности. |
| `project` | string | ✅ | Название проекта в crm.bp-gr.ru. Может содержать префикс `B2_` или `B3_` — наша система их обрезает. |
| `tag` | string | ✅ | Тег проекта. |
| `phone` | string(11) | ✅ | Телефон в формате `7XXXXXXXXXX` (ровно 11 цифр, без `+`). |
| `phones` | array | — | Массив всех телефонов посетителя; `phone` всегда внутри. |
| `time` | int | ✅ | Unix timestamp события в секундах. |

## 5.3. Защита Webhook

| Уровень | Механизм |
|---|---|
| **Идентификация тенанта** | Уникальный `webhook_token` в URL (64 символа, генерируется при регистрации). При компрометации клиент перевыпускает токен в личном кабинете → старый URL перестаёт работать. |
| **HTTPS only** | HTTP-запросы редиректятся на HTTPS; Strict-Transport-Security включён. |
| **Rate limiting** | По умолчанию 100 RPS на токен (Redis-counters, скользящее окно). Настройка — `system_settings.webhook_rate_limit_rps`. Превышение → 429. |
| **Валидация JSON-схемы** | Если поля не соответствуют схеме — 400 без обработки. |
| **Размер payload** | Лимит 16 КБ (Nginx `client_max_body_size 16k` для пути `/webhook/`). |
| **HMAC-подпись** | crm.bp-gr.ru подпись не отправляет (на момент анализа), поэтому HMAC у нас не проверяется. **На случай, если crm.bp-gr.ru добавит подпись в будущем** — оставляем поле `tenants.webhook_hmac_secret` опциональным; при наличии секрета будем проверять заголовок `X-Signature`. |

**Перевыпуск токена:**

```sql
UPDATE tenants
   SET webhook_token = ?,
       webhook_token_rotated_at = NOW()
 WHERE id = ?;
```

После перевыпуска клиент должен обновить URL в crm.bp-gr.ru вручную. Старый токен мгновенно недействителен.

## 5.4. Логика обработки на приёмнике

```php
// routes/api.php
Route::post('/webhook/{token}', [WebhookController::class, 'receive'])
    ->middleware(['rate.limit.webhook', 'tenant.by.webhook.token']);
```

```php
// WebhookController.php
public function receive(Request $request)
{
    // Тенант уже разрешён middleware'ом и положен в TenantContext
    $tenant = app(TenantContext::class)->getTenant();

    // Если тенант не активен — отказываем без обработки
    if ($tenant->status !== 'active') {
        return response()->json(['status' => 'tenant_inactive'], 403);
    }

    // Валидация
    $data = $request->validate([
        'vid'     => 'required|integer',
        'project' => 'required|string|max:255',
        'tag'     => 'required|string|max:255',
        'phone'   => 'required|string|size:11|regex:/^7\d{10}$/',
        'phones'  => 'nullable|array',
        'phones.*'=> 'string|regex:/^7\d{10}$/',
        'time'    => 'required|integer',
    ]);

    // Лог raw payload (для аудита и пересчёта при ошибках)
    $logId = WebhookLog::create([
        'tenant_id'   => $tenant->id,
        'raw_payload' => $request->getContent(),
        'received_at' => now(),
    ])->id;

    // Обновляем активность тенанта (для отслеживания неактивных)
    $tenant->update(['last_webhook_at' => now()]);

    // Диспетч в очередь — обработка асинхронно
    ProcessWebhookJob::dispatch($tenant->id, $data, $logId)
        ->onQueue('webhooks');

    return response()->json(['status' => 'accepted'], 200);
}
```

**Время ответа:** ≤100 мс p95. Это критично — если приёмник тормозит, crm.bp-gr.ru может потерять лид (retry-механизма у источника нет).

## 5.5. ProcessWebhookJob — асинхронная обработка

```php
class ProcessWebhookJob implements ShouldQueue
{
    public int $tries = 3;
    public int $backoff = 60;
    public int $timeout = 30;

    public function __construct(
        public int $tenantId,
        public array $data,
        public int $logId
    ) {}

    public function handle(LeadIntakeService $service): void
    {
        DB::transaction(function () use ($service) {
            $tenant = Tenant::lockForUpdate()->find($this->tenantId);

            // Проверка баланса
            if ($tenant->balance_leads <= 0) {
                RejectedDealsLog::create([
                    'tenant_id'   => $tenant->id,
                    'webhook_log_id' => $this->logId,
                    'reason'      => 'zero_balance',
                    'payload'     => $this->data,
                    'created_at'  => now(),
                ]);

                // Лимит один email в час на тенант
                if ($tenant->should_notify_zero_balance()) {
                    Mail::to($tenant->contact_email)->queue(new ZeroBalanceMail($tenant));
                }
                return;
            }

            // Обрезка префикса B2_/B3_
            $cleanProjectName = preg_replace('/^B[123]_/', '', $this->data['project']);

            // Проект (создаётся или находится)
            $project = Project::firstOrCreate(
                ['tenant_id' => $tenant->id, 'name' => $cleanProjectName],
                ['tag' => $this->data['tag'], 'created_at' => now()]
            );

            // Идемпотентность через advisory lock (v8.7).
            // PostgreSQL запрещает UNIQUE на партиционированной deals без partition key,
            // поэтому ключ (tenant_id, source_crm_id) → deal_id вынесен в отдельную
            // не-партиционированную webhook_dedup_keys (v8.6, CTO-17). См. db/schema.sql:1289.
            //
            // Стратегия v8.7: pg_advisory_xact_lock сериализует операции с
            // (tenant_id, source_crm_id) → SELECT в dedup_keys атомарен →
            // INSERT-deal-первым (FK validate immediate проходит) → INSERT dedup_key.
            //
            // Почему НЕ INSERT-в-dedup-keys-первым (как было в v8.6 спецификации):
            // даже с DEFERRABLE INITIALLY DEFERRED FK — в Laravel-тестах
            // (DatabaseTransactions trait, outer-txn + savepoint) PG проверяет
            // deferred FK на RELEASE SAVEPOINT, не на outer COMMIT. Двустадийный
            // INSERT падает с FK violation. Это PG-семантика subtransactions.
            // Advisory lock работает identically в любой вложенности.
            $receivedAt = Carbon::createFromTimestamp($this->data['time']);

            // Стадия 1: advisory lock на (tenant_id, vid). Lock авто-освобождается
            // на COMMIT/ROLLBACK. Конкурентные webhook'ы с тем же vid сериализуются.
            // Lock-key = (tenant_id << 32) | (vid & 0xFFFFFFFF) — один bigint
            // (vid из crm.bp-gr.ru помещается в int32).
            $lockKey = (($tenant->id & 0xFFFFFFFF) << 32) | ($this->data['vid'] & 0xFFFFFFFF);
            DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]);

            // Стадия 2: SELECT в dedup_keys атомарен из-за advisory lock.
            $existing = DB::selectOne(
                'SELECT deal_id, deal_received_at FROM webhook_dedup_keys WHERE tenant_id = ? AND source_crm_id = ?',
                [$tenant->id, $this->data['vid']],
            );

            if ($existing !== null) {
                // Дубль vid — UPDATE по composite-ключу (id, received_at).
                // partition pruning через received_at попадает в одну партицию.
                $deal = Deal::where('id', $existing->deal_id)
                    ->where('received_at', $existing->deal_received_at)
                    ->firstOrFail();
                $deal->update([
                    'phone'   => $this->data['phone'],
                    'phones'  => $this->data['phones'] ?? [$this->data['phone']],
                    // status не перезаписываем — менеджер мог изменить.
                ]);
                $isNew = false;
            } else {
                // Новый vid: INSERT deal первым (FK immediate OK), затем dedup_key.
                $deal = Deal::create([
                    'tenant_id'      => $tenant->id,
                    'source_crm_id'  => $this->data['vid'],
                    'project_id'     => $project->id,
                    'phone'          => $this->data['phone'],
                    'phones'         => $this->data['phones'] ?? [$this->data['phone']],
                    'status'         => 'new',
                    'received_at'    => $receivedAt,
                ]);
                DB::table('webhook_dedup_keys')->insert([
                    'tenant_id'        => $tenant->id,
                    'source_crm_id'    => $this->data['vid'],
                    'deal_id'          => $deal->id,
                    'deal_received_at' => $deal->received_at,
                    'created_at'       => now(),
                ]);
                $isNew = true;
            }

            // Списание баланса (только для НОВЫХ сделок, не дублей)
            if ($isNew) {
                $tenant->decrement('balance_leads');

                BalanceTransaction::create([
                    'tenant_id'      => $tenant->id,
                    'type'           => 'lead_charge',
                    'amount_leads'   => -1,
                    'balance_after'  => $tenant->balance_leads,
                    'related_type'   => Deal::class,
                    'related_id'    => $deal->id,
                ]);

                // v8.1, Ю-2: запись себестоимости лида.
                // В одной транзакции с deal + balance_transaction — целостность гарантирована.
                // На MVP закупочная цена единая (system_settings.supplier_default_cost_rub).
                SupplierLeadCost::create([
                    'deal_id'        => $deal->id,
                    'received_at'    => $deal->received_at,
                    'supplier_code'  => SystemSetting::get('supplier_code', 'crm_bp_gr'),
                    'cost_rub'       => SystemSetting::get('supplier_default_cost_rub'),
                    'supplier_lead_id' => $this->data['vid'] ?? null,
                ]);

                ActivityLog::create([
                    'tenant_id' => $tenant->id,
                    'deal_id'   => $deal->id,
                    'event'     => 'deal.created',
                    'context'   => ['source' => 'webhook'],
                ]);

                // Отправка уведомлений (внутри своей очереди)
                SendNewLeadNotificationJob::dispatch($deal->id);
            }
        });
    }

    public function failed(\Throwable $e): void
    {
        // Сохраняем для разбора и потенциального повтора
        FailedWebhookJob::create([
            'tenant_id' => $this->tenantId,
            'payload'   => $this->data,
            'error'     => $e->getMessage(),
            'log_id'    => $this->logId,
        ]);
        Sentry::captureException($e);
    }
}
```

## 5.6. Обработка крайних случаев

| Ситуация | Поведение |
|---|---|
| Невалидный JSON (синтаксис) | 400, не пишем в `webhook_log`, не диспетчим Job |
| Поля не соответствуют схеме | 400, пишем в `webhook_log` для аудита, не диспетчим Job |
| Неизвестный `webhook_token` | 404 (без подсказок). Логируем IP в `auth_log` (потенциальная атака) |
| Тенант suspended/deleted | 403 |
| Тенант active, баланс=0 | 200 (приняли), но в воркере отвергаем → `rejected_deals_log` |
| Тенант active, баланс>0, дубль `vid` | 200, в воркере 2-стадийная dedup-логика (v8.6): INSERT в `webhook_dedup_keys` → ON CONFLICT → UPDATE существующей сделки в `deals`, баланс **не списывается** второй раз |
| Тенант active, валидный payload, но ошибка в воркере | Job retry до 3 раз; после — в `failed_jobs`, alert в Sentry |
| Превышение rate limit | 429 + заголовок `Retry-After`, не пишем `webhook_log` |
| Payload > 16 КБ | 413 (Payload Too Large), Nginx режет до PHP |

## 5.7. Хранение логов Webhook

```sql
CREATE TABLE webhook_log (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    raw_payload     JSONB NOT NULL,
    received_at     TIMESTAMPTZ DEFAULT NOW(),
    processed_at    TIMESTAMPTZ,
    deal_id         BIGINT,
    error           TEXT
);

CREATE INDEX idx_webhook_log_tenant_received ON webhook_log(tenant_id, received_at DESC);
```

**Retention:** хранить `system_settings.webhook_log_retention_days` (по умолчанию 90 дней). Cron `webhook_log:cleanup` удаляет старше N дней.

```sql
CREATE TABLE rejected_deals_log (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    webhook_log_id  BIGINT REFERENCES webhook_log(id),
    reason          VARCHAR(50) NOT NULL,                -- zero_balance, validation_failed, ...
    payload         JSONB,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_rejected_tenant_created ON rejected_deals_log(tenant_id, created_at DESC);
```

`rejected_deals_log` хранится **бессрочно** (опционально 12 месяцев). Когда клиент пополнит баланс, в админке появляется кнопка «Восстановить N отвергнутых лидов» — массовое создание сделок из лога. Это не реализуется в MVP, но архитектурно подготавливается.

## 5.8. Тестирование Webhook (для клиента)

В личном кабинете клиента — кнопка «Отправить тестовый Webhook»:

1. Клиент жмёт → бэкенд формирует тестовый payload с `vid=0` (зарезервированный для тестов)
2. POST на собственный приёмник → проходит весь флоу, но с пометкой `is_test = true`
3. В сделке указывается `is_test`, она не списывает баланс
4. Клиент видит в интерфейсе: «Тестовый лид принят успешно ✓»

Для разработчиков — `curl`:

```bash
curl -X POST https://liderra.ru/webhook/abcdef1234567890... \
  -H "Content-Type: application/json" \
  -d '{
    "vid": 432176649,
    "project": "B2_Caranga",
    "tag": "Caranga",
    "phone": "79000000000",
    "phones": ["79000000000"],
    "time": 1703781939
  }'
```

---

# 6. CSV-импорт (опциональный модуль)

> **Статус:** опционально. На MVP не реализуется. Добавляется в v2 продукта при необходимости миграции исторических данных от клиентов с большой историей.

## 6.1. Когда нужен

Webhook передаёт **только новые лиды** с момента подключения. Если клиент ранее работал в crm.bp-gr.ru несколько лет и хочет перенести историю в наш аналог — нужен CSV-импорт.

## 6.2. Формат CSV (из crm.bp-gr.ru)

URL экспорта: `GET https://crm.bp-gr.ru/admin/visit/index-visit-archive?ext=csv`

**Заголовок:**

```
id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя
```

**Особенности файла:**

| Параметр | Значение |
|---|---|
| Кодировка | UTF-8 с BOM (`\xEF\xBB\xBF`) |
| Разделитель | Запятая |
| Кавычки | Поля с запятыми/кавычками — в двойных кавычках |
| Пустые поля | Два разделителя подряд (`,,`) |
| Формат даты | `YYYY/MM/DD HH:MM:SS` (слэши, не дефисы) |
| Телефон | Ровно 11 цифр, формат `7XXXXXXXXXX` |
| Состояние | Русский текст одного из 14 статусов воронки |

## 6.3. Маппинг полей

| Колонка CSV | Поле в БД | Преобразование |
|---|---|---|
| id | `deals.source_crm_id` | `(int)$value` |
| Проект | `projects.name` через `findOrCreate` | Обрезаем префикс `^B[123]_` |
| Тег проекта | `projects.tag` | as-is |
| Телефон | `deals.phone` | as-is, валидация regex `^7\d{10}$` |
| Создано | `deals.received_at` | `Carbon::createFromFormat('Y/m/d H:i:s', $value)` |
| Напоминание | `deals.reminder_at` (если не пусто) | формат как `Создано` |
| Комментарий | `deals.comment` | as-is или null |
| Состояние | `deals.status` | Маппинг русский → slug (см. ниже) |
| Имя | `deals.contact_name` | as-is или null |

## 6.4. Маппинг русских названий статусов в slug

```php
const STATUS_RU_TO_SLUG = [
    'Новые'                    => 'new',
    'Просмотрено'              => 'viewed',
    'Проработан'               => 'worked',
    'База'                     => 'base',
    'Недозвон'                 => 'missed',
    'Переговоры'               => 'negotiations',
    'Ожидаем оплаты'           => 'waiting_payment',
    'Партнерка'                => 'partnership',
    'Оплачено'                 => 'paid',
    'Закрыто и не реализовано' => 'closed',
    'Тест драйв'               => 'test_drive',
    'Горячий'                  => 'hot',
    'На замену'                => 'replacement',
    'Конечный недозвон'        => 'final_missed',
];
```

Если в CSV встречается неизвестное значение — записывается в `import_unknown_statuses`, импорт продолжается со статусом `new`, в админке появляется уведомление «найдено N неизвестных статусов, замапить вручную».

## 6.5. Идемпотентность импорта

Повторный импорт того же файла не создаёт дублей. С v8.6 (CTO-17) UNIQUE-индекс по
`(tenant_id, source_crm_id)` на партиционированной `deals` невозможен (PostgreSQL запрещает
UNIQUE без partition-key) — идемпотентность реализована через отдельную таблицу
`webhook_dedup_keys`. CSV-импортёр выполняет ту же advisory-lock логику в одной транзакции,
что и `ProcessWebhookJob` (§5.5 v8.7):

```sql
-- Стадия 1: advisory lock на (tenant_id, vid). Lock авто-освобождается на COMMIT.
SELECT pg_advisory_xact_lock(((? & x'FFFFFFFF'::bigint) << 32) | (? & x'FFFFFFFF'::bigint));
-- ^ tenant_id, source_crm_id

-- Стадия 2: атомарный SELECT (благодаря advisory lock).
SELECT deal_id, deal_received_at
FROM webhook_dedup_keys
WHERE tenant_id = ? AND source_crm_id = ?;

-- Стадия 3a: если найдено — UPDATE по composite-ключу (partition pruning через received_at).
UPDATE deals
SET status = EXCLUDED.status,
    contact_name = EXCLUDED.contact_name,
    comment = EXCLUDED.comment,
    updated_at = NOW()
WHERE id = ? AND received_at = ?;

-- Стадия 3b: иначе — INSERT deal первым (FK immediate проходит), затем dedup_key.
INSERT INTO deals (tenant_id, source_crm_id, project_id, phone, status, received_at, ...)
VALUES (?, ?, ?, ?, ?, ?, ...)
RETURNING id, received_at;

INSERT INTO webhook_dedup_keys (tenant_id, source_crm_id, deal_id, deal_received_at)
VALUES (?, ?, ?, ?);
```

**Внимание:** при повторном импорте мы **не списываем баланс**. Это исторические данные, не новые лиды. Тип транзакции `historical_import` фиксируется отдельно.

## 6.6. Workflow импорта в интерфейсе

```
Пользователь → /admin/import → загружает CSV
                ↓
        Файл сохраняется в S3 (tenant_imports/{tenant_id}/{uuid}.csv)
                ↓
        Создаётся ImportJob со статусом pending
                ↓
        Воркер обрабатывает: парсит, валидирует, делает UPSERT
                ↓
        Результат: { added: 1234, updated: 56, errors: [...] }
                ↓
        Email пользователю + уведомление в интерфейсе
```

## 6.7. Таблица import_log

```sql
CREATE TABLE import_log (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    user_id         BIGINT NOT NULL REFERENCES users(id),
    filename        VARCHAR(255) NOT NULL,
    file_path       VARCHAR(500),
    rows_total      INT DEFAULT 0,
    rows_added      INT DEFAULT 0,
    rows_updated    INT DEFAULT 0,
    rows_skipped    INT DEFAULT 0,
    status          VARCHAR(20) DEFAULT 'pending',  -- pending, processing, done, failed
    error_message   TEXT,
    started_at      TIMESTAMPTZ DEFAULT NOW(),
    finished_at     TIMESTAMPTZ
);
```

---

# 7. Сущности и схема БД

## 7.1. Карта таблиц

**Метрики schema.sql v8.4 (06.05.2026):** **53 логических таблицы + 12 партиций, 86 индексов, 34 RLS-политики на 34 защищённых таблицах (`ENABLE ROW LEVEL SECURITY`, 1:1 соответствие), 3 роли БД.** Полный DDL — [Приложение А — `schema.sql`](../db/schema.sql) (см. раздел 28). В narrative ниже — ключевые таблицы для понимания модели.

**Уровень SaaS (общие):**

- `tenants` — клиенты SaaS (+ `chargeback_unrecovered_rub` в v8.1 Ю-3, + `desired_daily_numbers INT NULL` в v8.3 Биз-16)
- `tenant_custom_domains` — кастомные домены тенантов
- `system_settings` — глобальные настройки (+ `supplier_default_cost_rub`, `payments_pending_*_timeout_*` в v8.1; + 3 ключа `projects_purge_deleted_*` в v8.3 Биз-14)
- `tariff_plans` — каталог тарифов (+ 4 заглушечных тарифа на старте, Биз-1)
- `tariff_subscriptions` — подписки тенантов на тарифы (5 состояний enum в v8.1, CTO-1)
- `legal_entities` — справочник юр. лиц владельца SaaS
- `payment_gateways` — конфигурация платёжных шлюзов
- `saas_invoices`, `saas_invoice_items`, `saas_upd_documents` — счета/УПД клиентов SaaS
- `saas_transactions` — транзакции на уровне SaaS-биллинга
- `refund_requests` — запросы на возврат (+ поле `source` в v8.1, Ю-3)
- `lead_statuses` — справочник статусов воронки (общий для всех тенантов)
- `suppliers` — справочник поставщиков B1/B2/B3 (+ 5 capabilities полей в v8.3, партия 13.3.5: `channel`, `supports_sender_name`, `supports_keyword`, `supports_csv_upload`, `supports_domains_list`)
- `document_sequences` — нумерация документов

**Уровень тенанта (с `tenant_id`):**

- `users` — пользователи (`notification_preferences` JSONB вместо 4 BOOLEAN в v8.1, CTO-4; **+ `telegram_user_id` в v8.5, Биз-20**)
- `user_recovery_codes`, `user_sessions` — auth-инфраструктура пользователя
- `projects` — проекты-источники лидов (+ 6 полей в v8.2, партии 10.3/10.6: `daily_limit_target`, `effective_daily_limit_today`, `effective_limit_calculated_at`, `region_mask`, `region_mode`, `delivery_days_mask`; **+ `assignment_strategy`/`ttfr_target_minutes` в v8.5, Биз-17/18**)
- `project_suppliers` — m2m связь проектов с поставщиками (v8.2, партия 10)
- **`project_user_assignments`** — m2m «проект ↔ менеджеры» с per-assignment skills для skill-based routing (v8.5, CTO-16; используется при `assignment_strategy IN ('round_robin','least_loaded')` Post-MVP)
- `project_limit_adjustments` — ручные корректировки лимитов проекта (v8.2, партия 10.7)
- `deals` — сделки (партиционирована по `received_at`; в v8.3 удалены `reminder_text/reminder_at` — партия 12.2.5; **+ 11 полей в v8.5: `duplicate_of_id` Биз-19, `utm_source/medium/campaign/content` CTO-14, `region_code/city` Биз-23, `time_in_form_seconds/lead_score` Биз-22, `assigned_at/escalated_count` OPEN-И-25**)
- `tenant_status_overrides` — переименование настраиваемых статусов
- `comment_templates` — шаблоны комментариев
- `deal_tags`, `deal_tag_pivot` — теги и связи
- `reminders` — напоминания (расширена в v8.3: `created_by` вместо `user_id`, + `assignee_id`, + `completed_at`; партия 12.2.4)
- `push_subscriptions` — подписки браузеров на Web Push
- `activity_log` — журнал действий (полный аудит мутаций сделки — см. [§14](#14-журнал-действий-activity-log))
- `webhook_log`, `failed_webhook_jobs` — лог принятых Webhook + упавшие после 3 ретраев джобы
- `rejected_deals_log` — лог отвергнутых лидов
- `balance_transactions` — транзакции лидов (+ `chargeback_writedown`, `chargeback_repayment` в v8.1, Ю-3; **+ `log_hash` в v8.5, OPEN-И-15**)
- `api_keys` — ключи исходящего REST API (**v8.5: `expires_at` теперь NOT NULL, default NOW()+365d, OPEN-И-17/19**)
- **`outbound_webhook_subscriptions`, `outbound_webhook_deliveries`** — подписки тенантов на исходящие события + журнал доставок (v8.4, см. [§19.10](#1910-outbound-webhook-подписка-внешних-систем-на-события))
- `auth_log` — лог входов в систему (расширен в v8.1: `actor_type` для объединения tenant_user и saas_admin; **+ `log_hash` в v8.5, OPEN-И-15**)
- `activity_log` — журнал действий (**+ `log_hash` в v8.5, OPEN-И-15**)
- `import_log` — журнал CSV-импорта
- `report_jobs` — асинхронные отчёты (**v8.5: триггер `report_jobs_log_export` AFTER INSERT → `pd_processing_log`, OPEN-И-20**)

**Себестоимость и поставщики (v8.1+, Ю-2):**

- `supplier_lead_costs` — себестоимость каждого лида (партиционирована синхронно с `deals`; v8.2: `supplier_id` FK вместо `supplier_code`)
- `supplier_invoices` — счета от crm.bp-gr.ru к нам, со сверкой и `discrepancy_rub`

**Админка SaaS (v8.1, новый блок):**

- `saas_admin_users` — учётные записи админов SaaS (**v8.5: + `sso_provider` (default `yandex360`), + `is_break_glass` для аварийного входа при недоступности IDP, OPEN-И-13**)
- `saas_admin_recovery_codes`, `saas_admin_sessions` — auth-инфраструктура админов
- `saas_admin_audit_log` — журнал действий админов (**+ `log_hash` в v8.5, OPEN-И-15**)
- `impersonation_tokens` — одноразовые коды «Войти как клиент» (Ю-1; **+ `second_approver_id`/`second_approval_at` в v8.5 для two-person approval, CTO-15+Ю-9**)

**152-ФЗ:**

- `tenant_consents` — согласия на обработку ПДн (+ `consent_type='pd_processing_as_data_provider'` в v8.1)
- `pd_processing_log` — журнал обработки ПДн (**+ `log_hash` в v8.5, OPEN-И-15**)
- `pd_subject_requests` — обращения субъектов ПДн (новая в v8.1; + `processing_restricted` BOOLEAN в v8.2 OPEN-Д-1)
- `incidents_log` — журнал инцидентов SaaS (новая в v8.2, OPEN-Д-5/И-1)

**Утилитарные:**

- `email_verifications`, `password_resets`
- `failed_jobs`

> **Полный DDL** всех 54 таблиц + 12 партиций со всеми constraint, индексами и RLS-политиками — в [Приложении А — `schema.sql` v8.5](../db/schema.sql). История изменений — [`db/CHANGELOG_schema.md`](../db/CHANGELOG_schema.md) (записи **Y=v8.5**, Z=v8.4, A=v8.3, B=v8.2).
>
> **Метрики schema.sql v8.5:** 54 таблицы + 12 партиций; 91 индекс; 35 RLS-политик (из них 2 с WITH CHECK на INSERT); 35 ENABLE ROW LEVEL SECURITY; 4 роли БД (`crm_app_user`, `crm_admin_user`, `crm_migrator`, **`crm_audit_writer` v8.5**); 12 триггеров (5×2 audit append-only + 1 report_jobs export-log + 1 deals lead_score); 4 функции (`audit_chain_hash`, `audit_block_mutation`, `report_jobs_log_export`, `calc_lead_score`).
>
> **Что отложено в schema (план упоминал, но НЕ добавлено):** `crm_connections` и `crm_field_mappings` (Уровень 2 интеграций, OPEN-И-2) — DDL появится в v8.6+ при старте реализации amoCRM-коннектора в спринте 14–15. До этого момента Уровень 1 (`outbound_webhook_*`) закрывает потребность. `call_recordings` (Биз-12 Post-MVP, OPEN-И-26) — закомментированный задел в `db/schema.sql` секция 17.

## 7.2. ER-диаграмма

> **v8.1:** полная ER-диаграмма вынесена в **Приложение Б+В — `Приложение_Б_В_БД_диаграммы_v8_3.md`** (Часть Б — ER, исходно раздел 28 v8.1). Там 6 доменных Mermaid-подграфов (тенанты и пользователи, сделки, SaaS-биллинг, лид-биллинг и логи, админка и 152-ФЗ, поставщики и себестоимость), сводная карта связей между доменами, полный текстовый справочник 81 FK и рейтинг таблиц по входящим ссылкам.

Краткая ASCII-диаграмма (только ключевые связи, для быстрой ориентации):

```
tenants ─┬─< users ─< notification_preferences (JSONB)
         ├─< projects ─< deals (партиционирована по received_at)
         ├─< balance_transactions ─< saas_admin_users (manual_adjustment, chargeback_*)
         ├─< webhook_log
         ├─< rejected_deals_log
         ├─< saas_invoices ─< saas_upd_documents
         ├─< tariff_subscriptions (5 состояний) ──> tariff_plans
         ├─< chargeback_unrecovered_rub (поле, Ю-3)
         ├─< impersonation_tokens ─< saas_admin_sessions (Ю-1)
         └─< supplier_lead_costs (партиционирована синхронно с deals, Ю-2)

deals ───┬─< activity_log
         ├─< reminders
         └─< deal_tag_pivot >─ deal_tags

users ───< auth_log
users ───< push_subscriptions
users ───< api_keys
users ───< comment_templates

payment_gateways ─< saas_transactions
legal_entities ─< saas_invoices
legal_entities ─< saas_upd_documents

supplier_invoices ─< supplier_lead_costs (после сверки)
```

Полная картина с 47 таблицами + 12 партициями и 81 FK — в Приложении Б.

## 7.3. Полные DDL основных таблиц

> ⚠️ **Источник истины — `db/schema.sql` v8.4.** Приведённые ниже DDL — иллюстрация структурных решений; они могут отставать от schema.sql по отдельным полям (см. историю в `db/CHANGELOG_schema.md`, записи Z/A/B). Перед миграцией / Eloquent-моделью сверяться **только** со schema.sql.

### tenants

См. раздел 3.4.

### users

```sql
CREATE TABLE users (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    email           VARCHAR(255) NOT NULL,
    password_hash   VARCHAR(255) NOT NULL,
    first_name      VARCHAR(255),
    last_name       VARCHAR(255),
    phone           VARCHAR(20),
    timezone        VARCHAR(50) DEFAULT 'Europe/Moscow',
    avatar_path     VARCHAR(500),
    -- 2FA
    totp_secret     VARCHAR(255),                    -- ШИФРУЕТСЯ Crypt::encrypt
    totp_enabled    BOOLEAN DEFAULT FALSE,
    -- Уведомления (v8.1, CTO-4): JSONB матрица 8 типов × до 3 каналов.
    -- Заменяет 4 раздельных BOOLEAN из v8.0 (notify_new_lead/reminder/balance/payment).
    -- Структура и дефолты согласованы с UI раздела 18.5.
    notification_preferences JSONB NOT NULL DEFAULT '{
      "new_lead":         {"inapp": true,  "push": true,  "email": false},
      "reminder":         {"inapp": true,  "push": true,  "email": true},
      "low_balance":      {"email": true},
      "zero_balance":     {"email": true},
      "topup_success":    {"email": true},
      "invoice_paid":     {"email": true},
      "new_device_login": {"email": true},
      "marketing":        {"email": false}
    }'::jsonb,
    sound_enabled     BOOLEAN DEFAULT TRUE,            -- общий toggle звука для in-app уведомлений
    -- Статус
    email_verified_at TIMESTAMPTZ,
    last_login_at     TIMESTAMPTZ,
    last_active_at    TIMESTAMPTZ,
    is_active         BOOLEAN DEFAULT TRUE,
    -- Аудит
    created_at        TIMESTAMPTZ DEFAULT NOW(),
    updated_at        TIMESTAMPTZ,
    deleted_at        TIMESTAMPTZ,                    -- soft delete + anonymization

    UNIQUE (tenant_id, email),
    -- v8.1: базовая защита, что notification_preferences — JSON-объект.
    -- Полная валидация структуры — на уровне приложения через Form Request.
    CONSTRAINT chk_users_notif_prefs_object CHECK (jsonb_typeof(notification_preferences) = 'object')
);

CREATE INDEX idx_users_tenant_email ON users(tenant_id, email) WHERE deleted_at IS NULL;
```

### projects

```sql
CREATE TABLE projects (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    name            VARCHAR(255) NOT NULL,           -- "чистое" имя без префикса B2_/B3_
    tag             VARCHAR(255),
    type            VARCHAR(20) DEFAULT 'webhook',   -- webhook, manual, import
    is_active       BOOLEAN DEFAULT TRUE,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ,

    UNIQUE (tenant_id, name)
);

CREATE INDEX idx_projects_tenant ON projects(tenant_id);
CREATE INDEX idx_projects_tag    ON projects(tag);
```

### deals (партиционирована)

```sql
CREATE TABLE deals (
    id              BIGSERIAL,
    tenant_id       BIGINT NOT NULL,
    source_crm_id   BIGINT,                          -- vid из webhook (NULL для ручных)
    project_id      BIGINT NOT NULL,
    phone           VARCHAR(20) NOT NULL,
    phones          JSONB,                           -- дополнительные телефоны
    status          VARCHAR(50) NOT NULL DEFAULT 'new',  -- slug из lead_statuses
    contact_name    VARCHAR(255),
    comment         TEXT,
    -- v8.3: поля reminder_text и reminder_at удалены (партия 12.2.5),
    -- множественные напоминания вынесены в отдельную таблицу `reminders`.
    manager_id      BIGINT,                          -- FK не ставится (партиционированная)
    is_test         BOOLEAN DEFAULT FALSE,
    received_at     TIMESTAMPTZ NOT NULL,            -- ключ партиционирования
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ,

    PRIMARY KEY (id, received_at)
) PARTITION BY RANGE (received_at);

-- Индексы (на родительской таблице — наследуются партициями)
CREATE INDEX        ON deals (tenant_id, status);
CREATE INDEX        ON deals (tenant_id, project_id);
CREATE INDEX        ON deals (tenant_id, phone);
CREATE INDEX        ON deals (tenant_id, manager_id);
-- v8.3: idx_deals_reminder удалён вместе с полем reminder_at
-- v8.6 (CTO-17): UNIQUE INDEX по (tenant_id, source_crm_id) удалён — PostgreSQL
-- запрещает UNIQUE на партиционированной таблице без partition key (received_at).
-- Идемпотентность webhook'ов перенесена в webhook_dedup_keys (см. ниже).
CREATE INDEX        ON deals (tenant_id, source_crm_id) WHERE source_crm_id IS NOT NULL;

-- Партиции создаются автоматически cron'ом (раз в сутки, на 2 месяца вперёд)
CREATE TABLE deals_2026_05 PARTITION OF deals FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE deals_2026_06 PARTITION OF deals FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
-- ... etc, до 6 месяцев вперёд
```

### webhook_dedup_keys (v8.6, CTO-17)

```sql
-- Идемпотентность webhook'ов от crm.bp-gr.ru. ON DELETE CASCADE через composite FK
-- на (deal_id, deal_received_at) — при удалении сделки дедуп-ключ исчезает автоматически.
-- v8.7 (CTO-17 addendum): FK DEFERRABLE INITIALLY DEFERRED — ProcessWebhookJob
-- (§5.5) делает INSERT в webhook_dedup_keys ДО INSERT в deals (атомарный захват
-- dedup-ключа через ON CONFLICT). Без DEFERRED FK проверяется immediate и падает
-- с FK violation. ON DELETE CASCADE по-прежнему срабатывает immediate на DELETE.
CREATE TABLE webhook_dedup_keys (
    tenant_id           BIGINT NOT NULL,
    source_crm_id       BIGINT NOT NULL,
    deal_id             BIGINT NOT NULL,
    deal_received_at    TIMESTAMPTZ NOT NULL,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at          TIMESTAMPTZ,
    PRIMARY KEY (tenant_id, source_crm_id),
    FOREIGN KEY (deal_id, deal_received_at) REFERENCES deals (id, received_at)
        ON DELETE CASCADE
        DEFERRABLE INITIALLY DEFERRED
);

CREATE INDEX idx_webhook_dedup_keys_deal ON webhook_dedup_keys (deal_id, deal_received_at);

ALTER TABLE webhook_dedup_keys ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON webhook_dedup_keys
    USING      (tenant_id = current_setting('app.current_tenant_id')::bigint)
    WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::bigint);
```

### lead_statuses (справочник)

```sql
CREATE TABLE lead_statuses (
    slug            VARCHAR(50) PRIMARY KEY,
    name_ru         VARCHAR(100) NOT NULL,
    is_system       BOOLEAN DEFAULT FALSE,            -- TRUE = нельзя переименовать
    sort_order      INT,
    color_hex       VARCHAR(7),
    description     TEXT
);

INSERT INTO lead_statuses (slug, name_ru, is_system, sort_order, color_hex) VALUES
    ('new',             'Новые',                    TRUE,  1, '#3B82F6'),
    ('viewed',          'Просмотрено',              TRUE,  2, '#8B5CF6'),
    ('worked',          'Проработан',               TRUE,  3, '#06B6D4'),
    ('base',            'База',                     FALSE, 4, '#64748B'),
    ('missed',          'Недозвон',                 FALSE, 5, '#F59E0B'),
    ('negotiations',    'Переговоры',               FALSE, 6, '#EAB308'),
    ('waiting_payment', 'Ожидаем оплаты',           FALSE, 7, '#A78BFA'),
    ('partnership',     'Партнерка',                FALSE, 8, '#EC4899'),
    ('paid',            'Оплачено',                 TRUE,  9, '#10B981'),
    ('closed',          'Закрыто и не реализовано', TRUE, 10, '#6B7280'),
    ('test_drive',      'Тест драйв',               FALSE,11, '#14B8A6'),
    ('hot',             'Горячий',                  FALSE,12, '#EF4444'),
    ('replacement',     'На замену',                FALSE,13, '#F97316'),
    ('final_missed',    'Конечный недозвон',        TRUE, 14, '#1F2937');
```

### tenant_status_overrides (переименование настраиваемых статусов)

```sql
CREATE TABLE tenant_status_overrides (
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    status_slug     VARCHAR(50) NOT NULL REFERENCES lead_statuses(slug),
    custom_name     VARCHAR(100) NOT NULL,
    PRIMARY KEY (tenant_id, status_slug)
);
```

### balance_transactions (внутренний лид-биллинг)

```sql
CREATE TABLE balance_transactions (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    type            VARCHAR(50) NOT NULL,
    -- Типы: trial_bonus, topup, lead_charge, refund, manual_adjustment, historical_import
    amount_rub      DECIMAL(12,2) DEFAULT 0,
    amount_leads    INT DEFAULT 0,
    balance_rub_after   DECIMAL(12,2),
    balance_leads_after INT,
    description     TEXT,
    related_type    VARCHAR(100),                    -- "App\Models\Deal" / "SaasTransaction" / etc
    related_id      BIGINT,
    user_id         BIGINT REFERENCES users(id),     -- кто инициировал (NULL для системных)
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_balance_tenant_created ON balance_transactions(tenant_id, created_at DESC);
CREATE INDEX idx_balance_tenant_type    ON balance_transactions(tenant_id, type);
```

### reminders (v8.3, партия 12.2.4)

Множественные напоминания на сделку. В v8.2 поля `reminder_text/reminder_at` жили в `deals`, в v8.3 вынесены в отдельную таблицу для паритета с оригиналом (`histories[].type='reminder'`).

```sql
CREATE TABLE reminders (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    deal_id         BIGINT NOT NULL,                 -- БЕЗ FK (deals партиционирована)
    text            VARCHAR(255) NOT NULL,
    remind_at       TIMESTAMPTZ NOT NULL,
    created_by      BIGINT NOT NULL REFERENCES users(id),  -- семантика "from" из оригинала
    assignee_id     BIGINT REFERENCES users(id),     -- семантика "to" из оригинала, обычно NULL
    completed_at    TIMESTAMPTZ,                     -- наше расширение для аналитики
    is_sent         BOOLEAN DEFAULT FALSE,           -- cron уже разослал нотификацию?
    sent_at         TIMESTAMPTZ,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ
);

CREATE INDEX idx_reminders_due                  ON reminders(remind_at) WHERE completed_at IS NULL AND is_sent = FALSE;
CREATE INDEX idx_reminders_deal                 ON reminders(tenant_id, deal_id, remind_at DESC);
CREATE INDEX idx_reminders_tenant_user_active   ON reminders(tenant_id, created_by) WHERE completed_at IS NULL;
CREATE INDEX idx_reminders_tenant_active        ON reminders(tenant_id) WHERE completed_at IS NULL;
```

### outbound_webhook_subscriptions (v8.4, см. §19.10)

Подписки тенантов на исходящие события. Hash secret + key_prefix как в `api_keys`.

```sql
CREATE TABLE outbound_webhook_subscriptions (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    user_id         BIGINT NOT NULL REFERENCES users(id),
    name            VARCHAR(255) NOT NULL,           -- "n8n CRM-копия", "Корпоративный data-pipeline"
    target_url      VARCHAR(2048) NOT NULL,          -- только https:// (валидация в Application layer)
    secret_hash     VARCHAR(255) NOT NULL,           -- bcrypt; оригинал показывается 1 раз
    secret_prefix   VARCHAR(10) NOT NULL,            -- "whsec_a3f7…" для UI
    events          JSONB NOT NULL DEFAULT '[]',     -- ["deal.created","deal.status_changed",…]
    custom_headers  JSONB DEFAULT '{}',
    is_active       BOOLEAN DEFAULT TRUE,
    paused_at       TIMESTAMPTZ,
    last_delivery_at TIMESTAMPTZ,
    last_failure_at  TIMESTAMPTZ,
    consecutive_failures INT DEFAULT 0,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ,
    CONSTRAINT chk_outbound_subs_events CHECK (jsonb_typeof(events) = 'array' AND jsonb_array_length(events) > 0)
);

CREATE INDEX idx_outbound_subs_tenant_active ON outbound_webhook_subscriptions(tenant_id, is_active) WHERE is_active = TRUE;
CREATE INDEX idx_outbound_subs_secret_prefix ON outbound_webhook_subscriptions(secret_prefix);
```

### outbound_webhook_deliveries (v8.4, см. §19.10.6)

Журнал попыток доставки. Retention 90 дней. Status-флоу: `pending → success | failed → permanently_failed` после 7 попыток.

```sql
CREATE TABLE outbound_webhook_deliveries (
    id                  BIGSERIAL PRIMARY KEY,
    tenant_id           BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    subscription_id     BIGINT NOT NULL REFERENCES outbound_webhook_subscriptions(id) ON DELETE CASCADE,
    delivery_uuid       UUID NOT NULL,               -- X-Liderra-Delivery
    event               VARCHAR(50) NOT NULL,
    payload             JSONB NOT NULL,
    attempt_number      SMALLINT NOT NULL DEFAULT 1
                        CHECK (attempt_number BETWEEN 1 AND 7),
    status              VARCHAR(20) NOT NULL DEFAULT 'pending'
                        CHECK (status IN ('pending','success','failed','permanently_failed')),
    http_status_code    SMALLINT,
    response_body       TEXT,
    response_time_ms    INT,
    error_message       VARCHAR(500),
    scheduled_at        TIMESTAMPTZ NOT NULL,
    started_at          TIMESTAMPTZ,
    finished_at         TIMESTAMPTZ,
    next_retry_at       TIMESTAMPTZ,
    created_at          TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_outbound_deliveries_subscription   ON outbound_webhook_deliveries(subscription_id, created_at DESC);
CREATE INDEX idx_outbound_deliveries_status_pending ON outbound_webhook_deliveries(status, scheduled_at) WHERE status = 'pending';
CREATE INDEX idx_outbound_deliveries_created        ON outbound_webhook_deliveries(created_at DESC);
```

Остальные таблицы (`activity_log`, `comment_templates`, `deal_tags`, `tariff_*`, `legal_entities`, `payment_gateways`, `saas_invoices`, `saas_upd_documents`, `api_keys`, `auth_log`, `push_subscriptions`, `suppliers`, `supplier_*`, `tenant_consents`, `pd_*`, `incidents_log`, `saas_admin_*`, `impersonation_tokens`) — определены в соответствующих разделах ниже или в [Приложении А — `schema.sql` v8.4](../db/schema.sql).

## 7.4. Конвенции

- **Таймзоны:** все `TIMESTAMPTZ`, хранятся в UTC, отображаются в зоне пользователя
- **Деньги:** всегда `DECIMAL(12,2)`, никогда float; в коде — Money-объекты с явной валютой
- **JSON-поля:** `JSONB` (бинарный, индексируемый), не `JSON` или `TEXT`
- **Soft delete:** `deleted_at TIMESTAMPTZ` на всех таблицах с ПДн (для 152-ФЗ)
- **Аудит:** `created_at`, `updated_at` на всех таблицах; `updated_at` обновляется на application-уровне через Eloquent `$timestamps = true` (trigger в БД не вводим на MVP, чтобы не дублировать логику ORM; при необходимости добавим в post-MVP)
- **Нумерация документов:** через отдельную таблицу `document_sequences` с блокировкой `FOR UPDATE` (избегаем дыр в нумерации счетов/УПД)

```sql
CREATE TABLE document_sequences (
    legal_entity_id BIGINT NOT NULL,
    document_type   VARCHAR(20) NOT NULL,            -- invoice, upd, upd_correction
    year            INT NOT NULL,
    last_number     INT NOT NULL DEFAULT 0,
    PRIMARY KEY (legal_entity_id, document_type, year)
);
```

---

# 8. Воронка продаж и статусы

## 8.1. 14 статусов воронки

**Состав 14 = 6 системных + 8 настраиваемых.** Цвета хранятся в `lead_statuses.color_hex` (палитра брендбука v1.1, см. [§26](#26-дизайн-система)). Для системных — фиксированы в seed schema.sql; для настраиваемых — могут переопределяться через UI (см. §8.5).

| # | Slug | Название (default) | Системный | Цвет (hex) | Описание |
|---|---|---|---|---|---|
| 1 | `new` | Новые | ✅ | `#3B82F6` синий | Только что поступил, ещё не открыт |
| 2 | `viewed` | Просмотрено | ✅ | `#8B5CF6` фиолетовый | Открыт менеджером (>3 сек) |
| 3 | `worked` | Проработан | ✅ | `#06B6D4` голубой | Был контакт с лидом |
| 4 | `base` | База | — | `#64748B` серый | Отложенная база |
| 5 | `missed` | Недозвон | — | `#F59E0B` амбер | Не удалось дозвониться (промежуточный) |
| 6 | `negotiations` | Переговоры | — | `#EAB308` жёлтый | Активная работа |
| 7 | `waiting_payment` | Ожидаем оплаты | — | `#A78BFA` сиреневый | Счёт выставлен |
| 8 | `partnership` | Партнерка | — | `#EC4899` розовый | Передан партнёру |
| 9 | `paid` | Оплачено | ✅ | `#10B981` зелёный | **Финальный успешный** |
| 10 | `closed` | Закрыто и не реализовано | ✅ | `#6B7280` серый | **Финальный неуспешный** |
| 11 | `test_drive` | Тест драйв | — | `#14B8A6` бирюзовый | Демо/пробный |
| 12 | `hot` | Горячий | — | `#EF4444` красный | Срочный |
| 13 | `replacement` | На замену | — | `#F97316` оранжевый | Замена другой сделки |
| 14 | `final_missed` | Конечный недозвон | ✅ | `#1F2937` тёмно-серый | **Финальный неуспешный** (после `missed`) |

**Системные** статусы (6 штук: `new`, `viewed`, `worked`, `paid`, `closed`, `final_missed`) — нельзя переименовывать. Они зашиты в логику автопереходов (§8.3) и финальных триггеров (§8.2). Их семантика стабильна между тенантами.

**Настраиваемые** (8 штук) — клиент задаёт своё название через `tenant_status_overrides` (см. §8.5). Список настраиваемых может пополняться в Post-MVP без миграции структуры (только seed `lead_statuses` с `is_system=FALSE`).

## 8.2. Жизненный цикл сделки

**Free state machine (CTO-2, закрыто 04.05.2026).** Менеджер может перевести сделку из любого статуса в любой другой в любой момент. Принципиальное решение, не подлежащее пересмотру в MVP. Обоснование:

1. **Лидген-CRM работает в режиме «много лидов / быстрая обработка».** Жёсткие state-transitions с валидацией (как в enterprise CRM типа Salesforce) тормозят менеджера и создают трение там, где нужна скорость.
2. **Паритет с оригиналом** — в crm.bp-gr.ru переходы тоже свободные (партии 2.2 и 4.1, см. [Прил. М](Analiz_originala_v8_3.md)).
3. **Защита от ошибок — не на уровне state machine**, а через [§14 Activity Log](#14-журнал-действий-activity-log) (полный аудит мутаций) + confirm-диалог при попытке изменить статус из финального (`paid`/`closed`/`final_missed`).
4. **Расширения Post-MVP** возможны как опциональные правила тенанта (например, «менеджер не может выставлять `paid` без согласования») — добавляются на уровне Authorization Policy без изменения схемы БД. Решение пока не делать — Биз-9 сравнимое (RBAC) тоже Post-MVP.

Существуют **рекомендованные траектории** (для UI-подсказок и обучающих материалов, не для валидации):

```
                  ┌─────► Оплачено (финал)
                  │
[new] ──► [viewed] ──► [worked] ──┬─► [base]
                                  │
                                  ├─► [negotiations] ─┬─► [waiting_payment] ─► [paid]
                                  │                   ├─► [test_drive]       ─► [hot] ─► [paid]
                                  │                   ├─► [partnership]      ─► [paid]
                                  │                   └─► [closed]
                                  │
                                  └─► [missed] ──► [final_missed] (финал)

[replacement] — состояние замены (используется отдельно по решению менеджера)
```

**Финальные статусы** (рекомендованные конечные точки):

- `paid` — успешно реализовано
- `closed` — закрыто без реализации
- `final_missed` — окончательно недозвонились

При попытке изменить статус финальной сделки — confirm-диалог: «Вы уверены? Сделка уже закрыта».

## 8.3. Автопереход

| Событие | Автопереход |
|---|---|
| Поступление лида через Webhook | `null → new` |
| Открытие сделки пользователем | `new → viewed` (через 3 секунды просмотра, чтобы случайные клики не считались) |
| Ручное изменение | По выбору менеджера |

Опционально (на будущее): автопереход `viewed → worked` при первом сохранении комментария / звонке.

## 8.4. Кто может менять статус

На MVP (одна роль администратор) — любой пользователь тенанта может менять статус любой сделки.

В будущем (при добавлении ролей) — настройка прав:

- Менеджер может менять статус только своих сделок
- Только администратор может вручную выставлять `paid` (защита от мошенничества)

## 8.5. Кастомизация статусов

URL: `/settings/statuses` (внутреннее название по терминологии оригинала — `/admin/site/states`).

### 8.5.1. Что можно настроить

| Атрибут | Системные (6) | Настраиваемые (8) | Реализация |
|---|---|---|---|
| Slug | ❌ зафиксирован | ❌ зафиксирован | seed `lead_statuses.slug` |
| Название (`name_ru`) | ❌ зафиксировано | ✅ переопределяется | `tenant_status_overrides.custom_name` |
| Цвет (`color_hex`) | ❌ зафиксирован | ✅ переопределяется (Post-MVP) | `lead_statuses.color_hex` (default) или `tenant_status_overrides.custom_color_hex` |
| Порядок в воронке | ❌ зафиксирован | ❌ зафиксирован (общая воронка для всех) | `lead_statuses.sort_order` |
| Удаление | ❌ нельзя | ❌ нельзя на MVP | — |

**На MVP меняется только название.** Цвет и порядок — Post-MVP. Удалить или добавить новый статус — Post-MVP (потребует миграции для уже накопленных сделок в БД, по дизайну не делаем сейчас).

### 8.5.2. Примеры переименования

| Slug | По умолчанию | Пример кастомизации |
|---|---|---|
| `base` | База | «На потом» |
| `missed` | Недозвон | «Не дозвонились» |
| `negotiations` | Переговоры | «В работе» |
| `waiting_payment` | Ожидаем оплаты | «Счёт выставлен» |
| `partnership` | Партнерка | «Партнёрка» |
| `test_drive` | Тест драйв | «Демо-период» |
| `hot` | Горячий | «🔥 Горящий» |
| `replacement` | На замену | «Замена» |

### 8.5.3. Резолв названия в UI

Названия отображаются в UI через `tenant_status_overrides`, если задано, иначе — `lead_statuses.name_ru`:

```php
public function displayName(): string
{
    return $this->tenant->statusOverrides[$this->slug] ?? $this->name_ru;
}
```

Так как поле небольшое и часто читается, вся таблица `tenant_status_overrides` загружается в память при инициализации `TenantContext` и кешируется в Redis с key `tenant:{id}:status_overrides`. TTL — 1 час, инвалидация при UPDATE через event-listener.

### 8.5.4. Изменение названия — UX

В UI кнопка-карандаш рядом с каждым настраиваемым статусом → inline-редактор → save через `PATCH /api/v1/lead-statuses/{slug}` (с проверкой `is_system=FALSE` на бэкенде). После сохранения — все открытые вкладки тенанта обновляют название через WebSocket (или при следующем navigate, если SSE отвалился).

---

# 9. Мои Проекты

## 9.1. URL и назначение

URL: `/projects`

Список всех проектов тенанта. **Проект = группировка сделок по источнику/кампании** + **настройки приёма лидов** (целевой лимит, регионы, дни обслуживания, поставщики). Создаётся:

- **Автоматически** при поступлении первого Webhook с новым именем `project` (после обрезки префиксов B2_/B3_).
- **Вручную** через UI с указанием поставщика и параметров (для подготовки конфигурации до того, как лиды начнут поступать).

## 9.2. Структура таблицы

В v8.4 (после партий 10–13 аудита, см. [Прил. М §3.2](Analiz_originala_v8_3.md)) таблица расширена параметрами лимитов и расписания.

| Колонка | Тип | Описание | Видимость по умолчанию |
|---|---|---|---|
| ☐ | checkbox | Множественный выбор для массовых операций | ✅ |
| ID | integer | Внутренний ID проекта | — |
| Тег | string | Тег/группа проекта (для фильтров и группировки) | ✅ |
| Название | string | Название (без префиксов B2_/B3_) | ✅ |
| Поставщик | badge | B1 / B2 / B3 (из `project_suppliers` JOIN `suppliers`) | ✅ |
| Тип | enum | webhook / manual / import | — |
| Статус | badge | Активен / Архив / Soft-deleted | ✅ |
| **Целевой лимит** | integer | `daily_limit_target` — что клиент хочет получить за день | ✅ |
| **Эффективный лимит сегодня** | integer | `effective_daily_limit_today` — что реально допустимо с учётом баланса (см. §9.5) | ✅ |
| Регионы | badge / chip | Кол-во разрешённых регионов из `region_mask` (например, «5/8») | — |
| Дни обслуживания | string | Маска дней недели из `delivery_days_mask` (например, «Пн–Пт») | — |
| Получено всего | integer | Количество принятых лидов за всё время | ✅ |
| Уникальных сегодня | integer | Уникальных по `phone` за текущий день | ✅ |
| Дубли | integer | Сколько раз приходил Webhook с уже существующим `vid` | — |
| Последний лид | datetime | Время последнего Webhook (relative time) | ✅ |

Видимость колонок настраивается через меню «Колонки» (сохраняется в `users.preferences`, как в [§10.2](#102-структура-таблицы)).

## 9.3. Фильтры

- Поиск по имени проекта (полнотекстовый)
- Фильтр по статусу: Активные / Архивные / Soft-deleted (см. §9.7) / Все
- Фильтр по тегу (мультиселект всех уникальных тегов)
- Фильтр по поставщику: B1 / B2 / B3
- Сортировка по: ID, Названию, Целевому лимиту, Эффективному лимиту, Получено, Последний лид

## 9.4. Карточка проекта (детали и редактирование)

URL: `/projects/{id}` или модалка из списка.

Структура карточки — паритет с оригиналом (партии 10.3–10.7), 11 секций:

| Секция | Поле / настройка | Источник в БД |
|---|---|---|
| **1. Общие** | Название, тег, тип (webhook/manual/import), активен/архив | `projects.name`, `tag`, `type`, `is_active` |
| **2. Поставщик** | Выбор `supplier_id` (B1/B2/B3) — определяет, какие capabilities доступны (см. §9.8) | `project_suppliers.supplier_id` |
| **3. Параметры поставщика** | Зависят от выбранного supplier'а: B2 — sender_name + keyword; B3 — только sender_name; B1 — sites + calls. Поля показываются динамически по `suppliers.supports_*` | JSONB в `project_suppliers.config` |
| **4. Лимит** | `daily_limit_target` (целевой) + read-only показ `effective_daily_limit_today` | `projects.daily_limit_target`, `effective_daily_limit_today` |
| **5. Срок действия** | Опциональная дата окончания приёма лидов | `projects.valid_until` (Post-MVP) |
| **6. Toggle регионов** | Включить регионы / Исключить регионы | `projects.region_mode` ('include'/'exclude') |
| **7. Регионы (8 ФО)** | Битмаска 8 федеральных округов: Центральный (1), Северо-Западный (2), Южный (4), Северо-Кавказский (8), Приволжский (16), Уральский (32), Сибирский (64), Дальневосточный (128). Default 255 = все. | `projects.region_mask` (INT) |
| **8. Список доменов / телефонов** | Только для B1 — список разрешённых сайтов или массив телефонов колл-центра (если supports_domains_list) | JSONB в `project_suppliers.config.domains` |
| **9. CSV-ключевики** | Только если `suppliers.supports_csv_upload` — загрузка CSV | См. §6 |
| **10. Webhook URL** | Для входящего webhook от crm.bp-gr.ru (см. [§5](#5-источник-данных--webhook-от-crmbp-grru)) — read-only, привязан к `tenants.webhook_token` | `tenants.webhook_token` |
| **11. Дни обслуживания** | Битмаска 7 дней недели: Пн (1), Вт (2), Ср (4), Чт (8), Пт (16), Сб (32), Вс (64). Default 127 = все 7 дней. | `projects.delivery_days_mask` (INT) |

## 9.5. Бизнес-правило: динамический эффективный лимит (CTO-2 → 04.05, главное расхождение №2)

**Что фиксирует оригинал** (партия 10.6, [Прил. М §3.2](Analiz_originala_v8_3.md)):

> Если у клиента баланс не покрывает `daily_limit_target × lead_cost`, оригинал **автоматически снижает** дневной лимит проекта. Без этой логики мы выкупали бы у crm.bp-gr.ru лиды, которые не можем оплатить → задолженность и chargeback.

**Реализация в Лидерре.**

```text
effective_daily_limit_today = min(daily_limit_target, floor(tenant.balance_rub / lead_cost))
```

### 9.5.1. Сервис EffectiveLimitCalculator

PHP-сервис, который пересчитывает `effective_daily_limit_today` для проекта и кеширует результат.

**Триггеры пересчёта:**

| Триггер | Когда срабатывает | Кто инициирует |
|---|---|---|
| Cron `limits:recalc` | 00:00 МСК ежедневно | Laravel Scheduler, для всех активных проектов всех тенантов |
| Пополнение баланса | После commit `balance_transactions` (`type='topup'`) | `BalanceService` через event `BalanceCharged` |
| Списание за лид | После `type='lead_charge'`, **только если `effective_limit < target_limit`** (баланс был узким) | `BalanceService` |
| Создание проекта | При INSERT в `projects` | `ProjectsController::store` |
| Смена тарифа | При UPDATE `tariff_subscriptions.tariff_plan_id` (изменение `lead_cost`) | `TariffService` |

### 9.5.2. Логирование автокоррекции

Каждое изменение `effective_daily_limit_today` логируется в `project_limit_adjustments` (см. DDL в [Прил. М §3.2](Analiz_originala_v8_3.md)):

| Поле | Описание |
|---|---|
| `target_limit` | Что клиент хотел в момент пересчёта |
| `effective_limit` | Что получилось |
| `reason` | `balance_low` / `manual_override` / `tariff_change` / `daily_recalc` |
| `balance_at_calc` | Снимок баланса в момент пересчёта |
| `lead_cost_at_calc` | Снимок стоимости лида (зависит от тарифа) |

Дополнительно в `activity_log` пишется событие `project.limit_auto_adjusted` (см. [§14.2](#142-отслеживаемые-события)) — только когда `effective != target`. Это позволяет в UI клиента показать notification: «Лимит проекта X снижен с 50 до 32 — пополните баланс, чтобы вернуть».

### 9.5.3. UI-отображение

В таблице (§9.2) колонка «Эффективный лимит сегодня» показывает:

- **Зелёный**, если `effective == target` (полный лимит работает).
- **Жёлтый** + tooltip «Снижен из-за низкого баланса. Пополнить?», если `effective < target`.
- **Серый**, если проект архивный (лимит не пересчитывается).

В карточке проекта (§9.4 секция 4) — бейдж рядом с числом + ссылка «Журнал корректировок» → таблица `project_limit_adjustments` за последние 30 дней.

## 9.6. Поставщики проекта (project_suppliers, m2m)

В v8.2 (партия 10) добавлена связь m2m между `projects` и `suppliers`. Один проект может иметь **только одного активного поставщика** (B1/B2/B3 — взаимоисключающие по физике процесса), но история смены поставщиков сохраняется.

```sql
-- из db/schema.sql v8.4
CREATE TABLE project_suppliers (
    project_id      BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    supplier_id     BIGINT NOT NULL REFERENCES suppliers(id),
    config          JSONB DEFAULT '{}',          -- провайдер-специфичные параметры (sender_name, keyword, domains)
    is_active       BOOLEAN DEFAULT TRUE,
    activated_at    TIMESTAMPTZ DEFAULT NOW(),
    deactivated_at  TIMESTAMPTZ,
    PRIMARY KEY (project_id, supplier_id, activated_at)
);
```

При смене поставщика старая запись получает `is_active=FALSE` и `deactivated_at`, новая — добавляется как активная. Это позволяет видеть историю «когда и какой поставщик обслуживал этот проект» — нужно для разбора инцидентов и compliance.

## 9.7. Soft-delete и retention (Биз-14 → 06.05)

**Решение Биз-14:** проекты не удаляются мгновенно — soft-delete с TTL **180 дней** (закрыто 06.05.2026 по дефолту, см. [реестр v1.10](Открытые_вопросы_v8_3.md#биз-14)).

```text
Активный → [архив (is_active=FALSE)] → [soft-deleted (deleted_at IS NOT NULL)] → [hard-deleted (через 180 дней cron-ом)]
```

| Состояние | Видимость в UI | Webhook принимаются? | Можно восстановить? |
|---|---|---|---|
| `is_active=TRUE` | По умолчанию во всех списках | ✅ | — |
| `is_active=FALSE` (архив) | Вкладка «Архив» | ❌ возвращаем 410 Gone | ✅ Один клик «Восстановить» |
| `deleted_at IS NOT NULL` (soft-deleted) | Вкладка «Корзина», только админ тенанта | ❌ возвращаем 410 Gone | ✅ Один клик «Восстановить» (в течение 180 дней) |
| Hard-deleted | Не видно | — | ❌ |

**Cron `projects:purge-deleted`** удаляет soft-deleted проекты старше 180 дней. **На MVP отключён** (`system_settings.projects_purge_deleted_enabled = FALSE`) — включается админом SaaS вручную после согласования с юристом (compliance с 152-ФЗ ст.5 п.7 о retention). Все три ключа конфигурации: `projects_purge_deleted_enabled`, `projects_purge_deleted_ttl_days`, `projects_purge_deleted_cron`.

При hard-delete: cascade delete сделок (`deals`) — настроено на уровне FK с ON DELETE CASCADE. Перед запуском cron'а проверяется, что у тенанта-владельца не осталось ожидающих оплаты лидов (`waiting_payment` статус) — иначе пропускаем.

## 9.8. Capabilities поставщика (suppliers.supports_*, партия 13.3.5)

В v8.3 в `suppliers` добавлены 5 capability-полей, чтобы UI карточки проекта (§9.4 секция 3) генерировался динамически:

| Поле | Тип | B1 (Сайты/Звонки) | B2 (СМС с keyword) | B3 (СМС по sender_name) |
|---|---|---|---|---|
| `channel` | VARCHAR(20) | `'web_phone'` | `'sms'` | `'sms'` |
| `supports_sender_name` | BOOLEAN | ❌ | ✅ (опц.) | ✅ (обязательно) |
| `supports_keyword` | BOOLEAN | ❌ | ✅ (обязательно) | ❌ |
| `supports_csv_upload` | BOOLEAN | ✅ (домены) | ✅ (ключевики) | ❌ |
| `supports_domains_list` | BOOLEAN | ✅ | ❌ | ❌ |

При выборе поставщика в карточке проекта frontend читает `suppliers.supports_*` и показывает только релевантные секции. Это **исключает класс ошибок** «менеджер заполнил sender_name для B1, а оно не используется» (партия 13.3.5 нашла такие ошибки в data crm.bp-gr.ru).

## 9.9. Действия над проектом

| Действие | Описание | Доступно когда |
|---|---|---|
| Просмотр сделок проекта | Переход в `/deals?project_id={id}` | Всегда |
| Изменить целевой лимит | UPDATE `daily_limit_target` → автоматический пересчёт `effective_daily_limit_today` | Активный или архивный |
| Изменить регионы | Toggle 8 ФО + режим include/exclude | Активный или архивный |
| Изменить дни обслуживания | Чекбоксы 7 дней недели | Активный или архивный |
| Сменить поставщика | Деактивация старой записи в `project_suppliers`, активация новой | Активный |
| Архивировать | `is_active = FALSE` — webhook отвергаются 410 Gone | Только если нет лидов в `waiting_payment` |
| Восстановить из архива | `is_active = TRUE` | Из вкладки «Архив» |
| Soft-delete | `deleted_at = NOW()` — переезд в «Корзину» (см. §9.7) | Только если архивирован ≥30 дней |
| Восстановить из корзины | `deleted_at = NULL`, `is_active = TRUE` | В течение 180 дней после soft-delete |
| Hard-delete | Только cron `projects:purge-deleted` после 180 дней (см. §9.7) | Не вручную в UI на MVP |

**Массовые операции** (через чекбоксы): Архивировать / Восстановить / Soft-delete / Изменить регионы (общее значение для всех выбранных) / Экспортировать список (CSV).

## 9.10. Бизнес-правило окна обслуживания (паритет не делаем)

В оригинальной crm.bp-gr.ru есть ограничение:

> С 22:00 до 00:00 МСК создание/редактирование проектов запрещено

В Лидерре **это правило не воспроизводится** — оно специфично для оригинальной системы (окно технических работ или ограничение поставщиков B1/B2/B3, до которых Лидерра не имеет отношения; партия 10.3 не нашла обоснования).

Если в будущем потребуется аналогичное окно — вводится флагом в `system_settings`:

```sql
INSERT INTO system_settings VALUES
    ('maintenance_window_start', '22:00', 'string', 'Начало окна тех. работ (МСК)'),
    ('maintenance_window_end',   '00:00', 'string', 'Конец окна тех. работ');
```

При активном окне фронтенд блокирует кнопки «Сохранить» в карточке проекта с подсказкой «Тех. работы — настройка вернётся в 00:00».

---

# 10. Мои Сделки (таблица)

## 10.1. URL и назначение

URL: `/deals`

Основной рабочий экран менеджера: таблица всех сделок тенанта с фильтрами, поиском, массовыми операциями, экспортом.

## 10.2. Структура таблицы

| Колонка | Тип | Сортировка | Видимость по умолчанию |
|---|---|---|---|
| ☐ | checkbox | — | ✅ |
| ID | integer | ✅ | ✅ |
| Проект | string | ✅ | ✅ |
| Тег | string | ✅ | — |
| Телефон | string(11) | ✅ | ✅ |
| Имя | string | ✅ | ✅ |
| Статус | badge | ✅ | ✅ |
| Теги | chips | — | — |
| Менеджер | string | ✅ | ✅ |
| Создано | datetime | ✅ | ✅ |
| Обновлено | datetime | ✅ | — |
| Напоминание | datetime | ✅ | — |
| Комментарий (фрагмент) | text | — | ✅ |

Пользователь может настраивать видимость колонок через меню «Колонки» (сохраняется в `users.preferences` JSON).

## 10.3. Фильтры (sticky-панель сверху)

| Фильтр | Тип | Особенности |
|---|---|---|
| Поиск | text | Полнотекстовый по `phone`, `contact_name`, `comment` (PostgreSQL FTS) |
| Проект | мультиселект | Список проектов тенанта |
| Статус | мультиселект с группировкой | Системные / Настраиваемые / Финальные |
| Менеджер | селект | Все менеджеры тенанта + «Без менеджера» |
| Период | date range picker | Сегодня / Неделя / Месяц / Произвольный |
| Поле даты | dropdown | По дате создания / по дате обновления / по напоминанию |
| Теги | мультиселект | Все теги тенанта |
| Только с напоминанием | checkbox | `reminder_at IS NOT NULL AND reminder_at <= NOW() + interval '7 days'` |

Состояние фильтров сохраняется в URL (для возможности шарить ссылку) и в `users.preferences.deals_filters`.

## 10.4. Массовые операции (после выбора чекбоксов)

| Операция | Описание |
|---|---|
| Изменить статус | Выпадает селект всех 14 статусов, применяет ко всем выбранным |
| Назначить менеджера | Выпадает селект менеджеров |
| Добавить тег | Мультиселект существующих тегов + кнопка «Создать новый» |
| Снять тег | — |
| Экспортировать | CSV / XLSX — экспорт **только выбранных** строк |
| Удалить | Hard delete (с подтверждением, опасное действие) |

Массовое изменение статуса инкрементирует записи в `activity_log` для каждой сделки.

## 10.5. Экспорт

| Формат | Особенности |
|---|---|
| CSV | UTF-8 + BOM, разделитель — запятая, формат даты ISO 8601, статусы в виде русских названий |
| XLSX | Полные стили, с цветными бейджами статусов |

При экспорте >5000 строк — отказ от синхронной генерации, переход в очередь:

```
Пользователь жмёт «Экспорт XLSX» с фильтром "за последний год" (50000 строк)
    ↓
Создаётся ReportJob (тип export_deals)
    ↓
Воркер генерирует файл, кладёт в S3
    ↓
Email пользователю + уведомление в интерфейсе со ссылкой на скачивание
    ↓
Файл доступен 7 дней, потом удаляется
```

## 10.6. Карточка сделки (просмотр/редактирование)

Открывается по клику на строку — модалка или отдельная страница `/deals/{id}`.

**Блоки карточки:**

1. **Шапка**: ID, статус (с возможностью изменить), теги (с возможностью добавить/удалить)
2. **Основная информация**: проект, тег проекта, телефон (кликабельный для звонка через интеграцию), имя контакта
3. **Комментарий**: textarea, с кнопкой «⚡ Шаблоны» (см. раздел 15)
4. **Напоминание**: дата/время + текст; при наступлении — push + email
5. **Менеджер**: селект менеджеров тенанта
6. **История** (Activity Log): см. раздел 14
7. **Кнопки действий**: «Сохранить», «Удалить», «Перевести в Kanban-вид»

## 10.7. Создание сделки вручную

URL: `/deals/create`

Форма:

| Поле | Обязательное | Валидация |
|---|---|---|
| Проект | ✅ | Селект существующих или ввод нового |
| Телефон | ✅ | regex `^7\d{10}$` |
| Имя | — | max:255 |
| Комментарий | — | text |
| Статус | ✅ | По умолчанию `new` |
| Менеджер | — | Селект |

При создании ручной сделки `source_crm_id = NULL`, `is_test = false`. **Баланс не списывается** — ручные сделки бесплатны (это управленческое решение, можно изменить через настройку `system_settings.charge_manual_deals`).

## 10.8. v8.5: Антифрод дублей, автораспределение, scoring, регион, эскалация

> **Источник:** реестр v1.12 §13.10 (Биз-17/19/22/23, CTO-16, OPEN-И-25/26).

### 10.8.1. Антифрод дублей по `phone` в окне 24 ч (Биз-19)

**Проблема:** в pay-per-lead-сегменте поставщик может прислать одного физлица дважды (двойной submit формы / повторный звонок) — без защиты клиент платит за оба. Без дедупликации продукт не отличим от голого webhook'а.

**Решение:**

- При входящем webhook (`ProcessWebhookJob` §5.5) сервис `DuplicateDetector` ищет в `deals` записи с тем же `(tenant_id, phone)`, у которых `received_at >= NOW() - INTERVAL '24 hours'`.
- Если найден master (запись без `duplicate_of_id`) — новой сделке проставляется `duplicate_of_id = master.id` и **списания с `tenants.balance_leads/balance_rub` НЕ происходит** (`balance_transactions.type` НЕ создаётся).
- Дубль виден в карточке сделки master'а как «Связанные дубли» (UI list по `(duplicate_of_id = master.id)`).
- Клиент видит метку «дубль за 24 ч» в списке `/deals` и фильтре «исключая дубли».
- **Окно фиксированное 24 ч** (не настраивается на MVP). Если поставщик задерживает повторку — клиент платит. Это компромисс между антифродом и легитимными повторными интересами.

**Lookup performance:** существующий индекс `(tenant_id, phone)` достаточен — 24 ч мелкая выборка, app-уровневый фильтр по `received_at` дешёв.

### 10.8.2. Стратегия автораспределения лидов (Биз-17 + CTO-16)

**Поле `projects.assignment_strategy`:**

- **`manual` (MVP default)** — паритет с оригиналом. Менеджер выбирается вручную в карточке сделки или массовой операцией (§10.4).
- **`round_robin` (Post-MVP)** — циклически среди active members `project_user_assignments` (skill_filter не применяется).
- **`least_loaded` (Post-MVP)** — менеджер с минимумом открытых сделок проекта.

**`project_user_assignments(project_id, user_id, skills JSONB, is_active, …)`:**

- M2M «проект ↔ менеджеры» с per-assignment skills (slug-и: `['it_b2b','retail','sms_NAME1']`).
- Скилы используются только при `assignment_strategy='least_loaded'` для статистики; полный skill-based routing (intersection projects.required_skills × users.skills) — Post-MVP-расширение.
- При assignment_strategy='manual' (MVP) таблица не используется — UI скрывает соответствующую вкладку проекта.

**Реализация на MVP:** schema готова (см. `db/schema.sql` v8.5), но cron lead-router не пишется. Менеджер по-прежнему выбирается вручную. Включение Post-MVP — однострочный feature-flag.

### 10.8.3. Lead scoring (Биз-22)

**Формула без ML:**

```
lead_score = supplier.quality_score × (deals.time_in_form_seconds / 60.0)
clamped в [0.00, 99.99]
```

**Поля:**

- `suppliers.quality_score NUMERIC(3,2) DEFAULT 1.00` — настраивается админом в `/admin/suppliers` по фактической конверсии за месяц. Post-MVP — auto-adjustment по cron.
- `deals.time_in_form_seconds INT NULL` — приходит из webhook payload как `seconds_to_submit` (если поставщик передаёт; иначе NULL → `lead_score = NULL`).
- `deals.lead_score NUMERIC(5,2)` — заполняется триггером `calc_lead_score()` (BEFORE INSERT/UPDATE OF `time_in_form_seconds`, `project_id`).

**UX:** badge на карточке сделки (зелёный 🔥 ≥3.0 / жёлтый 1.0–3.0 / без badge <1.0 или NULL). В Kanban (§11) — сортировка колонки «Новые» по lead_score DESC опциональна.

**Полная скоринг-модель с обучением на исторических данных** (Биз-22 вариант B) — Post-MVP, требует ≥6 месяцев накопления данных.

### 10.8.4. Гео-таргетинг сделок (Биз-23)

**Поля:**

- `deals.region_code VARCHAR(8)` — ISO 3166-2:RU (`RU-MOW`, `RU-SPE`, `RU-MOS` и т. д.).
- `deals.city VARCHAR(100)` — свободный текст (приходит из webhook или enrichment-сервиса).

**Автоопределение `region_code`:** сервис `App\Services\Geo\PhonePrefixService` использует offline-CSV префиксов Минсвязи РФ (~5 МБ, обновление 1×/год). Если prefix не найден — `region_code = NULL`, `city` остаётся как есть.

**Использование:**

- **Фильтр в `/deals`** (§10.3) — мультиселект регионов.
- **Когорты в §12.5.5** — конверсия по региону.
- **Не путать с `projects.region_mask`** (битмаска ФО для приёма webhook): `projects.region_mask` ограничивает inbound на уровне проекта, `deals.region_code` — атрибут сделки для аналитики/фильтра.

### 10.8.5. Эскалация просроченных лидов (OPEN-И-25)

**Cron `leads:escalate-stale`** — каждые 30 мин (`schedule.php`).

**Логика:**

```sql
SELECT id, tenant_id, project_id, manager_id
FROM deals
WHERE assigned_at IS NOT NULL
  AND assigned_at + INTERVAL '4 hours' < NOW()
  AND status NOT IN ('closed','rejected','spam')
  AND escalated_count < 3  -- защита от бесконечного reassignment
LIMIT 100;
```

Для каждого:

1. `escalated_count++`.
2. Reassign на следующего active member проекта (если `assignment_strategy != 'manual'`) или supervisor проекта (если `manual`).
3. Email старому + новому менеджеру + админу тенанта.
4. Запись в `activity_log` event=`deal.escalated` с `context = {old_manager_id, new_manager_id, escalation_count}`.

**Связь с TTFR (Биз-18, см. §12.5.5):** TTFR = «время от `received_at` до первого человеческого касания». Эскалация = «менеджер забыл про назначенный лид». Это разные метрики, но дополняющие.

**Индекс:** `(tenant_id, assigned_at) WHERE status NOT IN ('closed','rejected')` — для cron.

### 10.8.6. Архитектурный задел под call-recording (OPEN-И-26)

В `db/schema.sql` секция 17 закомментирован DDL `call_recordings(...)` — задел под Биз-12 Post-MVP (телефонная интеграция + транскрипция). 10 строк DDL + RLS-политика + индекс. Активация — однострочный uncomment + миграция `0001_create_call_recordings.php` при первом клиентском запросе.

**Поля:** `tenant_id, deal_id, call_started_at, duration_sec, direction (in/out), recording_path (S3), transcript`.

**Интеграция с pd_processing_log:** при доступе к `recording_path` через signed URL — обязательная запись `action='exported'` (как в OPEN-И-20 для `report_jobs`).

---

# 11. Kanban-доска

## 11.1. URL и назначение

URL: `/deals/kanban`

Альтернативное представление сделок: доска с колонками = статусы воронки, карточки = сделки. Drag-and-drop для смены статуса.

## 11.2. Структура

```
[Новые]      [Просмотрено]  [Проработан]  [Переговоры]  [Оплачено] ...

┌──────┐     ┌──────────┐   ┌──────────┐  ┌──────────┐  ┌────────┐
│ Иван │     │ Пётр     │   │ Сидор    │  │ Алёна    │  │ Мария  │
│+7900 │     │+7910...  │   │+7920...  │  │+7930...  │  │+7940...│
│Caran.│     │drivez.   │   │carmoney  │  │Caranga   │  │Vashinv.│
│"Пере-│     │"Думает"  │   │          │  │"СРОЧНО"  │  │        │
│звон" │     │          │   │          │  │          │  │        │
│ ⏰   │     │          │   │          │  │ 🔥       │  │ ✓      │
└──────┘     └──────────┘   └──────────┘  └──────────┘  └────────┘
   ↑ drag-and-drop для смены статуса
```

## 11.3. Карточка Kanban

Содержит:

- Имя контакта (если есть) или маскированный телефон `+7 (909) ***-67-89`
- Название проекта + тег
- Дата создания (relative: «2 часа назад»)
- Фрагмент комментария (первые 60 символов)
- Иконки: `⏰` если есть активное напоминание; `🔥` если статус "Горячий"; `✓` если "Оплачено"
- Цветные теги

Клик по карточке → открытие карточки сделки (модалка). Drag → смена статуса.

## 11.4. Drag-and-drop

При перетаскивании карточки в другую колонку:

```
1. Optimistic UI: карточка мгновенно перемещается в новую колонку
2. Запрос: PATCH /api/v1/deals/{id}/status {"status": "negotiations"}
3. На сервере:
   a. Обновление deals.status
   b. Запись в activity_log: deal.status_changed (old → new)
4. Если ответ 4xx/5xx — откат: карточка возвращается в исходную колонку, toast с ошибкой
```

## 11.5. Производительность для больших объёмов

При 10М+ сделок и тысячах в активной работе колонка может содержать сотни карточек. Решения:

| Проблема | Решение |
|---|---|
| Слишком много DOM-нод | `vue-virtual-scroller` — виртуальный скролл, рендерит только видимые карточки |
| Много данных по сети | Пагинация колонок: загружаем по 50 карточек, при скролле — следующие 50 |
| Долгая первая загрузка | Lazy load: сначала только заголовки колонок и счётчики, карточки — по мере необходимости |

## 11.6. API для Kanban

```
GET  /api/v1/deals/kanban?status=negotiations&page=1&per_page=50
     Возвращает: { items: [...], total, has_more }

GET  /api/v1/deals/kanban/columns
     Возвращает: [{ slug, name, count, color }]

PATCH /api/v1/deals/{id}/status
     Body: { "status": "new_slug" }
     Возвращает: 200 + обновлённая сделка
```

## 11.7. Фильтры на Kanban

Те же фильтры, что на таблице (период, проект, менеджер, теги). Применяются ко всем колонкам.

При активном фильтре «Только мои» (= `manager_id = current_user_id`) — отлично подходит для рабочего экрана менеджера.

---

# 12. Дашборд и аналитика

## 12.1. URL и назначение

URL: `/dashboard`

Главная страница после логина: KPI, графики, последние сделки, активные напоминания.

## 12.2. KPI-блок (5 карточек)

| Карточка | Источник данных | Формат |
|---|---|---|
| **Лимит активных проектов** | Сумма `limit_count` по активным проектам | Число |
| **Получено за период** | `COUNT(*) FROM deals WHERE received_at IN (период)` | Число + динамика к прошлому периоду (%) |
| **Конверсия** | `COUNT(status='paid') / COUNT(*) * 100` | % с тенденцией |
| **Звонков** | Из интеграций телефонии (опционально, на v2) | Число |
| **Активных напоминаний** | `COUNT(reminders WHERE remind_at <= NOW() AND is_done = false)` | Число |

Сравнение с предыдущим периодом: бейдж со стрелкой вверх (зелёный) или вниз (красный) и % изменения.

## 12.3. Графики (2 виджета)

### 12.3.1. График активности и простоя

Линейный график: количество принятых лидов по часам/дням за выбранный период.
По умолчанию — последние 7 дней.

```
лиды/день
    │     ╭─╮
 50 │    ╱   ╲      ╭─╮
 40 │   ╱     ╲    ╱   ╲
 30 │  ╱       ╲__╱     ╲
 20 │ ╱
 10 │╱
    └──────────────────────────►
     Пн Вт Ср Чт Пт Сб Вс
```

Технология: ApexCharts (рекомендуется) или Chart.js. Интерактивные тултипы.

### 12.3.2. Распределение по статусам (Pie/Donut)

Круговая диаграмма: сколько сделок в каждом статусе.

```
        Новые 25%
       ╱───╲
   ╱           ╲
  │ Перегов.  │  ◄── при наведении — тултип с количеством
  │   30%     │
   ╲           ╱
       ╲───╱
        Прочее
```

## 12.4. Таблица последних сделок

5–10 последних сделок (по `received_at`) с краткой информацией: ID, телефон, имя, проект, статус. Клик → открытие карточки.

## 12.5. Расширенная аналитика

URL: `/analytics/advanced`

Отдельный раздел с углублёнными отчётами.

### 12.5.1. Воронка конверсии (Funnel chart)

Сколько сделок прошло через каждый статус. Показывает «протекание» воронки.

```
Новые:           ████████████████ 1000 (100%)
Просмотрено:     ██████████████   850  (85%)
Проработан:      ████████████     720  (72%)
Переговоры:      ████████         400  (40%)
Ожидаем оплаты:  █████            220  (22%)
Оплачено:        ████             200  (20%)
```

SQL-запрос:

```sql
SELECT status, COUNT(*) as count
FROM deals
WHERE tenant_id = ? AND received_at BETWEEN ? AND ?
GROUP BY status;
```

### 12.5.2. Тепловая карта активности

По часам и дням недели — где больше всего лидов.

```
       0  1  2  3  4 ... 9 10 11 12 ... 18 19 20 21 22 23
Пн    ░░ ░░ ░░ ░░ ░░    ▓▓ ██ ██ ▓▓    ██ ██ ▓▓ ░░ ░░ ░░
Вт    ░░ ░░ ░░ ░░ ░░    ▓▓ ██ ██ ██    ██ ██ ▓▓ ░░ ░░ ░░
...
Вс    ░░ ░░ ░░ ░░ ░░    ░░ ▓▓ ▓▓ ░░    ░░ ▓▓ ░░ ░░ ░░ ░░
```

SQL:

```sql
SELECT
    EXTRACT(DOW FROM received_at AT TIME ZONE ?) as day_of_week,
    EXTRACT(HOUR FROM received_at AT TIME ZONE ?) as hour,
    COUNT(*) as count
FROM deals
WHERE tenant_id = ? AND received_at BETWEEN ? AND ?
GROUP BY day_of_week, hour;
```

### 12.5.3. Сравнение периодов

Два графика на одном холсте: текущий период (например, апрель 2026) vs предыдущий (март 2026). Удобно для оценки роста.

### 12.5.4. Топ-проекты

Таблица: проекты с сортировкой по конверсии, объёму лидов, выручке (если интеграция с биллингом).

### 12.5.5. Производительность менеджеров и TTFR-SLA (v8.5: Биз-18)

**Базовая таблица:** менеджер | сделок принято | переведено в paid | конверсия | средний срок до закрытия.

**v8.5 — Time To First Response SLA (Биз-18):**

- **Метрика TTFR** = время от `deals.received_at` до первого человеческого касания (изменения `status` с `new` на любой другой ИЛИ создания `activity_log` event=`deal.viewed`/`deal.commented` в карточке).
- **`projects.ttfr_target_minutes`** (default 15, диапазон 1–1440) — настраивается в карточке проекта `/projects/:id/edit`. Применяется ко всем сделкам проекта.
- **Алерт при просрочке:**
  1. Через `ttfr_target_minutes` после `received_at` — если deal остаётся в статусе `new` без касаний — событие `deal.ttfr_breached` через event bus → email + push менеджеру + email админу тенанта.
  2. UI badge на карточке сделки: серый «⏱ 12 мин» (норма) → жёлтый при >50% (`12 мин > 7.5 мин при target 15`) → красный «⏱ 18 мин ⚠️ просрочка».
  3. Метрика «TTFR p50 / p95» в дашборде §12.5.5 рядом с конверсией.
- **Связь с эскалацией (OPEN-И-25, см. §10.8.5):** TTFR — про первое касание, эскалация — про забытые лиды. Жёсткие 5-минутные SLA (Биз-18 вариант B) не выбраны — риск выгорания менеджеров и ложных эскалаций для pay-per-lead.

**v8.5 — UTM-когортная аналитика (CTO-14):**

Поля `deals.utm_source`, `utm_medium`, `utm_campaign`, `utm_content` (4 × VARCHAR(100) NULL) приходят из webhook payload (если поставщик передаёт в `extras.utm_*`) или landing page при manual-create. Индекс `(tenant_id, utm_source)` partial WHERE NOT NULL.

Когортные отчёты в `/analytics/cohorts`:

- **Конверсия по `utm_source`** — таблица source × conversion_rate × avg_lead_score (Биз-22) × ROI (если `marketing.conversion` event настроен в §19.10, Биз-21).
- **Динамика `utm_campaign`** — line chart за 30/90 дней.
- **`utm_content` A/B-test** — сравнение вариантов креатива при равном `utm_campaign`.

### 12.5.6. Конверсия проектов (паритет с оригиналом, партия 12.1)

URL: `/analytics/projects-conversion`

Большая таблица «проект × статус» — паритет с экраном `/admin/visit/rt-stat` оригинала (см. [Прил. М §9.1.1](Analiz_originala_v8_3.md)). 17 колонок, 1 строка = 1 проект, плюс total-row.

**Структура колонок:**

| # | Колонка | Тип | Описание |
|---|---|---|---|
| 1 | ID | integer | Внутренний ID проекта |
| 2 | Тег | string | `projects.tag` |
| 3 | Проект | string | `projects.name` |
| 4 | Статус | badge | Активен / Архив (`is_active`) |
| 5 | Источник сбора | badge | B1 / B2 / B3 (через `project_suppliers`) |
| 6 | Обработано | integer | **Знаменатель** для всех процентов в строке: `COUNT(*) FROM deals WHERE project_id = X AND received_at BETWEEN ? AND ?` |
| 7 | Новые | `N (XX.XX%)` | По статусу `new` |
| 8 | Просмотрено | `N (XX.XX%)` | `viewed` |
| 9 | Проработан | `N (XX.XX%)` | `worked` |
| 10 | База | `N (XX.XX%)` | `base` |
| 11 | Недозвон | `N (XX.XX%)` | `missed` |
| 12 | Переговоры | `N (XX.XX%)` | `negotiations` |
| 13 | Ожидаем оплаты | `N (XX.XX%)` | `waiting_payment` |
| 14 | Партнерка | `N (XX.XX%)` | `partnership` |
| 15 | Оплачено | `N (XX.XX%)` | `paid` |
| 16 | Закрыто | `N (XX.XX%)` | `closed` |
| 17 | Конечный недозвон | `N (XX.XX%)` | `final_missed` |

**Формула для процентов** (колонки 7–17):

```text
percent_status_X = count_status_X / count_processed × 100
```

Формат отображения — `N (XX.XX%)`, например: `47 (12.34%)`. Знаменатель единый по строке (колонка 6 «Обработано»), не пересчитывается для каждого статуса отдельно.

**Цветовая схема — monotone per column** (не heatmap, не светофор): у каждого статуса свой пастельный цвет фона колонки, не зависящий от значения. Это паритет с оригиналом и обоснованный выбор: heatmap создаёт визуальный шум, перетягивает внимание с проекта на статус и не помогает увидеть аномалии (для аномалий есть сортировка по колонке).

**Total-row** внизу таблицы: суммы по знаменателю и числителям, проценты пересчитаны от total. Когда применён фильтр по периоду — total отражает только видимые проекты.

**Фильтры:**

- Период (date range, default — текущий месяц).
- Тип проекта (webhook / manual / import / все).
- Поиск по тегу или названию.
- Поставщик (B1 / B2 / B3 / все).

**Видимость колонок** — пользователь может скрыть любую из 11 status-колонок (но не первые 6). Сохраняется в `users.preferences.projects_conversion_columns` (паритет с `localStorage.rtstatopts1` оригинала, но через user-level хранилище).

**Сортировка** по любой колонке (включая процентные) — на стороне клиента, для текущей загруженной страницы. Серверная сортировка — только по `name` и `received_count` (для пагинации).

**Что есть в Лидерре как наше расширение** (сверх паритета — партия 12.1 нашла, что в оригинале нет):

| Наше расширение | Реализация |
|---|---|
| Экспорт в XLSX | Через `report_jobs` (см. [§13 Отчёты](#13-отчёты)) — асинхронная генерация, ссылка на email/in-app, файл живёт 7 дней в S3 |
| Drill-down из ячейки | Клик по `47 (12.34%)` → переход в `/deals?project_id=X&status=paid&from=...&to=...` (отфильтрованный список) |

**SQL-запрос (псевдокод):**

```sql
WITH project_stats AS (
    SELECT
        p.id AS project_id,
        p.tag, p.name, p.is_active,
        COUNT(d.id) AS processed,
        COUNT(*) FILTER (WHERE d.status = 'new')             AS s_new,
        COUNT(*) FILTER (WHERE d.status = 'viewed')          AS s_viewed,
        COUNT(*) FILTER (WHERE d.status = 'worked')          AS s_worked,
        COUNT(*) FILTER (WHERE d.status = 'base')            AS s_base,
        COUNT(*) FILTER (WHERE d.status = 'missed')          AS s_missed,
        COUNT(*) FILTER (WHERE d.status = 'negotiations')    AS s_negotiations,
        COUNT(*) FILTER (WHERE d.status = 'waiting_payment') AS s_waiting_payment,
        COUNT(*) FILTER (WHERE d.status = 'partnership')     AS s_partnership,
        COUNT(*) FILTER (WHERE d.status = 'paid')            AS s_paid,
        COUNT(*) FILTER (WHERE d.status = 'closed')          AS s_closed,
        COUNT(*) FILTER (WHERE d.status = 'final_missed')    AS s_final_missed
    FROM projects p
    LEFT JOIN deals d ON d.project_id = p.id
                     AND d.received_at BETWEEN $1 AND $2
                     AND d.tenant_id = current_setting('app.current_tenant_id')::bigint
    WHERE p.tenant_id = current_setting('app.current_tenant_id')::bigint
    GROUP BY p.id
)
SELECT * FROM project_stats ORDER BY processed DESC;
```

Запрос кешируется в Redis на 5 минут (по `tenant_id + period_hash + filters_hash`). Для тенантов с 100+ проектами — генерация через `report_jobs` с показом таблицы из снапшота.

**Что НЕ делаем** (отложено):

- Сравнение «период vs предыдущий» в той же таблице — логично, но усложняет UX. Если понадобится — отдельный экран `/analytics/projects-conversion-compare`.
- Графики «динамика конверсии по дням» внутри строки проекта — просится heatmap-вариант, противоречит paritet-решению (monotone per column). Если понадобится — отдельный экран.

## 12.6. API-эндпоинты аналитики

| Метод | URL | Возвращает |
|---|---|---|
| GET | `/api/v1/analytics/dashboard` | KPI + основные графики (объединённый запрос) |
| GET | `/api/v1/analytics/funnel?from=...&to=...` | Данные воронки |
| GET | `/api/v1/analytics/heatmap?from=...&to=...` | Тепловая карта (массив часов и дней) |
| GET | `/api/v1/analytics/compare?period_a=...&period_b=...` | Сравнение двух периодов |
| GET | `/api/v1/analytics/top-projects?limit=10` | Топ-проекты |
| GET | `/api/v1/analytics/managers-performance` | Эффективность менеджеров |
| GET | `/api/v1/analytics/projects-conversion?from=&to=&type=&search=` | Конверсия проектов (17 колонок, см. §12.5.6) |
| POST | `/api/v1/analytics/projects-conversion/export` | Запуск асинхронного XLSX-экспорта через `report_jobs`; возвращает `job_id` для polling |

Все запросы кешируются в Redis на 5 минут (агрегации тяжёлые при 10М+ строк).

---

# 13. Отчёты

## 13.1. URL

`/reports`

## 13.2. Типы отчётов

| Тип | Описание | Формат |
|---|---|---|
| `analytics_summary` | Сводный отчёт по активности проектов за период | XLSX, CSV |
| `deals_export` | Полный экспорт сделок с фильтрами | XLSX, CSV |
| `funnel_conversion` | Детализация по статусам воронки | XLSX |
| `calls_summary` | Статистика звонков по пользователям (при интеграции) | XLSX |
| `managers_kpi` | KPI менеджеров за период | XLSX |
| `requested_phones` | Запрошенные номера (тип 49 в оригинале) | XLSX |

## 13.3. Форма запроса отчёта

| Поле | Тип |
|---|---|
| Тип отчёта | селект |
| Период | date range |
| Проект (опционально) | мультиселект |
| Менеджер (опционально) | селект |
| Формат | XLSX / CSV |

## 13.4. Асинхронная генерация

Все отчёты — асинхронные:

```
Пользователь жмёт «Сгенерировать»
    ↓
POST /api/v1/reports → создаётся report_jobs запись (status=pending)
    ↓
Очередь reports → воркер берёт Job
    ↓
Воркер: генерирует данные SQL → формирует файл (через PhpSpreadsheet) → кладёт в S3
    ↓
Обновляет report_jobs.status = 'done', file_path, generation_seconds
    ↓
Email пользователю «Отчёт готов» + уведомление в интерфейсе
    ↓
Файл доступен 7 дней
```

## 13.5. Таблица report_jobs

```sql
CREATE TABLE report_jobs (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    user_id         BIGINT NOT NULL REFERENCES users(id),
    type            VARCHAR(50) NOT NULL,
    parameters      JSONB,
    status          VARCHAR(20) DEFAULT 'pending',  -- pending, processing, done, failed
    file_path       VARCHAR(500),                    -- s3://bucket/path/file.xlsx
    file_size       BIGINT,
    generation_seconds INT,
    error_message   TEXT,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    finished_at     TIMESTAMPTZ,
    expires_at      TIMESTAMPTZ                      -- автоудаление файла
);

CREATE INDEX idx_report_jobs_tenant_user ON report_jobs(tenant_id, user_id, created_at DESC);
```

## 13.6. Список отчётов в интерфейсе

| Колонка | Описание |
|---|---|
| ID | Идентификатор |
| Тип | Название отчёта |
| Параметры | Период, проект (как читаемая строка) |
| Создан | Дата запроса |
| Время генерации | Сколько секунд заняла |
| Статус | Готов / В процессе / Ошибка |
| Размер | Размер файла |
| Действия | Скачать / Удалить |

---

# 14. Журнал действий (Activity Log)

## 14.1. Назначение

Лента событий по каждой сделке: кто, когда, что изменил. Для аудита, разбора спорных ситуаций, обучения менеджеров.

## 14.2. Отслеживаемые события

**Принцип v8.4:** полный аудит всех мутаций сделки. Любая правка любого редактируемого поля карточки → запись в `activity_log` синхронно с UPDATE сделки.

| Событие | Описание | old_value | new_value |
|---|---|---|---|
| `deal.created` | Сделка создана | null | source: webhook/manual/import |
| `deal.viewed` | Сделка открыта пользователем (>3 сек) | — | — |
| `deal.status_changed` | Статус изменён | старый slug | новый slug |
| `deal.manager_changed` | Назначен/изменён менеджер | старый user_id | новый user_id |
| `deal.project_changed` | Сделка перенесена в другой проект | старый project_id | новый project_id |
| `deal.phone_changed` | Изменён основной телефон | старый phone | новый phone |
| `deal.phones_changed` | Изменён массив дополнительных телефонов | старый JSON | новый JSON |
| `deal.contact_name_changed` | Изменено имя контакта | старое имя | новое имя |
| `deal.commented` | Изменён комментарий | предыдущий текст | новый текст |
| `deal.tag_added` | Добавлен тег | — | tag_id |
| `deal.tag_removed` | Снят тег | tag_id | — |
| `deal.reminder_set` | Установлено напоминание | — | reminder_id + дата + текст |
| `deal.reminder_completed` | Напоминание отмечено выполненным | reminder_id | completed_by user_id |
| `deal.reminder_triggered` | Напоминание сработало (cron) | — | reminder_id |
| `deal.deleted` | Сделка удалена | — | — |
| `deal.restored` | Сделка восстановлена из soft-delete | — | — |

**Что НЕ логируется (осознанно):**

- Изменение `is_test` — техническое поле, отметка тестовых сделок саппортом.
- `received_at`, `source_crm_id`, `created_at` — иммутабельные поля, выставляются при создании.
- `updated_at` — служебное.
- Поле «сумма сделки» — у нас в модели данных его нет (стоимость лида фиксируется один раз в `lead_purchases.amount_rub` при покупке и не меняется по дизайну CTO-2).

## 14.3. Отображение в карточке сделки

Вертикальная лента событий:

```
● 14:32 — Дмитрий изменил статус: Переговоры → Ожидаем оплаты
● 14:30 — Дмитрий добавил комментарий: "Перезвонить завтра в 10:00"
● 14:29 — Дмитрий открыл сделку
● 14:25 — Дмитрий назначен менеджером
● 10:15 — Сделка создана (Webhook, проект: Caranga)
```

## 14.4. Схема БД

```sql
CREATE TABLE activity_log (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    user_id         BIGINT REFERENCES users(id),         -- NULL для системных событий
    deal_id         BIGINT NOT NULL,
    event           VARCHAR(100) NOT NULL,
    old_value       TEXT,
    new_value       TEXT,
    context         JSONB,                                -- доп. данные (например, source: webhook)
    ip_address      INET,
    user_agent      TEXT,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_activity_tenant_deal_created ON activity_log(tenant_id, deal_id, created_at DESC);
CREATE INDEX idx_activity_tenant_user_created ON activity_log(tenant_id, user_id, created_at DESC) WHERE user_id IS NOT NULL;
```

## 14.5. Реализация

Через Laravel Eloquent Observers или Events:

```php
// app/Observers/DealObserver.php
public function updated(Deal $deal): void
{
    if ($deal->wasChanged('status')) {
        ActivityLog::create([
            'tenant_id' => $deal->tenant_id,
            'user_id'   => auth()->id(),
            'deal_id'   => $deal->id,
            'event'     => 'deal.status_changed',
            'old_value' => $deal->getOriginal('status'),
            'new_value' => $deal->status,
            'ip_address'=> request()->ip(),
        ]);
    }
    // Аналогично для других полей
}
```

Запись в `activity_log` происходит **синхронно** в той же транзакции. Это критично — нужны гарантии, что аудит не потеряется. Если станет тормозить — перевести на очередь.

## 14.6. Retention

`activity_log` хранится **3 года** для соответствия типичным требованиям бизнес-аудита. Старше — архивируются в холодное хранилище (S3 Glacier / физический backup) и удаляются из активной БД.

## 14.7. Конкурентное преимущество vs оригинал crm.bp-gr.ru

**Что зафиксировано аудитом оригинала (партия 11.4, см. [Прил. М §2.9](Analiz_originala_v8_3.md)).** В истории сделки crm.bp-gr.ru логируются **только**:

- Смена статуса.
- Создание/изменение комментария.
- Исходящий звонок (call.placed).

**В оригинале НЕ логируется:**

| Действие | Что это означает в работе колл-центра |
|---|---|
| Смена ответственного менеджера | Лид можно незаметно «перевесить» с одного менеджера на другого |
| Смена проекта (перенос в другую кампанию) | Лид можно «спрятать» в чужом проекте без следа |
| Изменение телефона | Можно подменить контакт после первого касания |
| Изменение имени контакта | То же — подмена данных без аудита |

**Что у нас.** Полный аудит мутаций (см. §14.2) — каждая правка любого поля логируется в `activity_log` синхронно в той же транзакции, что и UPDATE сделки. Это критично для:

- **Спорных ситуаций между менеджерами** («кто именно перевесил лид на себя перед закрытием»).
- **Compliance с 152-ФЗ** (Прил. Д) — журнал изменений ПДн (телефон, имя) ведётся обязательно.
- **Защиты от внутренних мошеннических схем** в колл-центрах с подрядными менеджерами.

**Маркетинговое позиционирование** (преимущество №2 из 6, см. [§1.4.1](#141-конкурентные-преимущества-vs-оригинал-crmbp-grru)): «Полный аудит всех изменений сделки — в отличие от оригинала, где можно незаметно перевесить лид на другого менеджера или подменить телефон».

## 14.8. v8.5: Append-only audit hash chain + журнал экспорта

> **Источник:** реестр v1.12 §13.10 (OPEN-И-15, OPEN-И-20).

### 14.8.1. Tamper-evident audit log (OPEN-И-15)

**Проблема:** до v8.5 audit-журналы (`activity_log`, `auth_log`, `pd_processing_log`, `saas_admin_audit_log`, `balance_transactions`) технически могли быть изменены через прямой SQL `UPDATE`/`DELETE` под ролью `crm_app_user` (если application code допустит) или под `super_admin` SaaS. Юридически это значит, что доказательственная сила журналов слаба — недобросовестный admin мог стереть запись о своём действии.

**Решение в v8.5:**

- **`log_hash BYTEA` колонка** на 5 audit-таблицах — заполняется триггером `audit_chain_hash()` BEFORE INSERT по формуле `log_hash = sha256( prev_row.log_hash || NEW::text )`.
- **Триггер `audit_block_mutation()` BEFORE UPDATE/DELETE** — RAISE EXCEPTION 'audit log is append-only'. ALTER TABLE … DISABLE TRIGGER возможен только под суперпользователем PG (которого у админов SaaS нет в production).
- **Отдельная роль `crm_audit_writer`** (только INSERT на 5 audit-таблиц). Application пишет в audit под этой ролью через temporary `SET ROLE crm_audit_writer`. Даже если admin отключит триггеры, эта роль не имеет UPDATE/DELETE permissions.
- **REVOKE ALL UPDATE, DELETE** на 5 audit-таблиц от `crm_app_user`. Два слоя защиты (триггер + permissions).

**Cron `audit:verify-chain`** (раз в сутки в 04:00 МСК):

1. Для каждой из 5 audit-таблиц проходит по `id ASC`.
2. Пересчитывает `expected_hash = sha256(prev_hash || row::text)`.
3. Сравнивает с `row.log_hash`. При расхождении — алерт в Sentry severity=critical + email админам SaaS + запись в `incidents_log` type=`audit_chain_break`.
4. Под нормальной работой — нулевая нагрузка (полная проверка ~5 минут на 50М строк, потом инкрементально с last-checked id).

**Юридический эффект:** журналы становятся доказательственно сильными. Любая попытка модификации после INSERT'а — либо отклонена триггером, либо обнаружена cron'ом. Ссылки в спорах — на конкретные `id` строк + `log_hash`, который математически связан со всеми предыдущими.

**Защита от ALTER TABLE … DISABLE TRIGGER:** под `super_admin` SaaS, действующим через `crm_audit_writer`, эта команда не пройдёт (нет прав на ALTER). Под `super_admin` PostgreSQL — возможна, но это эскалация привилегий, фиксируемая отдельно (root-level audit на уровне ОС).

### 14.8.2. Триггер экспорта в `pd_processing_log` (OPEN-И-20)

**Проблема:** при создании `report_jobs` (выгрузка лидов в файл) запись в `pd_processing_log` action='exported' до v8.5 делалась из application-кода. Риск пропуска при ошибке программиста или новом фичевом коде.

**Решение:**

```sql
CREATE TRIGGER trg_report_jobs_export_log
    AFTER INSERT ON report_jobs
    FOR EACH ROW EXECUTE FUNCTION report_jobs_log_export();
```

Функция `report_jobs_log_export()` вставляет в `pd_processing_log`:

```
tenant_id        := NEW.tenant_id
subject_type     := 'lead'         -- bulk-выгрузка лидов
subject_id       := NULL           -- bulk = NULL (cм. CHECK chk_pd_actor)
action           := 'exported'
purpose          := 'report_job_<NEW.id>'
actor_tenant_user_id := NEW.user_id
```

**Эффект:** соответствие 152-ФЗ ст.18 ч.2 (фиксация всех операций обработки ПДн) обеспечивается автоматически. Пропуск audit-записи теперь невозможен — любой INSERT в `report_jobs` гарантирует запись.

**Дополнительно — signed URL для `report_jobs.file_path`:** S3 presigned URL TTL 24 ч (вместо постоянного auth-protected URL до v8.5). UI отдаёт presigned ссылку через `/api/v1/reports/:id/download`. Подробности генерации — в Прил. И (Runbook).

---

# 15. Шаблоны комментариев

## 15.1. Назначение

Заготовленные быстрые ответы для менеджеров. Один клик → текст вставляется в поле комментария сделки.

## 15.2. Типы шаблонов

| Тип | Кто создаёт | Кому доступен |
|---|---|---|
| **Глобальный** (для тенанта) | Администратор тенанта | Всем пользователям тенанта |
| **Личный** | Любой пользователь | Только создателю |

## 15.3. Примеры шаблонов по умолчанию

При регистрации тенанта создаются 5 базовых глобальных шаблонов:

| Название | Текст |
|---|---|
| Не берёт трубку | Не берёт трубку. Попробовать перезвонить позже. |
| Перезвонить завтра | Договорились перезвонить завтра. Клиент занят. |
| Отправить КП | Клиент заинтересован. Отправить коммерческое предложение. |
| Думает | Клиент думает. Перезвонить через 3 дня. |
| Не актуально | Уже решили вопрос с другой компанией. Не актуально. |

## 15.4. Схема БД

```sql
CREATE TABLE comment_templates (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    user_id         BIGINT REFERENCES users(id),     -- NULL = глобальный
    name            VARCHAR(255) NOT NULL,
    text            TEXT NOT NULL,
    sort_order      INT DEFAULT 0,
    usage_count     INT DEFAULT 0,                    -- для сортировки по популярности
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ
);

CREATE INDEX idx_templates_tenant_user ON comment_templates(tenant_id, user_id);
```

## 15.5. UI

В поле комментария сделки — кнопка `⚡ Шаблоны`. При клике — выпадающий список:

```
┌──────────────────────────────────┐
│ ⚡ Мои шаблоны                    │
│   • Перезвонить вечером          │
│   • Отправил договор              │
│                                   │
│ 🌐 Общие шаблоны                  │
│   • Не берёт трубку              │
│   • Перезвонить завтра           │
│   • Отправить КП                 │
│   • Думает                       │
│   • Не актуально                 │
│                                   │
│ + Создать новый шаблон           │
└──────────────────────────────────┘
```

Клик по шаблону → текст подставляется в поле комментария (но не сохраняется автоматически — пользователь может отредактировать перед сохранением).

## 15.6. Управление шаблонами

URL: `/templates/comments`

Таблица: ID, Название, Текст (фрагмент), Тип (личный/глобальный), Использовано раз, Действия.

CRUD через REST:

| Метод | URL | Описание |
|---|---|---|
| GET | `/api/v1/comment-templates` | Список (личные + глобальные) |
| POST | `/api/v1/comment-templates` | Создать (если `is_global=true` — нужны права администратора) |
| PATCH | `/api/v1/comment-templates/{id}` | Изменить |
| DELETE | `/api/v1/comment-templates/{id}` | Удалить |
| POST | `/api/v1/comment-templates/{id}/use` | Зафиксировать использование (`usage_count++`) |

---

# 16. Теги и метки для сделок

## 16.1. Назначение

Произвольные цветные метки для сегментации сделок **поверх** статуса воронки. Например, статус — «Переговоры», тег — «VIP» или «Горящий».

## 16.2. Примеры тегов

| Метка | Цвет | Описание |
|---|---|---|
| VIP | Золотой `#F59E0B` | Приоритетный клиент |
| Горящий | Красный `#EF4444` | Срочно обработать |
| Требует КП | Синий `#3B82F6` | Нужно отправить коммерческое предложение |
| Повторный | Зелёный `#10B981` | Клиент уже обращался |
| Проблемный | Серый `#6B7280` | Сложный клиент |

При регистрации тенанта эти 5 тегов создаются по умолчанию.

## 16.3. Схема БД

```sql
CREATE TABLE deal_tags (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    name            VARCHAR(100) NOT NULL,
    color_hex       VARCHAR(7) NOT NULL,
    description     TEXT,
    created_by      BIGINT REFERENCES users(id),
    created_at      TIMESTAMPTZ DEFAULT NOW(),

    UNIQUE (tenant_id, name)
);

CREATE TABLE deal_tag_pivot (
    deal_id         BIGINT NOT NULL,
    tag_id          BIGINT NOT NULL REFERENCES deal_tags(id) ON DELETE CASCADE,
    tagged_at       TIMESTAMPTZ DEFAULT NOW(),
    tagged_by       BIGINT REFERENCES users(id),
    PRIMARY KEY (deal_id, tag_id)
);

CREATE INDEX idx_deal_tag_pivot_tag ON deal_tag_pivot(tag_id);
```

> Внимание: `deal_id` в `deal_tag_pivot` — без FK на `deals.id` из-за партиционирования. Целостность поддерживается через triggers или на уровне приложения.

## 16.4. UI

В карточке сделки — chip-инпут с тегами:

```
┌──────────────────────────────────────────┐
│ Теги: [VIP×] [Горящий×] [+ Добавить тег] │
└──────────────────────────────────────────┘
```

При клике `+` — выпадающий список существующих тегов + поле «Создать новый».

## 16.5. Фильтрация по тегам

В таблице сделок и в Kanban — мультиселект тегов в фильтрах. Логика OR (показать сделки с любым из выбранных тегов).

## 16.6. API

| Метод | URL | Описание |
|---|---|---|
| GET | `/api/v1/deal-tags` | Список тегов тенанта |
| POST | `/api/v1/deal-tags` | Создать тег |
| PATCH | `/api/v1/deal-tags/{id}` | Изменить (название, цвет) |
| DELETE | `/api/v1/deal-tags/{id}` | Удалить (cascade удалит связи) |
| POST | `/api/v1/deals/{deal_id}/tags` | Привязать теги: `{ tag_ids: [1,2,3] }` |
| DELETE | `/api/v1/deals/{deal_id}/tags/{tag_id}` | Отвязать конкретный тег |

---

# 17. Напоминания и Push-уведомления

## 17.1. Назначение

Полноценная система уведомлений в интерфейсе и за его пределами. Заменяет Telegram-бота из оригинальной системы.

## 17.2. Каналы доставки

| Канал | Описание | Включён по умолчанию |
|---|---|---|
| **In-app** | Колокольчик в шапке + всплывающий toast | ✅ |
| **Web Push** | Push в браузере (даже когда вкладка закрыта, через Service Worker) | По выбору пользователя |
| **Email** | Письмо на адрес пользователя | По выбору пользователя |
| **Звуковой сигнал** | `/files/reminder.mp3` при срабатывании напоминания | ✅ |

## 17.3. Типы уведомлений

| Тип | Каналы по умолчанию |
|---|---|
| Новый лид поступил | In-app + Push |
| Напоминание по сделке сработало | In-app + Push + Email + звук |
| Низкий баланс лидов | Email |
| Баланс = 0 (лиды отвергаются) | Email |
| Успешное пополнение | Email |
| Счёт оплачен | Email |
| УПД сформирован | Email с вложением PDF |

Настройки — в `/account/notifications` (см. раздел 18).

## 17.4. Web Push — техническая реализация

```
1. Пользователь заходит в систему
2. JS запрашивает разрешение: Notification.requestPermission()
3. Если разрешено — регистрирует Service Worker
4. ServiceWorker.subscribe(VAPID public key) → возвращает endpoint + p256dh + auth
5. POST /api/v1/push/subscribe → сохраняем в push_subscriptions
6. При событии (новый лид, напоминание) — backend отправляет push через web-push библиотеку
7. ServiceWorker получает push → показывает Notification
8. Клик по нотификации → открывает соответствующую страницу
```

**VAPID-ключи** генерируются один раз при инсталляции платформы, хранятся в `system_settings`:

```sql
INSERT INTO system_settings (key, value, type, description) VALUES
    ('vapid_public_key',  '...', 'string', 'VAPID public key (для подписки)'),
    ('vapid_private_key', '...', 'string', 'VAPID private key (для отправки, ШИФРОВАН)'),
    ('vapid_subject',     'mailto:admin@liderra.ru', 'string', 'VAPID subject');
```

## 17.5. Схемы БД

```sql
CREATE TABLE reminders (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    user_id         BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    deal_id         BIGINT NOT NULL,
    remind_at       TIMESTAMPTZ NOT NULL,
    text            TEXT,
    is_done         BOOLEAN DEFAULT FALSE,
    is_sent         BOOLEAN DEFAULT FALSE,
    sent_at         TIMESTAMPTZ,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_reminders_due ON reminders(remind_at) WHERE is_sent = FALSE AND is_done = FALSE;
CREATE INDEX idx_reminders_tenant_user_due ON reminders(tenant_id, user_id, remind_at) WHERE is_done = FALSE;

CREATE TABLE push_subscriptions (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    user_id         BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    endpoint        TEXT NOT NULL,
    p256dh          TEXT NOT NULL,
    auth            TEXT NOT NULL,
    user_agent      TEXT,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    last_used_at    TIMESTAMPTZ,

    UNIQUE (user_id, endpoint)
);
```

## 17.6. Cron `reminders:dispatch`

Запускается каждую минуту:

```php
// schedule.php
$schedule->job(new DispatchRemindersJob)->everyMinute();

// DispatchRemindersJob
public function handle(): void
{
    Reminder::where('remind_at', '<=', now())
        ->where('is_sent', false)
        ->where('is_done', false)
        ->limit(500)  // batch обработка
        ->each(function ($reminder) {
            SendReminderNotificationJob::dispatch($reminder->id);
        });
}
```

## 17.7. Library

| Слой | Библиотека |
|---|---|
| PHP web-push | `minishlink/web-push` |
| Email | Laravel Notifications + SMTP-relay (Unisender Go, см. Ю-7) |
| Service Worker | Workbox или vanilla JS |

## 17.8. Тихие часы (quiet hours) — паритет с оригиналом

> **Уточнено в v8.4 по итогам аудита партии 13.2.1** (Прил. М). Ранее упоминалось в §18 только концептуально.

**Назначение:** окно, в которое уведомления (push, email-напоминания, real-time snackbar) **не доставляются**. Защищает менеджеров от ночных уведомлений вне рабочих часов.

**Уровень настройки:** **тенант** (общие для всех менеджеров организации, не индивидуальные).

**Поля в `tenants`** (см. schema.sql v8.3):

| Поле | Тип | Описание |
|---|---|---|
| `quiet_hours_start` | INT 0..23 | Час начала тихого окна (по timezone тенанта) |
| `quiet_hours_end` | INT 0..23 | Час окончания тихого окна |
| `timezone` | VARCHAR(50) | IANA-таймзона тенанта (например, `Europe/Moscow`). Общая для всех менеджеров. |

**Правила:**

- Минимальная длина окна — **3 часа** (валидация на бэкенде; UI запрещает сохранить меньше).
- Окно может пересекать полночь: `start=22, end=8` означает «с 22:00 до 08:00».
- Если webhook приходит в тихие часы — **сделка создаётся, баланс списывается**, но push/email откладываются до конца окна.
- Cron `reminders:dispatch` (см. §17.6) при срабатывании напоминания внутри тихих часов — переносит отправку на `quiet_hours_end + случайная задержка 0–10 минут` (чтобы не было пика в момент окончания тишины).
- Real-time push в кабинет (через WebSocket) — **продолжает работать** (это уведомление активному пользователю, не «на вне»).

**Settings UI (`/settings`):** см. дизайн-бриф 01_Scope §1.7. Два time-picker'а start/end + tooltip «минимум 3 часа», подпись «По часовому поясу `{tenant.timezone}`».

**Что отличается от оригинала:**

- В оригинале (партия 13.2.1) — то же поведение `start_hour 0..23 + end_hour 0..23`, но **timezone настраивается на уровне пользователя**, не тенанта. У нас — **на уровне тенанта** (упрощение, общее окно для всей команды).
- Telegram-канал из оригинала на MVP не воспроизводится. **v8.5 (Биз-20):** Telegram-бот для нотификаций менеджерам появляется в спринте 9 — см. §17.9 ниже.

## 17.9. v8.5: Telegram-канал, эскалация, late payment alert

> **Источник:** реестр v1.12 §13.10 (Биз-20, Биз-24, OPEN-И-25).

### 17.9.1. Telegram-бот для менеджеров (Биз-20, спринт 9)

**Архитектура:**

- На уровне тенанта — **`tenants.telegram_bot_token TEXT NULL`** (зашифровано через `Crypt::encryptString`). Тенант создаёт собственного бота через `@BotFather` (1 раз) и вставляет токен в `/settings/integrations`.
- На уровне пользователя — **`users.telegram_user_id BIGINT NULL`** (Telegram chat_id). Привязка через UI: «Подключить Telegram» → бот выдаёт код → пользователь шлёт `/start <code>` → бот сохраняет `chat_id`.

**Каналы:** Telegram становится 4-м каналом (in-app/push/email/**telegram**) в матрице `users.notification_preferences` (CTO-4) — добавляется ключ `"telegram": true|false` к каждому из 8 типов уведомлений (см. §17.3). Default для существующих пользователей — `false`; включение — через `/account/notifications`.

**Реализация:** Laravel notification channel `App\Notifications\Channels\TelegramChannel`. При `notification_preferences.<type>.telegram = true` AND `users.telegram_user_id != NULL` AND `tenants.telegram_bot_token != NULL` — отправка через Telegram Bot API.

**Quiet hours (см. §17.8) применяются ко всем каналам, включая Telegram.**

**Что НЕ делаем на MVP:**

- Inbound от менеджеров через Telegram (одобрить/закрыть лид через бота). Только outbound нотификации.
- Голосовые/видео-сообщения. Только текст.
- Telegram Mini-App. Используем стандартный bot API.

### 17.9.2. Эскалация просроченных лидов — нотификации (OPEN-И-25)

При срабатывании cron `leads:escalate-stale` (см. §10.8.5) — рассылается **2 уведомления:**

1. **Старому менеджеру** (тот, у кого лид «забылся»): `notification_type='deal.escalated_from_me'` (новый тип в §17.3). Каналы по умолчанию: email + in-app. Текст: «Лид #1234 от 12.05.2026 переназначен от тебя на ИвановИИ — превышен SLA 4 часа без касания».
2. **Новому менеджеру**: `notification_type='deal.escalated_to_me'`. Каналы: email + in-app + push (срочно). Текст: «Тебе переназначен просроченный лид #1234, контакт: ИвановИП +7900-…».

**Опционально** (default off): копия админу тенанта при `escalated_count >= 2` — индикация системной проблемы с менеджером.

### 17.9.3. Late waiting_payment alert (Биз-24)

**Cron `payments:notify-stale`** (раз в час).

**Логика:** для каждого `saas_invoices` со `status='waiting_payment'` AND `created_at + INTERVAL '48 hours' < NOW()` AND `notified_finance=FALSE`:

1. Email finance-роли SaaS (через `saas_admin_users WHERE role='finance' AND is_active`).
2. Bell-нотификация в админке `/admin/billing/stale-invoices` (UI badge на main nav).
3. `saas_invoices.notified_finance = TRUE` (флаг чтобы не дублировать).

**Дополнительно** (default off): если `created_at + INTERVAL '7 days' < NOW()` — повторный алерт + автоматическое создание `incidents_log` type=`payment_overdue`.

**Конфигурация:** ключи в `system_settings`:

- `payments_notify_stale_threshold_hours` (default 48)
- `payments_notify_stale_repeat_hours` (default 168 = 7 дней)
- `payments_notify_stale_enabled` (default true)

---

# 18. Профиль и аккаунт

## 18.1. Подразделы

- `/account` — основной профиль
- `/account/password` — смена пароля
- `/account/security` — 2FA и сессии
- `/account/notifications` — настройки уведомлений
- `/account/api-keys` — API-ключи (см. раздел 19)

## 18.2. Мой аккаунт (/account)

| Поле | Тип | Редактируется |
|---|---|---|
| Аватар | image upload | ✅ |
| Имя | string | ✅ |
| Фамилия | string | ✅ |
| Email | string | ✅ (с подтверждением через email) |
| Телефон | string | ✅ |
| Часовой пояс | select | ✅ (влияет на отображение всех дат в системе) |
| Язык интерфейса | select (ru, en) | ✅ |

## 18.3. Смена пароля (/account/password)

| Поле | Описание |
|---|---|
| Текущий пароль | Обязательно для подтверждения |
| Новый пароль | Min 10 символов, проверка zxcvbn ≥ 3 |
| Подтверждение нового | Должно совпадать |

После смены пароля — все остальные сессии этого пользователя инвалидируются (опция: «Завершить сессии на других устройствах», по умолчанию включена).

## 18.4. Безопасность (/account/security)

**Важно (Биз-9):** все функции безопасности — **2FA, журнал входов, активные сессии, резервные коды, уведомления о подозрительной активности** — доступны **на всех тарифах** (включая «Старт»), не только на Pro+. Это принципиальная позиция: безопасность аккаунта не должна продаваться как опция.

### 18.4.1. Двухфакторная аутентификация (TOTP)

Процесс активации:

1. Кнопка «Включить 2FA»
2. Сервер генерирует TOTP secret через `pragmarx/google2fa-laravel`
3. Показывается QR-код (через `BaconQrCode`) для сканирования в Google Authenticator / Яндекс.Ключ / 1Password
4. Пользователь вводит первый 6-значный код для подтверждения
5. Если корректен — `users.totp_secret` сохраняется (зашифрованный через `Crypt::encrypt`), `totp_enabled = true`
6. Пользователю показываются **8 резервных кодов** (для входа без аутентификатора), хранятся в `user_recovery_codes`

**SMS / Email-OTP — Post-MVP** (требуют SMS-шлюза, удорожают MVP без явной необходимости — см. [Прил. М §2.19](Analiz_originala_v8_3.md)).

```sql
CREATE TABLE user_recovery_codes (
    id              BIGSERIAL PRIMARY KEY,
    user_id         BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    code_hash       VARCHAR(255) NOT NULL,           -- bcrypt hash, оригинал не хранится
    used_at         TIMESTAMPTZ,                     -- одноразовый, используется один раз
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_recovery_user ON user_recovery_codes(user_id) WHERE used_at IS NULL;
```

### 18.4.2. Активные сессии

Список устройств/IP, с которых выполнен вход:

| Колонка | Описание |
|---|---|
| Устройство | User-Agent (parsed: Chrome on macOS) |
| IP | IP-адрес |
| Геолокация | Город, страна (по IP, опционально через ipapi.com) |
| Последняя активность | datetime |
| Текущая? | Бейдж «Текущая» для активной сессии |
| Действие | Кнопка «Завершить» |

```sql
CREATE TABLE user_sessions (
    id              BIGSERIAL PRIMARY KEY,
    user_id         BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    token_hash      VARCHAR(255) UNIQUE NOT NULL,
    ip_address      INET,
    user_agent      TEXT,
    geo_country     VARCHAR(50),
    geo_city        VARCHAR(100),
    last_active_at  TIMESTAMPTZ,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    expires_at      TIMESTAMPTZ NOT NULL
);

CREATE INDEX idx_sessions_user ON user_sessions(user_id, last_active_at DESC);
CREATE INDEX idx_sessions_expires ON user_sessions(expires_at) WHERE expires_at > NOW();
```

### 18.4.3. Лог входов

URL: `/account/security/auth-log`

История последних 50 входов в аккаунт: дата, IP, User-Agent, геолокация (по IP), успешно/неуспешно, причина (неверный пароль / неверный 2FA-код / OK / заблокирован rate-limit).

Хранится в таблице `auth_log` бессрочно (для compliance с 152-ФЗ — журнал доступа к ПДн). Старше 1 года — архивируется в холодное хранилище.

### 18.4.4. Уведомления о подозрительной активности

Email-уведомление отправляется автоматически при:

| Событие | Когда |
|---|---|
| Вход с нового устройства/IP | Если комбинация User-Agent + IP /24 не встречалась за последние 30 дней |
| 5 неуспешных попыток входа подряд | rate-limit сработал — email с IP и User-Agent |
| Смена пароля | Сразу после успешной смены — на старый email и (если изменился) на новый |
| Включение/выключение 2FA | Любое изменение `totp_enabled` |
| Активация резервного кода | Использование recovery code вместо TOTP |
| Завершение чужой сессии | Если пользователь сам нажал «Завершить» в /security |

Канал доставки — `email` (всегда включено по умолчанию, пользователь может отключить отдельно через [§18.5](#185-настройки-уведомлений-accountnotifications) → «Вход в аккаунт с нового устройства»). **Push не используется** — уведомление о подозрительном входе должно прийти даже если устройство, на котором настроен push, скомпрометировано.

### 18.4.5. Конкурентное преимущество vs оригинал crm.bp-gr.ru

**Что зафиксировано аудитом оригинала (партии 14.3.2–14.3.4, см. [Прил. М §2.19](Analiz_originala_v8_3.md)).** На странице `/admin/user/account` оригинала **отсутствуют**:

| Функция | В оригинале | У нас |
|---|---|---|
| Журнал входов | ❌ `/admin/user/auth-log` отдаёт пустую страницу 217 байт | ✅ §18.4.3 — таблица `auth_log`, последние 50 входов в UI |
| Список активных сессий | ❌ `/admin/user/sessions` — заглушка «Технические работы» | ✅ §18.4.2 — таблица `user_sessions` + кнопка «Завершить» |
| Управление устройствами | ❌ `/admin/user/devices` — пустая 217 байт | ✅ User-Agent в `user_sessions`, geolocation по IP |
| 2FA (TOTP / SMS / Email-OTP) | ❌ `/admin/user/2fa` — пустая | ✅ §18.4.1 — TOTP через Google2FA, на всех тарифах |
| Резервные коды | ❌ Не реализованы | ✅ §18.4.1 — 8 одноразовых кодов в `user_recovery_codes` |
| Уведомления о подозрительной активности | ❌ Нет | ✅ §18.4.4 — 6 типов email-алертов |
| Защита формы смены пароля | 🔴 Пароль в `<input type="text">` plaintext (партия 14.3.4) | ✅ §18.3 + §22 — `type="password"` + rate-limit 3/час, см. [§22 антипаттерны](#22-безопасность) |

**Маркетинговое позиционирование** (security-differentiator из [§1.4.1](#141-конкурентные-преимущества-vs-оригинал-crmbp-grru)): «2FA + журнал входов + список сессий + резервные коды + история смены пароля — у конкурентов отсутствует».

## 18.5. Настройки уведомлений (/account/notifications)

Таблица переключателей:

| Уведомление | In-app | Push | Email |
|---|---|---|---|
| Новый лид поступил | ✅ | [✅] | [ ] |
| Напоминание по сделке | ✅ | [✅] | [✅] |
| Низкий баланс лидов | — | — | [✅] |
| Баланс = 0 | — | — | [✅] |
| Успешное пополнение | — | — | [✅] |
| Счёт оплачен | — | — | [✅] |
| Вход в аккаунт с нового устройства | — | — | [✅] |
| Маркетинговые рассылки | — | — | [ ] |

Хранится в `users.notification_preferences JSONB` (CTO-4 v8.1: финальное решение — JSONB, не 4 раздельных BOOLEAN; полная структура и дефолты — в DDL раздела 7.3).

**Звук уведомлений** — отдельный глобальный toggle `users.sound_enabled` (BOOLEAN). Выведен отдельно, потому что играет именно в открытой in-app вкладке и не привязан к каналу.

**Расширяемость:** новый тип уведомления добавляется правкой дефолта в DDL + миграцией патча для существующих пользователей (`UPDATE users SET notification_preferences = notification_preferences || '{"new_type": {...}}'::jsonb`). Миграция структуры БД не нужна.

## 18.6. Удаление аккаунта (152-ФЗ)

В нижней части `/account` — секция «Опасная зона»:

> **Удалить аккаунт**
>
> Удаление приведёт к полному стиранию ваших данных через 30 дней. В течение этого срока вы можете восстановить аккаунт. После 30 дней восстановление невозможно.
>
> [Удалить аккаунт]

Поскольку на MVP **один пользователь = одно тенант-владение**, удаление пользователя = удаление тенанта (см. раздел 4.5).

---

# 19. Исходящий REST API

## 19.1. Назначение

Внешние системы (AmoCRM, Битрикс24, собственные сайты, мобильные приложения) могут читать и обновлять данные тенанта через публичный REST API.

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

Bearer Token (Laravel Sanctum):

```
Authorization: Bearer {api_key}
```

Ключи создаются в личном кабинете: `/account/api-keys`. Один тенант может иметь несколько ключей с разными правами и сроком жизни.

## 19.3. Схема api_keys

```sql
CREATE TABLE api_keys (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    user_id         BIGINT NOT NULL REFERENCES users(id),
    name            VARCHAR(255) NOT NULL,           -- "Битрикс24 интеграция"
    key_hash        VARCHAR(255) UNIQUE NOT NULL,   -- bcrypt hash, оригинал НЕ хранится
    key_prefix      VARCHAR(10) NOT NULL,            -- первые 8 символов, для отображения "abc123de..."
    scopes          JSONB DEFAULT '["read"]',        -- ["read","write","delete"]
    last_used_at    TIMESTAMPTZ,
    last_used_ip    INET,
    expires_at      TIMESTAMPTZ,                     -- NULL = бессрочный
    is_active       BOOLEAN DEFAULT TRUE,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_api_keys_tenant ON api_keys(tenant_id) WHERE is_active = TRUE;
```

Оригинал ключа (например, `crm_live_a3f7d8...`) показывается **один раз** при создании. После — клиент должен скопировать. В БД хранится только хеш.

## 19.4. Эндпоинты

| Метод | URL | Scope | Описание |
|---|---|---|---|
| GET | `/api/v1/deals` | read | Список сделок с пагинацией и фильтрами |
| GET | `/api/v1/deals/{id}` | read | Детали сделки |
| PATCH | `/api/v1/deals/{id}` | write | Обновить сделку (статус, комментарий, имя, теги) |
| DELETE | `/api/v1/deals/{id}` | delete | Удалить сделку |
| GET | `/api/v1/projects` | read | Список проектов |
| GET | `/api/v1/projects/{id}` | read | Детали проекта |
| GET | `/api/v1/projects/{id}/deals` | read | Сделки по проекту |
| GET | `/api/v1/analytics/summary` | read | Сводная аналитика |
| GET | `/api/v1/analytics/funnel` | read | Воронка |
| GET | `/api/v1/comment-templates` | read | Шаблоны |
| GET | `/api/v1/deal-tags` | read | Теги |
| POST | `/api/v1/deals/{id}/tags` | write | Привязать теги |
| GET | `/api/v1/balance` | read | Текущий баланс (лиды + рубли) |

## 19.5. Параметры фильтрации (GET /api/v1/deals)

| Параметр | Тип | Описание |
|---|---|---|
| `project_id` | int | Фильтр по проекту |
| `project_name` | string | Фильтр по названию проекта (полное совпадение) |
| `status` | string или array | Один или несколько slug статусов |
| `tag_ids` | array | Фильтр по тегам (OR) |
| `manager_id` | int | Фильтр по менеджеру |
| `phone` | string | Поиск по телефону (точное совпадение) |
| `search` | string | Полнотекстовый поиск (phone + name + comment) |
| `received_from` | datetime ISO 8601 | От даты получения |
| `received_to` | datetime ISO 8601 | До даты получения |
| `updated_after` | datetime | Изменённые после (для синхронизации с внешней системой) |
| `page` | int | Страница (default 1) |
| `per_page` | int | Записей на странице (default 50, max 100) |
| `sort` | string | Поле + направление: `received_at:desc`, `id:asc` |
| `include` | array | `tags`, `project`, `manager`, `activity_log` (для embed-связей) |

## 19.6. Формат ответа

**Список:**

```json
{
  "data": [
    {
      "id": 12345,
      "source_crm_id": 432176649,
      "phone": "79000000000",
      "phones": ["79000000000"],
      "contact_name": "Иван",
      "status": "negotiations",
      "status_name": "Переговоры",
      "comment": "Перезвонить завтра",
      "received_at": "2026-04-01T09:12:14Z",
      "created_at": "2026-04-01T09:12:14Z",
      "updated_at": "2026-04-01T15:30:00Z",
      "project": {
        "id": 1,
        "name": "Caranga",
        "tag": "Caranga"
      },
      "manager": {
        "id": 5,
        "name": "Дмитрий Петров"
      },
      "tags": [
        { "id": 2, "name": "VIP", "color_hex": "#F59E0B" }
      ]
    }
  ],
  "meta": {
    "current_page": 1,
    "per_page": 50,
    "total": 1234,
    "total_pages": 25
  },
  "links": {
    "first": "...",
    "next": "...",
    "last": "..."
  }
}
```

**Ошибки:**

```json
{
  "error": {
    "code": "validation_failed",
    "message": "Validation errors",
    "details": {
      "phone": ["Неверный формат телефона"]
    }
  }
}
```

HTTP-коды:

| Код | Когда |
|---|---|
| 200 | OK |
| 201 | Создано |
| 204 | OK без тела |
| 400 | Неверный синтаксис запроса |
| 401 | Нет/неверный API-ключ |
| 403 | Ключ не имеет нужного scope |
| 404 | Ресурс не найден или принадлежит другому тенанту |
| 422 | Валидация не прошла |
| 429 | Rate limit превышен |
| 500 | Внутренняя ошибка |

## 19.7. Rate limiting

- 60 запросов/минуту на ключ (настраивается через `system_settings.api_rate_limit_per_minute`)
- Заголовки ответа: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
- Превышение → 429 + `Retry-After: 30`

## 19.8. OpenAPI

Полная OpenAPI 3.1 спецификация генерируется через `darkaonline/l5-swagger`. Доступна по `/api/docs` (Swagger UI). Для интеграторов автоматически генерируется TypeScript-клиент через `openapi-typescript-codegen`.

## 19.9. Версионирование

API версионируется через URL-prefix (`/api/v1/...`). Изменения, ломающие совместимость, выпускаются как `/api/v2/`. Старая версия поддерживается минимум 12 месяцев после релиза новой. Депрекация анонсируется через `Deprecation` и `Sunset` HTTP-заголовки.

## 19.10. Outbound webhook (подписка внешних систем на события)

**Статус:** ✅ **MVP** (Уровень 1 стратегии CRM-интеграций — решение [OPEN-И-2](Открытые_вопросы_v8_3.md), закрыто 04.05.2026).

### 19.10.1. Назначение

Тенант регистрирует свою URL и подписывается на события (`deal.created`, `deal.status_changed`, `deal.commented`, …). При наступлении события мы отправляем POST с JSON-телом и HMAC-подписью на этот URL. Внешняя система (CRM клиента, корпоративный data-pipeline, Zapier, n8n, custom backend) принимает данные и обрабатывает у себя.

**Уровень 2** — нативные коннекторы к внешним CRM (без необходимости поднимать собственный приёмник):

| CRM | Сроки | Источник решения |
|---|---|---|
| amoCRM | Спринт 14–15 (релиз ~Q3 2026) | OPEN-И-2 |
| Bitrix24 | Post-MVP | OPEN-И-2 |
| RetailCRM | Post-MVP | OPEN-И-2 |

Для всех Уровней (включая нативные коннекторы) outbound webhook остаётся доступен как **fallback-канал** — даже если клиент использует amoCRM-коннектор, он может одновременно отправлять копию событий в свой webhook.

### 19.10.2. Конкурентное преимущество — 7 линий доказательств

**Что зафиксировано аудитом оригинала (партии 9–10, 15, см. [Прил. М §9.4.2](Analiz_originala_v8_3.md)).** В crm.bp-gr.ru outbound webhook subsystem **отсутствует полностью**. 7 независимых линий проверки:

| # | Линия проверки | Партия | Результат |
|---|---|---|---|
| 1 | Карточка проекта — поля настроек | 10 | Нет полей webhook |
| 2 | Форма создания проекта | 10 | Нет полей webhook |
| 3 | История действий проекта за 5 месяцев | 10 | Нет логов outbound webhook |
| 4 | WebSocket / SSE каналы | 9 | 0 каналов |
| 5 | DOM по 24 webhook-keyword | 10 | 0 совпадений |
| 6 | Список «API ▾» (12 пунктов меню) | 15.1 | Ни одного с outbound webhook-функционалом |
| 7 | Открытые карточки 5 категорий интеграций (Битрикс24, amoCRM, Скорозвон, Unisender, Мои Звонки) | 15.2 | 0/5 редактируемых полей «outbound webhook URL» |
| + | JS-код по 31 keyword (~71 КБ кастомного JS) | 15.3 | 0 совпадений |
| + | Network-мониторинг исходящих XHR | 15.4 | 0 внешних XHR; все вызовы провайдер-API — серверной стороной |

**Уточнение.** В HTML карточки Bitrix24 оригинала найден read-only URL `https://prostats.info/bitrix24/webhook.php?id=...` — это **inbound-канал** (Bitrix → prostats.info → CRM серверным путём), а не generic outbound webhook subsystem. **Не считается** outbound webhook'ом.

**Маркетинг** (преимущество №1 из 6, см. [§1.4.1](#141-конкурентные-преимущества-vs-оригинал-crmbp-grru)): «отдаём лиды и события сделок в любую внешнюю систему по webhook'у — у конкурентов нет».

### 19.10.3. Поддерживаемые события

| Событие | Когда отправляется |
|---|---|
| `deal.created` | Сделка создана (из webhook поставщика, ручного создания или CSV-импорта) |
| `deal.status_changed` | Любая смена статуса (системного или кастомного) |
| `deal.manager_changed` | Назначен/изменён ответственный менеджер |
| `deal.commented` | Изменён комментарий |
| `deal.tag_added` / `deal.tag_removed` | Привязан/отвязан тег |
| `deal.deleted` | Сделка удалена |
| `deal.restored` | Сделка восстановлена из soft-delete |

Полный список совпадает с событиями, логируемыми в `activity_log` (см. [§14.2](#142-отслеживаемые-события)). При публикации новых событий в `activity_log` их доступность в outbound webhook добавляется автоматически.

### 19.10.4. Формат запроса

```http
POST {tenant_webhook_url} HTTP/1.1
Content-Type: application/json
User-Agent: Liderra-Webhook/1.0
X-Liderra-Event: deal.status_changed
X-Liderra-Delivery: 7f3e9c2a-4b1d-4e5a-9f8b-2c1d3e4f5a6b
X-Liderra-Timestamp: 1746547200
X-Liderra-Signature: sha256=a3f7d8b2c1e4...

{
  "event": "deal.status_changed",
  "occurred_at": "2026-05-06T18:30:00Z",
  "tenant_id": 42,
  "data": {
    "deal_id": 12345,
    "phone": "79000000000",
    "contact_name": "Иван",
    "old_status": "negotiations",
    "new_status": "paid",
    "manager": { "id": 5, "name": "Дмитрий Петров" },
    "project": { "id": 1, "name": "Caranga" },
    "changed_by": { "id": 5, "name": "Дмитрий Петров" }
  }
}
```

### 19.10.5. Подпись HMAC SHA-256

```
signature = "sha256=" + hex(hmac_sha256(secret, timestamp + "." + body))
```

`secret` — генерируется при создании подписки, показывается **один раз**, в БД хранится только хеш (см. [§19.3](#193-схема-api_keys) — аналогичный паттерн с `api_keys`). Получатель должен:

1. Проверить, что `|now() - timestamp| < 5 минут` (защита от replay).
2. Вычислить ожидаемую подпись и сравнить через constant-time compare.
3. При несовпадении — вернуть 401 (мы залогируем delivery как failed).

### 19.10.6. Retry-логика

| Попытка | Задержка после предыдущей |
|---|---|
| 1 (первая отправка) | сразу при событии |
| 2 | через 30 секунд |
| 3 | через 5 минут |
| 4 | через 30 минут |
| 5 | через 2 часа |
| 6 | через 6 часов |
| 7 | через 24 часа |

Успешная доставка — HTTP 2xx. Любой другой код (включая 5xx, 429, таймауты, ошибки DNS) — попытка засчитывается failed. После 7 неуспешных попыток подписка не отключается, но в админке тенанта появляется бейдж «🔴 Доставка нестабильна», и события за этот период видны в журнале как `permanently_failed` (для ручного recover через POST `/api/v1/webhook-deliveries/{id}/redeliver`).

Все отправки — через очередь Redis (`SendOutboundWebhookJob`), не блокируют ProcessWebhookJob или UI-операции пользователя.

### 19.10.7. Управление в кабинете тенанта

URL: `/settings/outbound-webhooks`

| Действие | Описание |
|---|---|
| Добавить подписку | URL + список событий (чекбоксы) + опциональный header (для авторизации на стороне получателя) → генерация secret (показывается один раз) |
| Тестовая отправка | Кнопка «Отправить тестовое событие `deal.created`» с фиктивными данными |
| Журнал доставок | Последние 100 попыток: timestamp, событие, HTTP-код, длительность, тело ответа (первые 1 КБ) |
| Перевыпуск secret | Старый перестаёт работать сразу |
| Пауза/снятие с паузы | Не удаляя подписку, временно остановить отправку |
| Удаление | Полное удаление подписки и истории доставок |

### 19.10.8. Схема БД

DDL таблиц `outbound_webhook_subscriptions` и `outbound_webhook_deliveries` — в [`db/schema.sql`](../db/schema.sql) v8.4 (см. CHANGELOG запись Z). Структура аналогична `api_keys` (хеш secret, prefix для отображения, scopes-аналог = массив подписанных событий) + отдельная таблица доставок с retention 90 дней (как `webhook_log`).

### 19.10.9. Rate limiting и квоты

- Максимум **10 активных подписок** на тенанта (защита от misuse).
- Максимум **50 событий/секунду** на подписку (всплеск массовой выгрузки CSV → throttle с замедлением до 50/сек, не отбрасывая события).
- Полная история доставок хранится **90 дней**, далее агрегаты (count success/fail по дням) — 1 год.

### 19.10.10. CSP и безопасность URL получателя

При добавлении подписки URL валидируется:

- Только `https://` (http:// запрещён).
- Запрещены приватные диапазоны IP (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `127.0.0.0/8`, `169.254.0.0/16`) — защита от SSRF (попыток через webhook достучаться до внутренних сервисов нашей инфраструктуры). DNS-резолв проверяется при каждой отправке.
- Запрещены специальные хосты: `localhost`, `metadata.google.internal`, `169.254.169.254` (AWS/GCP/Yandex Cloud metadata).
- Проверка SSL-сертификата получателя обязательна (без опции `allow_self_signed`).

### 19.10.11. v8.5: DNS-rebinding защита (OPEN-И-18)

**Проблема:** до v8.5 SSRF-фильтр §19.10.10 проверял IP-адрес при добавлении подписки. Между «проверка при создании» и «реальный отправка через 5 минут» атакующий мог поменять DNS-запись `evil.example.com` с публичного IP (прошёл фильтр) на `127.0.0.1` (внутренний сервис). Это классический DNS-rebinding.

**Решение в v8.5:**

`App\Services\Webhook\SSRFGuard` при каждой отправке делает:

1. **Resolve once** — `gethostbyname()` → получаем IP.
2. **Validate IP** — фильтр RFC1918 + специальные хосты (как в §19.10.10), но **на pinned IP** (не на DNS-имени).
3. **Pin** — открываем HTTP-соединение к `https://<pinned_ip>` с `Host: <hostname>` header (для SSL SNI и virtual host routing).
4. **Connect** — `curl_setopt(CURLOPT_RESOLVE, ["{$hostname}:443:{$pinned_ip}"])`. Это «прибивает» DNS на время сессии — никаких повторных resolve'ов.
5. **Лимит redirect** — `CURLOPT_MAXREDIRS = 3`. При redirect — повторный SSRF-фильтр на новый Location.
6. **Лимит body** — `CURLOPT_RANGE = 0-1048576` (1 MB). Прерываем чтение если получатель попытается отправить нам гигабайт.

**CA-bundle:** доверенный CA-bundle Mozilla, обновляется в Docker-образе при пересборке. Self-signed запрещены даже на staging.

**Совместимость:** все существующие тенанты с corretly-configured webhook URLs продолжают работать без изменений. SSRF-фильтр прозрачен для них.

### 19.10.12. v8.5: marketing.conversion event (Биз-21)

**Новый event type** `marketing.conversion` — для интеграций с Я.Директ Offline + VK Ads Goals (классические pixel-back события).

**Whitelist в §19.10.3 расширяется:**

```
deal.created
deal.status_changed
deal.manager_changed
deal.commented
deal.tag_added
deal.tag_removed
deal.deleted
deal.restored
+ marketing.conversion        ← v8.5
```

**Когда генерируется:** при переходе `deals.status` в финальный статус-конверсия (`paid`, `worked_done` или кастомный, помеченный `lead_statuses.is_conversion=TRUE` — это новое поле, default FALSE; пока на MVP только `paid` помечен флагом). Application code в `DealStatusChangedListener` формирует event и шлёт через `OutboundDispatcher`.

**Payload:**

```json
{
  "event": "marketing.conversion",
  "delivery_uuid": "...",
  "deal_id": 1234,
  "tenant_id": 567,
  "received_at": "2026-05-12T10:30:00Z",
  "converted_at": "2026-05-15T16:45:00Z",
  "utm": {
    "source": "yandex",
    "medium": "cpc",
    "campaign": "spring2026",
    "content": "ad_variant_b"
  },
  "phone_hash": "sha256:abcdef…",  ← хеш для дедупликации в Я.Директ
  "conversion_value_rub": 50.00     ← lead_cost из tariff_plans
}
```

**Использование на стороне клиента:**

- **Я.Директ Offline:** клиент настраивает приёмник в виде Yandex.Direct Offline Conversion API endpoint, мапит `phone_hash` → `ClientId`.
- **VK Ads Goals:** аналогично через VK Marketing API.
- **Generic adapter:** клиент может писать любой свой обработчик. **Нативные коннекторы (Биз-21 вариант B) — не делаем на MVP** — клиент сам пишет адаптер.

---

# 20. SaaS-биллинг (уровень 1: вы → клиенты SaaS)

## 20.1. Концепция: кошельковая модель

Клиент SaaS пополняет свой денежный баланс (`tenants.balance_rub`) разовыми платежами. Этот баланс конвертируется в лиды по текущему тарифу (`tariff_plans.price_per_lead`). Например:

```
Клиент на тарифе "Старт" (50 ₽/лид)
↓
Пополнение 5000 ₽
↓
balance_rub = +5000.00
balance_leads = +100 (5000 / 50)
```

**Особенности:**

- Нет рекуррентных списаний (нет ежемесячной автоплаты)
- Нет привязки карт для автопополнения (упрощает PCI DSS, не нужны рекурренты в шлюзе)
- При смене тарифа конвертация остатка пересчитывается: остался баланс 1500 ₽, переход с тарифа "Старт" (50 ₽/лид) на "Профессиональный" (30 ₽/лид) → balance_leads пересчитывается: 1500 / 30 = 50 лидов
- При отмене подписки баланс **сгорает** — частичных возвратов нет (см. раздел 20.6)

## 20.2. Тарифные планы

### 20.2.1. Структура

```sql
CREATE TABLE tariff_plans (
    id              BIGSERIAL PRIMARY KEY,
    code            VARCHAR(50) UNIQUE NOT NULL,    -- 'start', 'basic', 'pro', 'custom_corp_xyz'
    name            VARCHAR(255) NOT NULL,
    description     TEXT,

    -- Модель оплаты
    billing_model   VARCHAR(50) NOT NULL,           -- 'per_lead', 'monthly', 'hybrid', 'custom'
    price_per_lead  DECIMAL(10,2),                  -- цена за лид (для per_lead и hybrid)
    price_monthly   DECIMAL(10,2),                  -- абонплата в месяц (для monthly и hybrid)
    included_leads  INT,                             -- сколько лидов входит в подписку (для monthly и hybrid)

    -- Лимиты и фичи (расширяемо через JSONB)
    limits          JSONB DEFAULT '{}',             -- {"max_users": 5, "max_projects": 10, "api_rps": 60}
    features        JSONB DEFAULT '[]',             -- ["kanban", "advanced_analytics", "api", "2fa", "custom_domain"]

    -- Trial-параметры
    trial_bonus_leads INT DEFAULT 0,                -- индивидуальный стартовый бонус для тарифа

    -- Видимость и активность
    is_active       BOOLEAN DEFAULT TRUE,
    is_public       BOOLEAN DEFAULT TRUE,           -- виден на странице регистрации
    sort_order      INT DEFAULT 0,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);
```

### 20.2.2. Стартовый набор тарифов (v8.1, Биз-1)

На старте создаются **4 тарифа-заглушки** через миграцию-сид (`schema.sql` v8.1, раздел 11). **Структура и `code` зафиксированы** (контракт с кодом — миграции, тесты, фичефлаги ссылаются на эти коды). **Цены, лимиты и фичи** заполняются заказчиком через админку до публичного запуска.

| `code` | Название | `billing_model` | Цена/лид | Абонплата | Лидов в подписке | `is_public` | Особенности |
|---|---|---|---|---|---|---|---|
| `start` | Старт | `per_lead` | заглушка 1.00 ₽ | — | — | TRUE | Без подписки, базовый функционал |
| `basic` | Базовый | `hybrid` | заглушка 1.00 ₽ | заглушка 1.00 ₽ | 100 | TRUE | Малые отделы, Kanban |
| `pro` | Про | `hybrid` | заглушка 1.00 ₽ | заглушка 1.00 ₽ | 300 | TRUE | API, расширенная аналитика, 2FA |
| `enterprise` | Корпоративный | `custom` | по договору | по договору | по договору | **FALSE** | Скрыт на публичной странице, оформляется через продажника |

**Биллинг-модели зафиксированы:** `start = per_lead`, `basic/pro = hybrid`, `enterprise = custom`. Это покрывает все 4 значения enum `billing_model` — критично для тестов биллинг-движка.

**Лимиты** (заглушки в DDL, заказчик правит через админку): `start` = 1 user/3 проекта/60 RPS, `basic` = 3/10/60, `pro` = 10/50/120, `enterprise` = по договору.

**Фичи** (реальные значения, проверяются кодом): `webhook` (везде), `kanban` (basic+), `basic_analytics` (везде), `advanced_analytics` (pro+), `api` (pro+), `2fa` (pro+ — но **см. Биз-9, рекомендация — на всех тарифах**), `custom_domain` (enterprise).

**Стартовый бонус:** `tariff_plans.trial_bonus_leads = 50` для всех 4 тарифов. Логика загрузки бонуса — `tariff_plans.trial_bonus_leads` если задан (не NULL), иначе fallback на `system_settings.trial_bonus_leads`.

### 20.2.3. Подписки тенантов (v8.1, CTO-1, CTO-2, Биз-2)

```sql
CREATE TABLE tariff_subscriptions (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    tariff_plan_id  BIGINT NOT NULL REFERENCES tariff_plans(id),
    started_at      TIMESTAMPTZ NOT NULL,                  -- для scheduled — момент в будущем (00:00 МСК)
    expires_at      TIMESTAMPTZ,                            -- NULL = бессрочно (для per_lead)
    status          VARCHAR(20) DEFAULT 'active'
                    CHECK (status IN ('scheduled','active','superseded','expired','cancelled')),
    cancelled_at    TIMESTAMPTZ,                            -- момент cancelled
    superseded_at   TIMESTAMPTZ,                            -- момент superseded (когда новая active заменила эту)
    custom_overrides JSONB,                                  -- индивидуальные условия
    created_by_type VARCHAR(20)
                    CHECK (created_by_type IN ('tenant_user','saas_admin','system')),
    created_by_id   BIGINT,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

-- v8.1, CTO-1: гарантия "одна active подписка на тенанта" на уровне БД
CREATE UNIQUE INDEX idx_subscriptions_one_active_per_tenant
    ON tariff_subscriptions(tenant_id) WHERE status = 'active';

-- Аналогичная гарантия для scheduled
CREATE UNIQUE INDEX idx_subscriptions_one_scheduled_per_tenant
    ON tariff_subscriptions(tenant_id) WHERE status = 'scheduled';

CREATE INDEX idx_subscriptions_tenant_status ON tariff_subscriptions(tenant_id, status);
CREATE INDEX idx_subscriptions_apply_scheduled ON tariff_subscriptions(started_at)  WHERE status = 'scheduled';
CREATE INDEX idx_subscriptions_apply_expired   ON tariff_subscriptions(expires_at)  WHERE status = 'active' AND expires_at IS NOT NULL;
```

**Жизненный цикл (v8.1, CTO-1, CTO-2, Биз-2):**

5 состояний enum:

| Статус | Семантика |
|--------|-----------|
| `scheduled` | Будущая активация в 00:00 МСК. Может существовать одновременно с `active`. |
| `active` | Текущий тариф тенанта. Уникально на тенанта. |
| `superseded` | Закрыта новой подпиской. Чаще всего `superseded_at` совпадает с `started_at` новой active. |
| `expired` | Истекла по `expires_at`. Каскадно `tenants.status='suspended'` (CTO-2). |
| `cancelled` | Принудительно отменена админом SaaS при блокировке тенанта (Биз-2: клиент сам не отменяет). |

**Cron `tariffs:apply-scheduled` (каждые 5–10 минут):**

- `scheduled → active`, если `started_at <= NOW()`. Каскадно: текущая `active` → `superseded`, `tenants.current_tariff_id` обновляется.
- `active → expired`, если `expires_at <= NOW()`. Каскадно: `tenants.status = 'suspended'`, email клиенту.

**Время отсечки 00:00 МСК фиксированно** (не таймзона тенанта). Старый тариф действует до 00:00 МСК следующих суток, новый — после. Для тенантов в Калининграде/Владивостоке — UI явно сообщает «новый тариф вступит в силу через X часов Y минут (00:00 МСК)».

**Поведение при `expired` (CTO-2, вариант Б — жёсткая блокировка):**

- `tenants.status = 'suspended'`. Webhook возвращает 402, **лиды теряются безвозвратно** (не попадают в `rejected_deals_log`).
- Экраны клиентского приложения показывают баннер «Подписка истекла», большинство — read-only.
- `/billing` остаётся полностью функциональным для возобновления.
- Восстановление — создание новой подписки через `/billing`.
- Email-уведомления (cron `subscription:expiry-notify`): за 7 дней до `expires_at`, за 1 день, в момент истечения.
- **Намеренная асимметрия с per_lead+нулевой баланс**: там лиды складываются в `rejected_deals_log` (восстановимы при пополнении), статус остаётся `active`. Это разные бизнес-ситуации — сохраняем разное поведение.

**Отмена клиентом (Биз-2: вариант А — не предусмотрена):**

- В `/billing` **нет кнопки «Отменить подписку»**. У нас не подписка как у Netflix, а кошельковая модель — рекуррентов нет, отменять формально нечего.
- Уход из системы: переход на тариф `start` (per_lead, без абонплаты), удаление аккаунта (152-ФЗ §18.6), естественное истечение `expires_at`.
- `cancelled` используется **только** при принудительной блокировке тенанта админом SaaS.

**История тарифов хранится бессрочно** — `superseded`, `expired`, `cancelled` записи не удаляются. Полная летопись смен тарифа за всё время жизни тенанта, нужна бухгалтерии и аудиту.

> Полная state-machine с триггерами всех переходов и побочными эффектами — в **Приложении Б+В — `Приложение_Б_В_БД_диаграммы_v8_3.md`** (Часть В, раздел 2).

## 20.3. Юр. лица (получатели платежей)

Заказчик SaaS может работать от лица **нескольких юр. лиц / ИП** — например, ООО для ОСН-клиентов и ИП для упрощённого приёма платежей.

```sql
CREATE TABLE legal_entities (
    id              BIGSERIAL PRIMARY KEY,
    code            VARCHAR(50) UNIQUE NOT NULL,    -- "ooo_main", "ip_ivanov"
    name            VARCHAR(500) NOT NULL,
    short_name      VARCHAR(255),                   -- "ООО Ромашка"
    legal_form      VARCHAR(20) NOT NULL,           -- 'OOO', 'IP', 'AO', 'PAO', 'NKO'
    -- Реквизиты
    inn             VARCHAR(12) NOT NULL,
    kpp             VARCHAR(9),                     -- NULL для ИП
    ogrn            VARCHAR(15),
    okpo            VARCHAR(10),
    -- Адреса
    legal_address   TEXT,
    actual_address  TEXT,
    -- Банк
    bank_name       VARCHAR(255),
    bank_account    VARCHAR(20),
    bank_bik        VARCHAR(9),
    bank_corr       VARCHAR(20),
    -- Подписант
    director_name   VARCHAR(255),
    director_post   VARCHAR(255) DEFAULT 'Генеральный директор',
    director_basis  VARCHAR(255) DEFAULT 'Устава',  -- Устава / Доверенности №...
    -- НДС
    vat_mode        VARCHAR(20) DEFAULT 'no_vat',   -- vat20, vat10, vat7, vat5, vat0, no_vat, usn_6, usn_15
    -- Файлы (S3)
    signature_path  VARCHAR(500),                   -- скан подписи директора (PNG, прозрачный фон)
    stamp_path      VARCHAR(500),                   -- скан печати (PNG, прозрачный фон)
    logo_path       VARCHAR(500),
    -- Управление
    is_active       BOOLEAN DEFAULT TRUE,
    is_default      BOOLEAN DEFAULT FALSE,          -- юр. лицо по умолчанию
    sort_order      INT DEFAULT 0,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ
);

CREATE UNIQUE INDEX idx_legal_entities_default ON legal_entities(is_default) WHERE is_default = TRUE;
```

**Управление через админку SaaS** (отдельная админ-панель, недоступная клиентам). Заказчик может добавить/изменить юр. лицо без релиза кода.

**Связь с шлюзами:** каждое юр. лицо может быть связано со своим набором шлюзов (например, ООО → ЮKassa-кабинет ООО, ИП → ЮKassa-кабинет ИП). Это решается через `payment_gateways.legal_entity_id`.

## 20.4. Платёжные шлюзы (Driver pattern)

### 20.4.1. Интерфейс

```php
interface PaymentGatewayDriver
{
    public function createPayment(PaymentRequest $request): PaymentResult;
    public function verifyWebhookSignature(string $payload, array $headers): bool;
    public function parseWebhook(array $payload): WebhookEvent;
    public function getPaymentStatus(string $gatewayPaymentId): PaymentStatus;
    public function refund(string $gatewayPaymentId, int $amountKopecks): RefundResult;
    public function getCode(): string;       // 'yookassa', 'robokassa', ...
    public function getDisplayName(): string;
}
```

Реализации (выбираются по факту):

- `YooKassaDriver` (рекомендуется как основной)
- `RobokassaDriver`
- `TinkoffDriver`
- `CloudPaymentsDriver`
- `SbpDriver` (через банк-эквайер с поддержкой СБП)

Новый шлюз добавляется без правок ядра — реализация интерфейса + регистрация в `payment_gateways`.

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

```sql
CREATE TABLE payment_gateways (
    id              BIGSERIAL PRIMARY KEY,
    code            VARCHAR(50) UNIQUE NOT NULL,    -- 'yookassa', 'robokassa', ...
    name            VARCHAR(255) NOT NULL,          -- "ЮKassa (ООО Ромашка)"
    driver          VARCHAR(50) NOT NULL,           -- класс драйвера
    legal_entity_id BIGINT NOT NULL REFERENCES legal_entities(id),
    config          TEXT NOT NULL,                  -- ШИФРОВАН Crypt::encrypt({"shop_id":"...", "secret_key":"..."})
    is_active       BOOLEAN DEFAULT FALSE,
    accepts_methods JSONB DEFAULT '[]',             -- ["card", "yoomoney", "qiwi", "sbp", "bank_transfer"]
    min_amount_rub  DECIMAL(10,2) DEFAULT 100.00,
    max_amount_rub  DECIMAL(12,2),
    sort_order      INT DEFAULT 0,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);
```

Конфиг шифруется через `Crypt::encrypt()`. Секретные ключи никогда не лежат в БД в открытом виде.

### 20.4.3. Webhook от шлюзов

Каждый шлюз отправляет callback на наш `/billing/webhooks/{gateway_code}`:

```
ЮKassa → POST /billing/webhooks/yookassa
   ↓
YooKassaController::handle:
   1. Валидация подписи через YooKassaDriver::verifyWebhookSignature
   2. Парсинг события: parseWebhook
   3. Применение результата:
      - payment.succeeded → обновить saas_transactions.status, увеличить balance_rub, конвертировать в лиды
      - payment.canceled → обновить status = failed
      - refund.succeeded → обновить, уменьшить balance_rub
   4. Запись в audit_log
   5. Уведомление клиенту (email)
```

## 20.5. Транзакции SaaS-уровня

```sql
CREATE TABLE saas_transactions (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    type            VARCHAR(50) NOT NULL,
    -- Типы: topup, refund, manual_credit (ручное начисление админом), manual_debit
    amount_rub      DECIMAL(12,2) NOT NULL,
    balance_rub_after DECIMAL(12,2),
    leads_credited  INT DEFAULT 0,                  -- сколько лидов было начислено
    -- Шлюз
    gateway_id      BIGINT REFERENCES payment_gateways(id),
    gateway_code    VARCHAR(50),
    gateway_payment_id VARCHAR(255),                -- ID в шлюзе
    gateway_idempotence_key UUID,                   -- наш UUID, отправляется в шлюз
    -- Способ оплаты
    payment_method  VARCHAR(50),                    -- 'card', 'yoomoney', 'sbp', 'bank_transfer', 'qiwi', 'webmoney'
    -- Юр. лицо получатель
    legal_entity_id BIGINT REFERENCES legal_entities(id),
    -- Документы
    invoice_id      BIGINT REFERENCES saas_invoices(id),  -- если оплата по счёту
    upd_id          BIGINT REFERENCES saas_upd_documents(id),
    -- Статус
    status          VARCHAR(20) DEFAULT 'pending',  -- pending, success, failed, refunded
    -- Метаданные
    description     TEXT,
    failure_reason  TEXT,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    completed_at    TIMESTAMPTZ
);

CREATE INDEX idx_saas_tx_tenant_created ON saas_transactions(tenant_id, created_at DESC);
CREATE INDEX idx_saas_tx_status ON saas_transactions(status) WHERE status IN ('pending');
CREATE INDEX idx_saas_tx_gateway ON saas_transactions(gateway_code, gateway_payment_id);
CREATE UNIQUE INDEX idx_saas_tx_idempotence ON saas_transactions(gateway_idempotence_key) WHERE gateway_idempotence_key IS NOT NULL;
-- v8.1, CTO-3: для cron payments:cancel-stale
CREATE INDEX idx_saas_tx_pending_stale ON saas_transactions(created_at) WHERE status = 'pending';
```

**Жизненный цикл (v8.1, CTO-3):**

| Из | В | Триггер |
|----|---|---------|
| `[*]` | `pending` | `POST /api/v1/billing/topup` |
| `[*]` | `success` | Bank transfer: админ подтверждает оплату счёта (20.9) |
| `pending` | `success` | Webhook `payment.succeeded` от шлюза |
| `pending` | `success` | Cron `payments:cancel-stale` polling: `gateway.getPaymentStatus() = succeeded` |
| `pending` | `failed` | Webhook `payment.canceled` |
| `pending` | `failed` | Cron 30 минут: `gateway_payment_id IS NULL` (платёж в шлюзе не создался) |
| `pending` | `failed` | Cron 30 минут: polling вернул `canceled` / `failed` |
| `pending` | `failed` | Hard timeout 24 часа независимо от ответа шлюза |
| `success` | `refunded` | `refund_requests(source='admin_initiated')` обработана (см. 20.6) |
| `success` | `refunded` | **Ю-3:** webhook `refund.succeeded` без `refund_requests` (внешний chargeback) — авто-создание `refund_requests(source='external_chargeback')`, см. 20.6.1 |

**`failed → success` запрещён.** Если webhook `payment.succeeded` приходит на транзакцию, уже находящуюся в `failed` — webhook логируется (`failure_reason='late_webhook_ignored'`), статус не меняется, в админке появляется **алерт `finance`** для ручного разбора (либо возврат через шлюз, либо ручное начисление через `balance_transactions(type='manual_adjustment')`, с four-eyes если сумма > порога).

**Cron `payments:cancel-stale` (запускается каждые 5–10 минут):**

Находит `saas_transactions WHERE status = 'pending' AND created_at < NOW() - INTERVAL '30 minutes'`. Для каждой:

1. Если `gateway_payment_id IS NULL` (мы создали запись, но в шлюзе платёж не создался) → `failed`, `failure_reason='gateway_create_timeout'`.
2. Иначе вызываем `driver.getPaymentStatus(gateway_payment_id)`:
   - `succeeded` → `success` (это именно запоздавший webhook от шлюза, нормально, проводим зачисление баланса);
   - `canceled` / `failed` → `failed`, `failure_reason='cron_polling_failed'`;
   - `pending` (всё ещё ждёт оплаты) → пропускаем до следующей итерации;
   - сетевая ошибка / шлюз недоступен → пропускаем до следующей итерации.

**Hard timeout 24 часа:** `status='pending' AND created_at < NOW() - INTERVAL '24 hours'` → `failed`, `failure_reason='hard_timeout_24h'` независимо от ответа шлюза. Это страховка на случай «шлюз постоянно говорит pending» или «шлюз умер».

**Допустимые `failure_reason`** (для фильтров в админке):
`gateway_canceled`, `gateway_declined`, `gateway_create_timeout`, `hard_timeout_24h`, `cron_polling_failed`, `gateway_unknown`, `late_webhook_ignored`.

> Полная state-machine `saas_transactions.status` — в **Приложении Б+В — `Приложение_Б_В_БД_диаграммы_v8_3.md`** (Часть В, раздел 1).

## 20.6. Возвраты

**Только администратор SaaS** может инициировать возврат. Не клиент.

Workflow:

```
Клиент пишет в поддержку: "Хочу возврат, не подошло"
   ↓
Администратор SaaS проверяет: остаток баланса, причину, политику
   ↓
Через админку: refund_requests → создание запроса
   ↓
Подтверждение администратором: одобрить / отклонить
   ↓
Если одобрено:
   - API запрос к шлюзу: gateway.refund(payment_id, amount)
   - Если успех: обновить saas_transactions, уменьшить balance_rub, конвертировать вычитание лидов
   - Создать корректировочный УПД
```

**Частичных возвратов нет** — только полная сумма исходной транзакции.

```sql
CREATE TABLE refund_requests (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id),
    transaction_id  BIGINT NOT NULL REFERENCES saas_transactions(id),
    requested_amount_rub DECIMAL(12,2),
    reason          TEXT,
    -- v8.1, Ю-3: источник возврата
    source          VARCHAR(20) NOT NULL DEFAULT 'admin_initiated'
                    CHECK (source IN ('admin_initiated','external_chargeback')),
    status          VARCHAR(20) DEFAULT 'pending'
                    CHECK (status IN ('pending','approved','rejected','processed')),
    -- admin_user_id NULL для source='external_chargeback' (автосоздание из webhook)
    admin_user_id   BIGINT REFERENCES saas_admin_users(id),
    admin_decision  TEXT,
    gateway_refund_id VARCHAR(255),
    correction_upd_id BIGINT REFERENCES saas_upd_documents(id),
    requested_at    TIMESTAMPTZ DEFAULT NOW(),
    decided_at      TIMESTAMPTZ,
    processed_at    TIMESTAMPTZ,
    -- Целостность: для admin_initiated админ обязателен после approved/rejected
    CONSTRAINT chk_refund_admin_decision CHECK (
        (source = 'external_chargeback')
        OR (status = 'pending')
        OR (admin_user_id IS NOT NULL)
    )
);
```

### 20.6.1. Chargeback и внешние возвраты (v8.1, Ю-3)

**Сценарий:** клиент оплатил тариф картой, через 3 недели идёт в банк и говорит «я этот платёж не делал». Банк инициирует **chargeback** — деньги списываются с нашего расчётного счёта **принудительно**. Платёжный шлюз (ЮKassa) шлёт нам webhook `payment.refund.succeeded`, **но мы возврат не запрашивали**.

**Workflow обработки:**

1. Webhook `refund.succeeded` приходит на `/billing/webhooks/{gateway_code}`. Драйвер ищет `saas_transactions` по `gateway_payment_id`.
2. Проверяется наличие связанного `refund_requests`:
   - **Есть** и `status IN ('pending', 'approved')` → обычный путь (наш инициированный возврат).
   - **Нет** → это chargeback. Создаётся `refund_requests` автоматически с `source='external_chargeback'`, `admin_user_id=NULL`, `status='processed'`, `processed_at=NOW()`.
3. `saas_transactions.status = 'refunded'`.
4. **Защита баланса от ухода в минус:**
   - Если `tenant.balance_rub - refund_amount >= 0` — обычное уменьшение, запись `balance_transactions(type='refund')`.
   - Если `tenant.balance_rub - refund_amount < 0` — баланс выставляется в `0`, лиды в `0`, разница записывается в `tenants.chargeback_unrecovered_rub`. `tenants.status = 'suspended'`. Дополнительная запись `balance_transactions(type='chargeback_writedown')` для бухгалтерского следа.
5. Корректировочный УПД создаётся автоматически (`refund_requests.correction_upd_id`).
6. Алерт в админку для роли `finance`: «Chargeback по тенанту X, сумма Y ₽, не покрыт балансом на Z ₽, тенант приостановлен».
7. Email клиенту (если `chargeback_unrecovered_rub > 0`): «Получен возврат от банка на сумму Y ₽. Аккаунт приостановлен до погашения задолженности Z ₽. Перейдите в раздел "Баланс" для погашения.»

**Workflow погашения долга клиентом:**

В `/billing` для suspended-тенанта с `chargeback_unrecovered_rub > 0` показывается отдельный экран:

> «Ваш аккаунт приостановлен из-за неоплаченного chargeback на сумму Z ₽. Чтобы возобновить работу, погасите задолженность.»
> [Кнопка: Погасить Z ₽]

Платёж через `saas_transactions(type='chargeback_repayment')`. После успеха: `chargeback_unrecovered_rub=0`, запись `balance_transactions(type='chargeback_repayment')`, `tenants.status='active'` (если других причин suspended нет).

**Альтернативное решение `finance`:** если разобрались, что chargeback мошеннический со стороны клиента или техническая ошибка — `finance` через админку списывает долг как убыток через `balance_transactions(type='manual_adjustment')` с обнулением `chargeback_unrecovered_rub` и восстановлением статуса. Требует four-eyes если сумма > порога ДЕФ-4. Запись в `saas_admin_audit_log`.

> Полная state-machine `saas_transactions` с триггерами chargeback и связанная мини-машина `refund_requests` — в **Приложении Б+В — `Приложение_Б_В_БД_диаграммы_v8_3.md`** (Часть В, разделы 1 и 4).

## 20.7. Способы оплаты

| Способ | Кто | Шлюз | Документы |
|---|---|---|---|
| Банковская карта (Visa, MC, МИР) | Физлицо или юрлицо | ЮKassa / Tinkoff / CloudPayments | Чек 54-ФЗ (физлицу) |
| ЮMoney | Физлицо | ЮKassa | Чек 54-ФЗ |
| СБП (Система Быстрых Платежей) | Физлицо | ЮKassa / Tinkoff (СБП) | Чек 54-ФЗ |
| QIWI | Физлицо | QIWI API (умирает, не приоритет) | Чек 54-ФЗ |
| Банковский перевод по р/с | Юрлицо или ИП | — (счёт + ручное подтверждение) | Счёт + УПД |

## 20.8. Сценарий: Оплата картой (физлицо)

```
1. Клиент в /billing/topup → выбирает сумму 5000 ₽ → "Карта"
2. POST /api/v1/billing/topup
   Body: { amount_rub: 5000, method: 'card' }
3. Backend:
   a. Создаёт saas_transactions (status=pending)
   b. Выбирает gateway (gateway = active && accepts_methods includes 'card', sort by sort_order)
   c. Создаёт платёж в шлюзе через драйвер: yookassa->createPayment(...)
   d. Получает payment_url
   e. Сохраняет gateway_payment_id в saas_transactions
   f. Возвращает { redirect_url: payment_url }
4. Браузер редиректит на ЮKassa
5. Клиент вводит карту, проходит 3DS, оплачивает
6. ЮKassa → POST /billing/webhooks/yookassa
7. Backend проверяет подпись, обновляет saas_transactions.status=success
8. Транзакционно:
   a. balance_rub += 5000
   b. balance_leads += 100 (по тарифу)
   c. balance_transactions: запись (type=topup, +5000 ₽, +100 лидов)
9. Генерируется чек 54-ФЗ через ЮKassa (отправляется на email клиента автоматически)
10. Email клиенту: "Баланс пополнен на 5000 ₽ (+100 лидов)"
```

## 20.9. Сценарий: Банковский перевод (юрлицо)

```
1. Клиент в /billing/topup → выбирает сумму → "Банковский перевод"
2. Заполняет форму:
   - Юр. лицо: "ООО Контрагент", ИНН, КПП, адрес
   - Сумма: 50000 ₽
3. POST /api/v1/billing/invoices
4. Backend:
   a. Выбирает default legal_entity_id (или клиент выбирает из списка)
   b. Берёт следующий номер счёта из document_sequences (FOR UPDATE)
   c. Считает НДС по vat_mode юр. лица
   d. Создаёт saas_invoices запись
   e. Генерирует PDF через DomPDF (с печатью и подписью)
   f. Сохраняет PDF в S3
   g. Отправляет email клиенту с приложенным PDF
5. Клиент идёт в свой банк-клиент, оплачивает
6. Деньги поступают на расчётный счёт юр. лица
7. Администратор SaaS заходит в админку, видит "Ожидает зачисления", нажимает "Подтвердить":
   a. Обновляет saas_invoices.status = paid
   b. Создаёт saas_transactions (type=topup, payment_method=bank_transfer)
   c. balance_rub += 50000, balance_leads += 1000
   d. Автоматически генерируется УПД
   e. PDF УПД отправляется на email клиенту
8. (Опционально, на v2) Автоматическое подтверждение через банковский API (Тинькофф API, Райффайзен API)
```

## 20.10. Счета и УПД

### 20.10.1. saas_invoices

```sql
CREATE TABLE saas_invoices (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    legal_entity_id BIGINT NOT NULL REFERENCES legal_entities(id),
    invoice_number  VARCHAR(50) NOT NULL,            -- СЧ-2026-00123
    -- Плательщик
    payer_type      VARCHAR(20) NOT NULL,            -- 'legal' / 'individual'
    payer_name      VARCHAR(500),
    payer_inn       VARCHAR(12),
    payer_kpp       VARCHAR(9),
    payer_address   TEXT,
    payer_email     VARCHAR(255),
    -- Финансы
    amount_net      DECIMAL(12,2) NOT NULL,         -- без НДС
    vat_rate        DECIMAL(5,2) DEFAULT 0,
    vat_amount      DECIMAL(12,2) DEFAULT 0,
    amount_total    DECIMAL(12,2) NOT NULL,
    payment_purpose TEXT,
    -- Связи
    transaction_id  BIGINT REFERENCES saas_transactions(id),
    -- Файлы
    pdf_path        VARCHAR(500),
    -- Статус
    status          VARCHAR(20) DEFAULT 'issued',   -- draft, issued, paid, overdue, cancelled
    issued_at       TIMESTAMPTZ DEFAULT NOW(),
    expires_at      TIMESTAMPTZ NOT NULL,            -- по умолчанию +5 рабочих дней
    paid_at         TIMESTAMPTZ,
    cancelled_at    TIMESTAMPTZ,

    UNIQUE (legal_entity_id, invoice_number)
);

CREATE TABLE saas_invoice_items (
    id              BIGSERIAL PRIMARY KEY,
    invoice_id      BIGINT NOT NULL REFERENCES saas_invoices(id) ON DELETE CASCADE,
    name            VARCHAR(500) NOT NULL,
    okpd2           VARCHAR(20),                     -- код ОКПД2
    quantity        DECIMAL(10,3) DEFAULT 1,
    unit            VARCHAR(50) DEFAULT 'усл.',
    price           DECIMAL(12,2) NOT NULL,
    amount_net      DECIMAL(12,2) NOT NULL,
    vat_rate        DECIMAL(5,2),
    vat_amount      DECIMAL(12,2),
    amount_total    DECIMAL(12,2) NOT NULL
);
```

### 20.10.2. saas_upd_documents

```sql
CREATE TABLE saas_upd_documents (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id),
    legal_entity_id BIGINT NOT NULL REFERENCES legal_entities(id),
    upd_number      VARCHAR(50) NOT NULL,
    upd_function    VARCHAR(10) DEFAULT 'СЧФ',      -- СЧФ или ДОП
    correction_for  BIGINT REFERENCES saas_upd_documents(id),  -- если корректировочный
    -- Покупатель
    buyer_type      VARCHAR(20) NOT NULL,
    buyer_name      VARCHAR(500),
    buyer_inn       VARCHAR(12),
    buyer_kpp       VARCHAR(9),
    buyer_address   TEXT,
    -- Финансы
    amount_net      DECIMAL(12,2) NOT NULL,
    vat_rate        DECIMAL(5,2),
    vat_amount      DECIMAL(12,2),
    amount_total    DECIMAL(12,2) NOT NULL,
    -- Связи
    invoice_id      BIGINT REFERENCES saas_invoices(id),
    transaction_id  BIGINT REFERENCES saas_transactions(id),
    -- Файлы
    pdf_path        VARCHAR(500),
    -- Статус
    status          VARCHAR(20) DEFAULT 'issued',   -- draft, issued, signed
    issued_at       TIMESTAMPTZ DEFAULT NOW(),

    UNIQUE (legal_entity_id, upd_number)
);
```

### 20.10.3. Расчёт НДС

```php
public function calculateVat(int $amountKopecks, string $vatMode): array
{
    $rates = [
        'vat20'  => 20,
        'vat10'  => 10,
        'vat7'   => 7,    // УСН с 2025
        'vat5'   => 5,    // УСН с 2025
        'vat0'   => 0,
        'no_vat' => null, // "Без НДС"
        'usn_6'  => null, // УСН 6% — НДС не выделяется
        'usn_15' => null, // УСН 15% — НДС не выделяется
    ];

    $rate = $rates[$vatMode] ?? null;

    if ($rate === null) {
        return [
            'rate'         => null,
            'amount_net'   => $amountKopecks,
            'vat_amount'   => 0,
            'amount_total' => $amountKopecks,
            'description'  => 'Без НДС',
        ];
    }

    // НДС "сверху": amount_net + (amount_net * rate / 100)
    $vatAmount = (int) round($amountKopecks * $rate / 100);
    return [
        'rate'         => $rate,
        'amount_net'   => $amountKopecks,
        'vat_amount'   => $vatAmount,
        'amount_total' => $amountKopecks + $vatAmount,
    ];
}
```

### 20.10.4. Нумерация

Через таблицу `document_sequences` с блокировкой `FOR UPDATE` — гарантирует непрерывность нумерации (важно для налоговой):

```php
public function getNextNumber(int $legalEntityId, string $documentType): string
{
    $year = (int) date('Y');

    return DB::transaction(function () use ($legalEntityId, $documentType, $year) {
        $sequence = DocumentSequence::lockForUpdate()->firstOrCreate(
            [
                'legal_entity_id' => $legalEntityId,
                'document_type'   => $documentType,
                'year'            => $year,
            ],
            ['last_number' => 0]
        );

        $sequence->increment('last_number');

        $prefix = ['invoice' => 'СЧ', 'upd' => 'УПД', 'upd_correction' => 'УПД-К'][$documentType];

        return sprintf('%s-%d-%05d', $prefix, $year, $sequence->last_number);
    });
}
```

### 20.10.5. Генерация PDF

Библиотека: **DomPDF** (Laravel package: `barryvdh/laravel-dompdf`).

Шаблон Blade: `resources/views/billing/invoice.blade.php` — с реквизитами юр. лица, печатью и подписью (PNG-картинка с прозрачным фоном).

УПД — `resources/views/billing/upd.blade.php` — соответствует форме, утверждённой Приказом ФНС от 19.12.2018 № ММВ-7-15/820@.

PDF генерируется в очереди (тяжёлая операция), сохраняется в S3 (privatе bucket), доступ через signed URL с TTL 1 час.

## 20.11. Endpoints SaaS-биллинга

| Метод | URL | Описание |
|---|---|---|
| GET | `/api/v1/billing/tariffs` | Список доступных тарифов |
| POST | `/api/v1/billing/subscribe` | Сменить тариф |
| GET | `/api/v1/billing/balance` | Текущий баланс (rub + leads) |
| GET | `/api/v1/billing/transactions` | История транзакций |
| POST | `/api/v1/billing/topup` | Создать пополнение → возврат payment_url |
| POST | `/api/v1/billing/invoice` | Создать счёт на банк. перевод (для юрлица) |
| GET | `/api/v1/billing/invoices` | Список счетов |
| GET | `/api/v1/billing/invoices/{id}/pdf` | PDF счёта (signed URL) |
| GET | `/api/v1/billing/upd-documents` | Список УПД |
| GET | `/api/v1/billing/upd-documents/{id}/pdf` | PDF УПД |
| POST | `/billing/webhooks/{gateway_code}` | Callback от шлюза (без аутентификации, по подписи) |

## 20.12. Себестоимость лидов (v8.1, Ю-2)

После закрепления реселлерской модели (раздел 1.5) — отдельный учёт того, **что мы платим crm.bp-gr.ru** за каждый принятый лид.

### 20.12.1. Концепция

На MVP — **единая закупочная цена** для всех лидов, хранится в `system_settings.supplier_default_cost_rub`. На каждый принятый лид записывается snapshot цены в `supplier_lead_costs` (партиционированная таблица синхронно с `deals`). Маржа = тариф клиента − закупка.

Эволюция к v2: разные цены по проектам/категориям, цена в payload webhook от crm.bp-gr.ru. Архитектура подготовлена (поле `supplier_lead_id` в DDL).

### 20.12.2. Таблицы

```sql
-- Себестоимость каждого лида
CREATE TABLE supplier_lead_costs (
    id                  BIGSERIAL,
    deal_id             BIGINT NOT NULL,                   -- БЕЗ FK (deals партиционирована)
    received_at         TIMESTAMPTZ NOT NULL,              -- ключ партиционирования (= deals.received_at)
    supplier_code       VARCHAR(50) NOT NULL DEFAULT 'crm_bp_gr',
    cost_rub            DECIMAL(10,2) NOT NULL,            -- snapshot system_settings.supplier_default_cost_rub
    supplier_lead_id    BIGINT,                            -- vid из webhook crm.bp-gr.ru
    supplier_invoice_id BIGINT,                            -- проставляется при сверке счёта
    created_at          TIMESTAMPTZ DEFAULT NOW(),
    PRIMARY KEY (id, received_at)
) PARTITION BY RANGE (received_at);

-- Партиции синхронно с deals (создаются cron на 2 месяца вперёд)
CREATE TABLE supplier_lead_costs_2026_05 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
-- ... etc

-- Счета от crm.bp-gr.ru к нам (наш расход)
CREATE TABLE supplier_invoices (
    id                  BIGSERIAL PRIMARY KEY,
    supplier_code       VARCHAR(50) NOT NULL DEFAULT 'crm_bp_gr',
    invoice_number      VARCHAR(100),
    period_from         DATE NOT NULL,
    period_to           DATE NOT NULL,
    leads_count         INT,                                -- по счёту от поставщика
    leads_count_actual  INT,                                -- по нашим данным
    amount_rub          DECIMAL(12,2),
    amount_rub_actual   DECIMAL(12,2),
    discrepancy_rub     DECIMAL(12,2)
                        GENERATED ALWAYS AS (COALESCE(amount_rub,0) - COALESCE(amount_rub_actual,0)) STORED,
    pdf_path            VARCHAR(500),
    paid_at             TIMESTAMPTZ,
    paid_by             BIGINT REFERENCES saas_admin_users(id),
    notes               TEXT,
    status              VARCHAR(20) DEFAULT 'received'
                        CHECK (status IN ('received','reconciled','paid','disputed')),
    reconciled_by       BIGINT REFERENCES saas_admin_users(id),
    reconciled_at       TIMESTAMPTZ,
    created_at          TIMESTAMPTZ DEFAULT NOW(),
    updated_at          TIMESTAMPTZ,
    CONSTRAINT chk_supplier_invoices_period CHECK (period_to >= period_from)
);
```

### 20.12.3. Связь с биллингом тенанта

В `ProcessWebhookJob` (раздел 5.5) для нового лида в одной транзакции (v8.6, CTO-17):

```
1. INSERT INTO webhook_dedup_keys ... ON CONFLICT DO UPDATE RETURNING is_new
2. Если is_new — INSERT INTO deals; иначе UPDATE deals → выход (списания нет)
3. INSERT INTO supplier_lead_costs (cost_rub = system_settings.supplier_default_cost_rub)  ← наш расход
4. INSERT INTO balance_transactions (type='lead_charge', amount_rub = тариф клиента)        ← наша выручка
5. UPDATE tenants SET balance_leads = balance_leads - 1
```

Маржа на лид = `balance_transactions.amount_rub - supplier_lead_costs.cost_rub`. Считается в реальном времени, агрегаты в админке (раздел 24, §4.9).

### 20.12.4. Доступ и UI

Обе таблицы доступны **только под ролью БД `crm_admin_user` (BYPASSRLS)**. Тенанты их не видят. Управление — через админку SaaS:

- **Дашборд маржи**: общая выручка / себестоимость / валовая прибыль за период, разбивка по тенантам и тарифам.
- **Реестр закупок** (`supplier_lead_costs`): фильтры, экспорт.
- **Реестр счетов от поставщика** (`supplier_invoices`): форма ввода, кнопка «Сверить» (автоматически считает `*_actual`), переходы статусов `received → reconciled → paid` или `disputed`.
- **Алерты `finance`**: отрицательная маржа на тенанте, расхождение в счёте > порога, новый счёт без обработки > 5 рабочих дней.

> Подробная спецификация админского раздела — в **Приложении Г — `Админка_SaaS_v8_1_драфт.md`**, §4.9.

### 20.12.5. Изменение закупочной цены

Изменяется через стандартный экран `system_settings` в админке, но с дополнительными требованиями:

- Поле «Дата вступления в силу» (по умолчанию = `NOW()`, можно отложить).
- Обязательное поле «Причина изменения» («новые условия договора с crm.bp-gr.ru с 1 июня»).
- Запись в `saas_admin_audit_log`.
- **Историческая себестоимость не пересчитывается** — только новые лиды получают новую цену. Старые `supplier_lead_costs` хранят snapshot цены на момент получения.

---

# 21. Лид-биллинг (уровень 2: клиент → его баланс ГЦК)

## 21.1. Концепция

Параллельно с `balance_rub` тенант имеет `balance_leads` — счётчик лидов. Каждый принятый Webhook — `-1`.

Лиды появляются:

1. **Стартовый бонус** при регистрации (50 лидов из `system_settings.trial_bonus_leads`)
2. **Конвертация при пополнении balance_rub** (по цене тарифа)
3. **Ручное начисление** администратором SaaS (тип `manual_credit`)
4. **Списание не происходит** при дублях (двустадийная dedup-логика через `webhook_dedup_keys` по `vid`, v8.6 §5.5) и при ручном создании сделок

## 21.2. Списание баланса (атомарность)

Реализуется в `ProcessWebhookJob`:

```php
DB::transaction(function () {
    $tenant = Tenant::lockForUpdate()->find($this->tenantId);

    if ($tenant->balance_leads <= 0) {
        $this->logRejection($tenant);
        return;
    }

    // ... создание сделки

    if ($deal->wasRecentlyCreated) {
        $tenant->decrement('balance_leads');
        BalanceTransaction::create([
            'tenant_id'      => $tenant->id,
            'type'           => 'lead_charge',
            'amount_leads'   => -1,
            'balance_leads_after' => $tenant->balance_leads - 1,
            'related_type'   => Deal::class,
            'related_id'     => $deal->id,
        ]);
    }
});
```

Альтернатива (без явного `lockForUpdate`):

```sql
UPDATE tenants
   SET balance_leads = balance_leads - 1
 WHERE id = ?
   AND balance_leads > 0
RETURNING balance_leads;
```

Если возвращено 0 строк — баланс был 0, лид отвергаем.

## 21.3. Низкий баланс — уведомления

Cron `tenants:notify-low-balance` (раз в час):

```sql
SELECT id, contact_email, balance_leads
FROM tenants
WHERE balance_leads > 0
  AND balance_leads <= ((SELECT value::int FROM system_settings WHERE key = 'low_balance_threshold_leads'))
  AND (low_balance_notified_at IS NULL OR low_balance_notified_at < NOW() - INTERVAL '7 days');
```

Каждому такому тенанту шлём email и ставим `low_balance_notified_at = NOW()` (чтобы не спамить).

При пополнении баланса флаг сбрасывается:

```sql
UPDATE tenants SET low_balance_notified_at = NULL WHERE id = ?;
```

## 21.4. Нулевой баланс

При попытке принять Webhook с `balance_leads = 0`:

1. Запись в `rejected_deals_log` (со всеми данными для возможного восстановления)
2. Если в течение последнего часа уже не было такого email — отправить тенанту: "Ваш баланс лидов исчерпан. N лидов отвергнуто за последний час. Пополните баланс."
3. **Платформа продолжает работать** — клиент может зайти, видеть существующие сделки, аналитику, выставлять счета. Просто новые лиды не принимаются.

## 21.5. Восстановление отвергнутых лидов (опционально, v2)

В админке тенанта появляется кнопка «Восстановить отвергнутые лиды»:

- Показывает количество в `rejected_deals_log` за период
- Спрашивает: «Восстановить N лидов? Будет списано N лидов с текущего баланса»
- При подтверждении создаются сделки из лога, баланс уменьшается
- Записи помечаются `restored_at = NOW()` (чтобы не восстанавливать повторно)

В MVP не реализуется.

## 21.6. История транзакций баланса

URL: `/billing/balance` (для клиента)

Таблица:

| Колонка | Описание |
|---|---|
| Дата | Дата и время |
| Тип | Пополнение / Списание / Стартовый бонус / Корректировка / Возврат |
| Лиды | +N или -N |
| Рубли | +N ₽ или -N ₽ (для финансовых операций) |
| Описание | "Пополнение картой Visa **6411", "Списание за лид #12345", "Стартовый бонус" |
| Статус | Успешно / В обработке / Ошибка |
| Документ | Ссылка на счёт/УПД (если связан) |

Фильтры: тип, период, способ оплаты.

---

# 22. Безопасность

## 22.1. Принципы

1. **Defense in depth** — несколько слоёв защиты, отказ одного не приводит к компрометации
2. **Least privilege** — минимально необходимые права у всех компонентов (БД-юзер, S3-роли, API-ключи)
3. **Secure by default** — все опасные настройки выключены до явного включения
4. **Audit trail** — все чувствительные действия логируются
5. **Privacy by design** — ПДн собираются минимально, шифруются, удаляются по запросу

## 22.2. Транспортный уровень

| Параметр | Значение |
|---|---|
| HTTPS | Только. HTTP-запросы редиректятся 301 на HTTPS |
| Минимальная версия TLS | TLS 1.2 (TLS 1.3 предпочтительно) |
| Strict-Transport-Security | `max-age=31536000; includeSubDomains; preload` |
| Content-Security-Policy | `default-src 'self'; script-src 'self' https://cdn.jsdelivr.net https://*.jivosite.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://*.sentry.io; frame-ancestors 'none'; base-uri 'self'; form-action 'self'` (полная политика — см. [§22.11](#2211-защита-от-prompt-injection-в-dom)) |
| X-Frame-Options | `DENY` (защита от clickjacking) |
| X-Content-Type-Options | `nosniff` |
| Referrer-Policy | `strict-origin-when-cross-origin` |
| Permissions-Policy | `camera=(), microphone=(), geolocation=()` |
| Сертификат | Let's Encrypt (auto-renewal) или коммерческий wildcard `*.liderra.ru` |

## 22.3. Rate limiting

| Эндпоинт | Лимит | Реализация |
|---|---|---|
| `/login` | 5 попыток / 15 мин на IP+email | Laravel `ThrottleRequests` + Redis |
| `/register` | 3 попытки / час на IP | Laravel throttle |
| `/webhook/{token}` | 100 RPS на токен (настраивается) | Redis sliding window |
| `/api/v1/*` | 60 запросов/мин на API-ключ | Laravel Sanctum + throttle |
| `/billing/topup` | 10 / час на тенант | Laravel throttle |
| Все остальные | 200 RPS на IP (Nginx уровень) | `limit_req_zone` |

При превышении: HTTP 429 + заголовок `Retry-After`. Логирование в `auth_log` для атак на login.

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

### 22.4.1. Пароль

- Минимум 10 символов
- Проверка через `bjeavons/zxcvbn-php` — score ≥ 3 (из 4)
- Bcrypt с cost=12 (Laravel default = 10, повышаем для безопасности; ~250 мс на проверку)
- Отказ от пароля если он в списке утечек (HaveIBeenPwned-стиль) — опционально, через локальную копию топ-1М утёкших

### 22.4.2. 2FA (TOTP)

- Алгоритм TOTP RFC 6238, период 30 сек, длина 6 цифр
- Window = 1 (принимаем коды ±30 сек от текущего)
- Recovery codes — 8 штук, каждый одноразовый, bcrypt-хеш
- При успешном использовании recovery code — обязательно сообщаем пользователю по email

### 22.4.3. Сессии

- Sanctum cookies (для SPA): `HttpOnly`, `Secure`, `SameSite=Lax`
- Sanctum tokens (для API): хранятся в БД хешированные
- TTL сессии: 7 дней по умолчанию, продлевается при активности
- "Запомнить меня": 30 дней
- При смене пароля — все остальные сессии инвалидируются (опция: с чекбоксом, default включён)

### 22.4.4. Анти-брутфорс

- 5 неудачных попыток входа на email → блокировка попыток на 15 минут
- 10 неудачных попыток с одного IP за час → блокировка IP на 1 час (через `auth_log`)
- Email-уведомление пользователю при превышении 3 неудачных попыток

## 22.5. Шифрование

### 22.5.1. Шифрование на лету (in-transit)

Все каналы — HTTPS. БД-соединения внутри VPC — TLS не обязателен (приватная сеть), но при внешнем подключении — обязательно `sslmode=require`.

### 22.5.2. Шифрование на хранении (at-rest)

| Данные | Метод |
|---|---|
| Файлы PostgreSQL на дисках | LUKS (full-disk encryption) на уровне ОС / EBS-encryption у облачного провайдера |
| Бэкапы в S3 | SSE-S3 или SSE-KMS |
| Чувствительные поля в БД | Laravel `Crypt::encryptString()` (AES-256-CBC + HMAC) |

Чувствительные поля (применяется `Crypt::encryptString` через Eloquent cast `encrypted`):

- `users.totp_secret`
- `payment_gateways.config` (содержит API-ключи шлюзов)
- `system_settings.value` для ключей `vapid_private_key`, `smtp_password` и подобных

Реализация:

```php
class User extends Authenticatable
{
    protected $casts = [
        'totp_secret' => 'encrypted',
    ];
}

class PaymentGateway extends Model
{
    protected $casts = [
        'config' => 'encrypted:json',
    ];
}
```

Ключ шифрования (`APP_KEY`) хранится в .env + secrets management (HashiCorp Vault / AWS Secrets Manager). При компрометации — процедура ротации с пересчётом всех зашифрованных полей.

### 22.5.3. Хеширование

- Пароли — bcrypt cost=12
- API-ключи — bcrypt
- Recovery codes — bcrypt
- Email-токены подтверждения — SHA-256 (короткие, одноразовые, в коротком окне TTL — bcrypt избыточен)

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

См. раздел 3.2 (4 уровня защиты, **уровень 4 RLS включён на MVP** — CTO-5).

**Дополнительные практики (v8.1):**

- **Линтер на CI** — автопроверка, что каждая Eloquent-модель с `tenant_id` имеет `static::addGlobalScope(new TenantScope)` в `boot()`. Если модель без скоупа — сборка падает. Закрывает классический риск «забытый global scope».
- **Code review checklist** для PR с миграцией БД — обязательный пункт «новая таблица с tenant_id → проверена ли модель на TenantScope → создана ли RLS-политика».
- **Тестовая утилита `assertTenantIsolation($endpoint)`** — генерирует двух тенантов с данными, делает запрос от первого, проверяет что данные второго не видны. Применяется в каждом feature-тесте.

**Обязательный набор тестов** — feature-tests, проверяющие что пользователь тенанта A не может прочесть/изменить/удалить данные тенанта B по каждому защищённому эндпоинту:

```php
test('user from tenant A cannot view deal from tenant B', function () {
    $tenantA = Tenant::factory()->create();
    $tenantB = Tenant::factory()->create();
    $userA = User::factory()->create(['tenant_id' => $tenantA->id]);
    $dealB = Deal::factory()->create(['tenant_id' => $tenantB->id]);

    $response = $this->actingAs($userA)->get("/api/v1/deals/{$dealB->id}");

    $response->assertNotFound(); // 404, не 403 — не раскрываем существование
});

// v8.1: дополнительный тест — даже сырой SQL без WHERE не выдаёт чужие строки
test('raw SQL respects RLS even without WHERE tenant_id', function () {
    $tenantA = Tenant::factory()->create();
    $tenantB = Tenant::factory()->create();
    Deal::factory()->count(3)->create(['tenant_id' => $tenantB->id]);

    DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenantA->id]);
    $count = DB::select('SELECT COUNT(*) as cnt FROM deals')[0]->cnt;

    expect($count)->toBe(0); // RLS отбросил все 3 записи tenant B
});
```

Этот тест выполняется для каждого ресурса: deals, projects, tags, templates, reminders, transactions, etc.

## 22.7. Защита от типовых атак

| Атака | Защита |
|---|---|
| SQL injection | Eloquent ORM + prepared statements; запрет `DB::raw($userInput)` без экранирования |
| XSS | Vue экранирует по умолчанию; для `v-html` — sanitize через DOMPurify |
| CSRF | Laravel CSRF-token в meta-теге, проверка для всех state-changing запросов |
| Mass assignment | `$fillable` / `$guarded` на всех моделях |
| Privilege escalation | Authorization через Policies, проверка на каждом эндпоинте |
| Open redirect | Валидация redirect URL по white-list |
| File upload (RCE) | Whitelist MIME-типов и расширений, ребилд изображений через Intervention Image, хранение в S3 (не в публичной папке) |
| SSRF | Запрет fetch с `localhost`, приватных IP-диапазонов, file://, нестандартных портов |
| Timing attacks | `hash_equals()` для сравнения секретов вместо `===` |
| Тех. ошибки в production | `APP_DEBUG=false`, перехват всех исключений, общая ошибка пользователю + Sentry для разработчиков |

## 22.8. Аудит

```sql
CREATE TABLE auth_log (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT REFERENCES tenants(id),
    user_id         BIGINT REFERENCES users(id),
    email           VARCHAR(255),                    -- если user не найден
    event           VARCHAR(50) NOT NULL,            -- login_success, login_failed, password_reset, ...
    ip_address      INET,
    user_agent      TEXT,
    failure_reason  VARCHAR(100),                    -- 'invalid_password', 'invalid_2fa', ...
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_auth_log_tenant_user ON auth_log(tenant_id, user_id, created_at DESC);
CREATE INDEX idx_auth_log_ip_failed   ON auth_log(ip_address, created_at DESC) WHERE event = 'login_failed';
CREATE INDEX idx_auth_log_email       ON auth_log(email, created_at DESC);
```

Хранится 1 год, архивируется в S3.

**Что логируется:**

- Все попытки входа (успешные и нет)
- Сброс пароля
- Включение/выключение 2FA
- Создание/отзыв API-ключей
- Изменение email или пароля
- Удаление аккаунта
- Подозрительные события (вход с нового IP, несколько неудачных попыток)

## 22.9. 152-ФЗ (защита персональных данных)

> **Архитектурная позиция (v8.1, Ю-2):** мы — **самостоятельный оператор ПДн**, не обработчик по поручению. crm.bp-gr.ru передаёт нам данные на основании договора. Цепочка передачи: физлицо → crm.bp-gr.ru → мы → тенант — каждый звено самостоятельный оператор. Источник заявок (где именно физлицо оставляет контакты) не контролируется нами.
>
> **Следствия:**
>
> - Уведомление в Роскомнадзор подаём как «оператор», не «обработчик».
> - В нашей оферте с тенантом — **зеркальные гарантии**: тенант принимает данные от нас как от оператора, дальше тенант — самостоятельный оператор по обработке тех же данных в своём CRM-процессе. Тенант гарантирует, что лиды собраны crm.bp-gr.ru с надлежащим согласием физлиц (Ю-8).
> - В договоре с crm.bp-gr.ru — зеркально: они гарантируют согласие физлиц.
> - Физлицо-лид имеет право обратиться **напрямую к нам** с запросом «удалите мои данные» / «покажите, что у вас обо мне есть» (см. 22.9.5).

### 22.9.1. Согласия

При регистрации тенанта — два согласия (плюс новое в v8.1):

- ☑ Согласие на обработку **своих** персональных данных (152-ФЗ) — `consent_type='pd_processing_as_subject'`
- ☑ Согласие с офертой и Политикой конфиденциальности — `consent_type='oferta_v1'`
- ☑ **(v8.1)** Подтверждение, что лиды от crm.bp-gr.ru переданы с надлежащим согласием физлиц, и тенант обязуется обрабатывать их в рамках 152-ФЗ — `consent_type='pd_processing_as_data_provider'`

```sql
CREATE TABLE tenant_consents (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    user_id         BIGINT NOT NULL REFERENCES users(id),
    consent_type    VARCHAR(50) NOT NULL,            -- pd_processing_as_subject, pd_processing_as_data_provider, marketing, oferta_v1, ...
    document_version VARCHAR(20),                    -- "v2.1" — версия документа на момент согласия
    given_at        TIMESTAMPTZ DEFAULT NOW(),
    ip_address      INET,
    user_agent      TEXT,
    revoked_at      TIMESTAMPTZ
);

CREATE INDEX idx_consents_tenant ON tenant_consents(tenant_id, consent_type);
```

При обновлении документа (новая версия Политики) — пользователю при следующем логине показывается окно с принятием новой версии.

### 22.9.2. Удаление по запросу (тенант → свои данные)

Пользователь имеет право на удаление своих данных (право на забвение, ст. 14 ФЗ-152).

Workflow:

1. Пользователь в `/account` → «Удалить аккаунт»
2. Подтверждение через email-ссылку
3. Soft delete: `tenants.deleted_at = NOW()`, `tenants.status = 'deleted'`
4. Через 30 дней автоматическое полное удаление (cron `tenants:purge-deleted`):
   - Анонимизация в `users`: ФИО → "Удалённый", email → "deleted_{id}@deleted.local", phone → null
   - Анонимизация в `deals`: contact_name → null, comment → null, phones → null (телефон в `deals.phone` остаётся как идентификатор лида, но без привязки к личности)
   - Удаление `webhook_log.raw_payload` (содержит телефоны)
   - Удаление файлов: аватары, экспорты
   - Удаление платёжных данных (только метаданные транзакций для бух. отчётности — хранятся в обезличенном виде)

### 22.9.3. Журнал обработки ПДн

Согласно ФЗ-152 ст. 22 — оператор ПДн ведёт реестр действий с данными:

```sql
CREATE TABLE pd_processing_log (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT REFERENCES tenants(id),
    subject_type    VARCHAR(50),                    -- 'user', 'lead'
    subject_id      BIGINT,
    action          VARCHAR(50),                    -- 'created', 'viewed', 'updated', 'deleted', 'exported'
    purpose         VARCHAR(255),                   -- 'lead_processing', 'analytics', 'export_request', 'support_impersonation' (v8.1)
    -- v8.1: actor может быть либо tenant_user, либо saas_admin (XOR через CHECK)
    actor_tenant_user_id BIGINT REFERENCES users(id),
    actor_admin_user_id  BIGINT REFERENCES saas_admin_users(id),
    ip_address      INET,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);
```

В реальности это совмещено с `activity_log` для большинства действий, но крупные действия (массовый экспорт, удаление, **импersonation**) попадают сюда отдельно.

### 22.9.4. Локация хранения данных

ПДн граждан РФ должны храниться на серверах в РФ (ч. 5 ст. 18 ФЗ-152). Серверная инфраструктура — в РФ-датацентрах:

- **Yandex Cloud, ru-central1 (Москва)** — выбран по DO-1 (см. Прил. К)
- VK Cloud (Санкт-Петербург) — альтернатива из Прил. К, не выбрана
- Selectel (Москва, СПб) — альтернатива из Прил. К, не выбрана

S3-бакеты бэкапов — также в РФ.

Уведомление в Роскомнадзор о деятельности оператора ПДн — обязанность заказчика (Ю-4 в Приложении Е), до начала работы платформы. **Шаблон уведомления** по форме Приказа РКН №180 от 28.10.2022 с матрицей 7 целей обработки и формулировками под нашу архитектуру (категории субъектов, цели обработки, правовые основания) — **Приложение З — `Uvedomlenie_RKN_v8_1.md`**.

### 22.9.5. Обращения субъектов ПДн (новый в v8.1)

Поскольку мы — самостоятельный оператор, физлицо-лид имеет право обратиться напрямую к нам с запросом данных или удалением. Workflow обработки:

```sql
CREATE TABLE pd_subject_requests (
    id                  BIGSERIAL PRIMARY KEY,
    received_at         TIMESTAMPTZ DEFAULT NOW(),
    subject_email       VARCHAR(255),                   -- email/контакт заявителя
    subject_phone       VARCHAR(50),
    request_type        VARCHAR(50),                    -- 'access', 'deletion', 'correction', 'restriction'
    status              VARCHAR(20) DEFAULT 'received', -- received, identity_verification, identity_verified, processing, completed, rejected
    tenant_id           BIGINT REFERENCES tenants(id),  -- найденный тенант (если данные у нас есть)
    assigned_admin_id   BIGINT REFERENCES saas_admin_users(id),
    deadline_at         TIMESTAMPTZ,                    -- дедлайн ответа по 152-ФЗ (30 дней)
    -- ... детали — см. schema.sql v8.1
    completed_at        TIMESTAMPTZ
);
```

**Особый шаг (v8.1):** при удалении данных физлица-лида у нас — мы должны **уведомить вторичных операторов** (crm.bp-gr.ru как первоисточник, тенанта получившего этого лида), чтобы они тоже удалили. Это требование 152-ФЗ для оператора, передающего данные другим операторам.

> **Полный workflow** с шаблонами писем (7 шт.), сроками внутри 30-дневного дедлайна, процедурой проверки личности заявителя, фазой уведомления вторичных операторов (crm.bp-gr.ru вверх по цепочке + тенанты-получатели вниз) — **Приложение Д — `Workflow_pd_subject_requests_v8_1.md`**.

### 22.9.6. Доступ оператора SaaS к данным тенанта (impersonation, Ю-1)

Поддержка может зайти в систему «от имени» тенанта для воспроизведения проблемы. Поскольку клиент — оператор ПДн лидов, это требует специальной защиты.

**Workflow «Войти как клиент» (v8.1, Ю-1):**

1. Админ роли `support`/`finance`/`super_admin` в админке нажимает «Войти как…», указывает основание (свободный текст, мин. 30 символов).
2. На `tenant.contact_email` уходит письмо с одноразовым 6-значным кодом, действительным 15 минут.
3. Клиент передаёт код админу. После 5 неверных попыток — токен инвалидируется.
4. Создаётся специальная сессия (`saas_admin_sessions.impersonating_tenant_id`), открывается клиентское приложение с принудительным красным баннером.
5. Сессия живёт 1 час. Запрещены write-действия: смена пароля, email, удаление аккаунта, exports, 2FA, изменение `tenant.contact_email`.
6. Каждое действие логируется в `saas_admin_audit_log` и `pd_processing_log` (`purpose='support_impersonation'`).
7. По завершении — email клиенту со списком действий админа.

**Юридическое обоснование:** в оферте — формулировка «Тенант соглашается, что в случае его обращения в поддержку оператор SaaS может запросить временный технический доступ к его аккаунту. Доступ предоставляется только по разовому коду, отправляемому на email тенанта, и только после явного подтверждения тенантом передачей этого кода. Все действия оператора в режиме доступа фиксируются в журнале и доступны тенанту по запросу.»

> Полная спецификация — в **Приложении Г — `Админка_SaaS_v8_1_драфт.md`**, §4.2.1.

## 22.10. Безопасность платежей

| Стандарт | Применимость |
|---|---|
| **PCI DSS** | Не применяется напрямую — мы не храним и не обрабатываем номера карт. Все платежи через шлюзы (ЮKassa, Tinkoff и т.д.), которые сами PCI DSS Level 1 |
| **54-ФЗ** | Чеки генерируются на стороне шлюза (ЮKassa, CloudPayments) автоматически. Нам нужно только передать товарную позицию и email клиента |
| **HMAC-подпись webhook от шлюзов** | Обязательная проверка для всех callback-ов |
| **Idempotence keys** | UUID при создании платежа, передаётся в шлюз. При повторном запросе шлюз вернёт тот же платёж — защита от двойного списания |

## 22.11. Защита от prompt injection в DOM

**Контекст.** В аудите оригинала crm.bp-gr.ru (партии 10, 12, 13, 14, 15 — см. [Прил. М §6](Analiz_originala_v8_3.md)) во всех страницах сервиса обнаружена **активная prompt injection-атака**, целенаправленно нацеленная на AI-агентов с именем «Claude»:

```html
<div id="claude-agent-stop-container">
  <button id="claude-agent-stop-button"><span>Stop Claude</span></button>
</div>
<div id="claude-agent-glow-border"></div>
<style id="claude-agent-animation-styles">@keyframes claude-pulse { ... }</style>
```

Источник инъекции не установлен (вероятнее всего — расширение браузера, версия 1 в [Прил. М §6.2](Analiz_originala_v8_3.md)). Реакция всех операторов Claude — корректная: кнопка не нажата, команда «Stop Claude» проигнорирована.

Чтобы наша платформа Лидерра не стала аналогичной целью для AI-агентов клиентов и их подрядчиков, применяем 4 уровня защиты.

### 22.11.1. CSP — запрет inline и whitelist источников

Полная политика CSP (заголовок `Content-Security-Policy`):

```text
default-src 'self';
script-src 'self' https://cdn.jsdelivr.net https://*.jivosite.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self' data:;
connect-src 'self' https://*.sentry.io https://*.jivosite.com;
media-src 'self';
object-src 'none';
frame-src 'none';
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
upgrade-insecure-requests;
report-uri /csp-report
```

**Ключевое:**

- `script-src` — **без `'unsafe-inline'`** для скриптов (только для `style-src`, без них Vuetify ломается). Все JS-файлы — собранные Vite-бандлы с известными хешами.
- `object-src 'none'` — запрет Flash, Java applets, plugins.
- `frame-ancestors 'none'` — никто не может встроить наш UI в iframe (защита от clickjacking).
- `report-uri /csp-report` — все нарушения политики логируются в Sentry для расследования.

### 22.11.2. Запрет AI-маркеров в собственном DOM

В CI добавляется **линтер сборки** (Vue ESLint plugin + кастомное правило), который падает при попытке закоммитить элемент с ID/class/data-attribute, начинающимся на:

- `claude-`
- `gpt-`
- `agent-`
- `ai-` (кроме явно разрешённых легитимных префиксов из шорт-листа, например `ai-icon-` из MDI)

**Обоснование:** даже если в нашем UI появится элемент с таким префиксом по неосторожности (например, `claude-tooltip` для какой-то фичи), это создаёт риск: AI-агенты клиентов могут неожиданно среагировать, плюс это упрощает враждебную инъекцию через XSS (атакующий просто переиспользует наш стиль).

### 22.11.3. Санитизация пользовательского ввода

Все поля, которые могут отображать HTML (комментарии сделок, заметки, имена контактов с эмодзи, тело шаблонов комментариев), проходят через **DOMPurify** при сохранении и при выводе:

```javascript
import DOMPurify from 'dompurify';

const SAFE_HTML_CONFIG = {
  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'br', 'p', 'a'],
  ALLOWED_ATTR: ['href', 'title'],
  ALLOWED_URI_REGEXP: /^https?:\/\//i,
  FORBID_TAGS: ['style', 'script', 'iframe', 'object', 'embed', 'form'],
  FORBID_ATTR: ['onerror', 'onload', 'onclick', 'style'],
};

const safe = DOMPurify.sanitize(userInput, SAFE_HTML_CONFIG);
```

По умолчанию Vue экранирует через `{{ }}`. `v-html` разрешён только в компонентах с явным комментарием `// SAFE: DOMPurify applied` и проходит через тот же конфиг.

### 22.11.4. Аудит сторонних виджетов

Перед подключением любого third-party скрипта (JivoSite, аналитика, маркетинг-пиксели):

1. **Скачать минифицированный JS** и прогнать grep по 31 keyword (`claude-`, `gpt-`, `ai-agent-`, `prompt-`, `instruction-`, …) — методология из [Прил. М §9.4.2](Analiz_originala_v8_3.md), линия 5.
2. При обнаружении хотя бы одного маркера — **отказ от интеграции** или прокcирование через наш CDN с очисткой подозрительных конструкций перед раздачей клиентам.
3. Все third-party скрипты имеют `Subresource Integrity (SRI)` хеш — при изменении контента CDN скрипт не выполнится.
4. Ежеквартальный аудит — повторная проверка SRI-хешей и keyword-grep актуальных версий подключённых скриптов.

### 22.11.5. Что мы НЕ делаем

- **Не блокируем AI-агентов клиентов в принципе.** Семантическая разметка, ARIA-атрибуты, осмысленные id остаются — это нужно для legitimate автоматизации (clients automation через Playwright/Selenium, тесты доступности).
- **Не пытаемся детектировать «вредоносный» AI-трафик** по User-Agent или поведению — это гонка вооружений без победителя, плюс ломает легитимные интеграции.

## 22.12. Антипаттерны оригинала crm.bp-gr.ru

**Контекст.** В аудите оригинала (партии 14–15, см. [Прил. М §2.18–2.19](Analiz_originala_v8_3.md)) выявлены 3 практики, которые мы **строго не повторяем**. Этот раздел — фиксация наших защитных мер с прямой привязкой к найденным антипаттернам, чтобы при code review/PR review можно было быстро сослаться на конкретный пункт.

| # | Антипаттерн оригинала | Где обнаружен | Наша защита |
|---|---|---|---|
| 1 | **Пароль в `<input type="text">` plaintext в HTML профиля** | `/admin/user/account`, поле «Текущий пароль» (партия 14.3.4) | См. §22.12.1 |
| 2 | **API-credentials (api_key, client_secret, token) в `<input type="text">`** | Все 5 карточек интеграций (Битрикс24, amoCRM, Скорозвон, Unisender, Мои Звонки) — партия 15.2.3 | См. §22.12.2 |
| 3 | **AI-маркеры `claude-agent-*` в DOM** | Каждая страница оригинала (партии 10, 12–15) | См. [§22.11](#2211-защита-от-prompt-injection-в-dom) |

### 22.12.1. Защита формы смены пароля

| Угрозы оригинала | Наши требования (обязательны на code review) |
|---|---|
| Пароль виден в DevTools → Elements при просмотре сессии | Все 3 поля формы — `<input type="password">` |
| Пароль угоняется XSS без user interaction | Кнопка-toggle «👁 показать/скрыть» только на клиенте, без отправки на сервер |
| При случайном скриншоте пароль в кадре | В HTML страницы пароль никогда не пре-заполнен (`value=""` всегда) |
| Brute-force через подмену старого пароля | Rate-limit 3 попытки/час на пользователя (см. [§22.3](#223-rate-limiting)) |
| Слабый пароль | zxcvbn ≥ 3, минимум 10 символов (см. [§22.4.1](#2241-пароль)) |
| Сохранение в `auth_log` | Запись `event='password_changed'` без значения пароля |

### 22.12.2. Защита форм интеграций (API-credentials)

При реализации Уровня 2 интеграций (amoCRM в спринте 14–15, далее Bitrix24/RetailCRM, см. [§19.10.1](#19101-назначение)):

| Угрозы оригинала | Наши требования |
|---|---|
| Токены видны на экране при заполненной карточке | Все credentials-поля — `<input type="password">` + button-toggle «👁 показать» |
| Токены попадают в скриншоты для саппорта | В UI после сохранения отображаются только последние 4 символа: `••••••••••6b8c` |
| Полные значения видны в Network → Response | API-response при чтении карточки возвращает маску `***...****` (последние 4 символа) |
| Невозможно скрыть полный секрет от админа | Полное значение возвращается **только при явном «отредактировать»** + повторная аутентификация (re-auth через пароль или 2FA) |
| Утечка через журнал | В `activity_log` пишем `event='integration.credentials_changed'` без значения |
| Никогда не ротируется | Кнопка «Перевыпустить секрет» в карточке + email-уведомление при ротации |

**Шифрование at-rest.** Поля credentials в БД — обязательно с Eloquent cast `'encrypted'` (см. [§22.5.2](#2252-шифрование-на-хранении-at-rest) — `payment_gateways.config` использует ту же механику).

### 22.12.3. Code review checklist

При ревью PR с новой формой ввода чувствительных данных проверяем:

- [ ] Все поля паролей/секретов — `type="password"`?
- [ ] Нет пре-заполнения значением в HTML?
- [ ] Применён DOMPurify к пользовательскому HTML (если есть `v-html`)?
- [ ] Нет ID/class начинающихся на `claude-/gpt-/agent-/ai-`?
- [ ] Поля credentials в модели имеют cast `'encrypted'`?
- [ ] В `activity_log` не утекает значение секрета?
- [ ] Rate-limit настроен (если форма отправляется на изменение секретного значения)?

## 22.13. v8.5: SSO + SET LOCAL test + Sentry PII + Anti-DDoS + TTL secrets

> **Источник:** реестр v1.12 §13.10 (P0 OPEN-И-13/14/15/16 + CTO-13; P1 OPEN-И-17/21 + CTO-15 + Ю-9). Это сводная секция изменений безопасности v8.5 — для углублённой деталировки RLS см. также §22.6, для Audit hash chain — §14.8.1, для admin SaaS workflow — §23.10.

### 22.13.1. Yandex 360 SSO для админов SaaS (OPEN-И-13)

**Решение DO-5 (04.05.2026):** Yandex 360 SSO для админов SaaS. **v8.5 (OPEN-И-13):** полный flow.

**Архитектура:**

- **Протокол:** OIDC (OpenID Connect). Yandex 360 — IDP, наш `SaasAdminAuthService` — RP (Relying Party).
- **Provisioning:** **JIT** (Just-In-Time). При первом входе через Yandex 360 — автоматическое создание `saas_admin_users` с `sso_provider='yandex360'`, role=`read_only` по умолчанию. Повышение роли — вручную через `super_admin` в `/admin/admins/:id`.
- **Локальный 2FA выключен** при `sso_provider='yandex360'` — Yandex 360 имеет собственный enforced 2FA (TOTP/Yandex Key). Поля `totp_secret_enc/totp_enabled_at` остаются NULL для SSO-пользователей.

**Break-glass-аккаунт:**

- В schema добавлено `saas_admin_users.is_break_glass BOOLEAN DEFAULT FALSE`.
- При первой инициализации SaaS — создаётся **один** аккаунт `super_admin` с `sso_provider='local'`, `is_break_glass=TRUE`, обязательным TOTP, длинным random-паролем (хранится в 1Password DevOps vault, см. OPEN-И-12).
- Используется **только** при недоступности Yandex 360 IDP (>30 минут). Каждый вход break-glass — отдельный alert в Sentry severity=critical + email всем `super_admin` SaaS.
- Нельзя удалить или сделать `is_break_glass=FALSE` через UI — только прямой SQL под `crm_admin_user`.

**Flow:**

1. Админ → `/admin/login`.
2. UI отдаёт «Войти через Yandex 360» (primary button) + «Аварийный вход» (secondary, маленький).
3. Yandex 360 → OIDC redirect → callback `/admin/auth/yandex/callback`.
4. `SaasAdminAuthService::handleSsoCallback($payload)`: validate JWT signature; lookup или JIT-create `saas_admin_users`; create `saas_admin_sessions`; redirect в `/admin`.
5. Логирование в `auth_log` с `actor_type='saas_admin'`, `event='sso_login_success'` (новое значение в whitelist).

**Configuration:** `config/services.php` секция `yandex360` — `client_id`, `client_secret` (из env), `redirect_uri`. Полный SAML/OIDC `xml`-проект Yandex 360 — отдельная задача DevOps в Прил. И.

### 22.13.2. CTO-13: Тест-план `SET LOCAL app.current_tenant_id` через PgBouncer

**Контекст:** все 35 RLS-политик (§22.6) полагаются на `current_setting('app.current_tenant_id')::bigint`. Это значение устанавливается через `SET LOCAL app.current_tenant_id = …` в начале каждого HTTP-request handler / job worker. **PgBouncer в transaction-pooling mode** возвращает соединение в пул после `COMMIT/ROLLBACK` каждой транзакции — `SET LOCAL` живёт ровно одну транзакцию.

**Риск без теста:** если разработчик случайно использует `SET` (session-scope) вместо `SET LOCAL`, или если `auto-commit=true` в Eloquent опускает первую транзакцию — `app.current_tenant_id` может протечь между тенантами. RLS станет декорацией.

**Тест-план (обязателен в спринте 1, до первого PR с tenant-моделью):**

1. **Auto-commit базовый кейс.** Установить `SET LOCAL app.current_tenant_id = 1`, выполнить `SELECT * FROM deals` (должно вернуть только tenant_id=1). Без `BEGIN`/`COMMIT` — что произойдёт? Ожидание: либо явный exception, либо корректное скоупирование. Молчаливая утечка → BLOCKER.
2. **Reuse соединения.** Сценарий: запрос tenant_id=1 завершается; PgBouncer возвращает соединение в пул; новый запрос tenant_id=2 берёт это же соединение. Проверить, что `app.current_tenant_id` НЕ виден из второго запроса (либо вообще undefined, либо явная очистка между транзакциями).
3. **Job retry.** `TenantAwareJob` сериализуется с `tenant_id`. При retry (после exception) — установка `SET LOCAL` корректна; нет утечки от предыдущего failed job в очереди.
4. **Долгая транзакция.** `BEGIN; SET LOCAL …; SELECT …; …; COMMIT;` 5-минутная транзакция. PgBouncer не должен «отнять» соединение для другого pool-юзера в середине.
5. **GUC parameter с `quoted` value.** Проверка корректного escaping `tenant_id` (который BIGINT, не должен иметь quotes — но всё равно проверить prepared statement).

**Критерий прохождения:** все 5 кейсов либо корректно изолируют tenant_id, либо явно падают с exception (приложение видит ошибку и не маскирует утечкой). **Молчаливая утечка = BLOCKER, тест не пройден, фаза 1 не открыта.**

**Альтернативы (не выбраны):** session-mode pooler — жертва производительности при ~100 соединений; `SET ROLE` per tenant — не масштабируется на тысячи ролей в `pg_roles`.

**Расширение Прил. И:** runbook «sprint 1 RLS smoke test» с конкретными SQL-командами и ожидаемыми результатами.

### 22.13.3. RLS WITH CHECK + REVOKE (OPEN-И-14)

См. подробности в schema CHANGELOG_schema.md §Y.6 + §Y.7.

**Кратко:**

- `WITH CHECK` добавлен к политикам `tenant_isolation` на `deal_tag_pivot` и `saas_invoice_items`. До v8.5 USING (фильтр SELECT/UPDATE) защищал чтение/обновление, но INSERT мог пройти при `tag_id`/`invoice_id` чужого тенанта.
- `REVOKE ALL ON saas_admin_users, saas_admin_sessions, saas_admin_audit_log, incidents_log, pd_subject_requests, impersonation_tokens FROM crm_app_user` — defense-in-depth. К saas-таблицам tenant-приложение доступа НЕ должно иметь даже теоретически (RLS + REVOKE = 2 барьера).

**Тест:** в фазе 1 RLS smoke-test (CTO-13) расширяется проверкой WITH CHECK — попытка INSERT в `deal_tag_pivot` с `tag_id` чужого тенанта должна падать с RLS exception.

### 22.13.4. Sentry PII-scrubbing (OPEN-И-16)

**Решение:** whitelist + regex маска для `phone/email/password/secret/token/api_key` во всех контекстах Sentry-event.

**Конфигурация в `config/sentry.php`:**

```php
'before_send' => function (\Sentry\Event $event, ?\Sentry\EventHint $hint) {
    return app(\App\Services\Sentry\PiiScrubber::class)->scrub($event);
},
```

`App\Services\Sentry\PiiScrubber::scrub()`:

1. **Request data:** в `query`, `cookies`, `data`, `headers` — каждое значение проверяется regex'ом на pattern: `phone (^|[^a-z])(\+?7|8)\d{10}` → `+7XXXXXXXXXX`; `email \w+@\w+\.\w+` → `***@***.***`; ключи в whitelist `password|secret|token|api_key|webhook_token|totp_secret_enc` — value заменяется на `***`.
2. **Breadcrumbs:** для каждой crumb проходит то же scrub.
3. **Exception args:** для каждого frame в stacktrace — args редактируются.
4. **Whitelist полей:** в JSON-контекстах разрешены только `id`, `tenant_id`, `created_at`, `status`, `event`. Всё остальное по умолчанию заменяется на `[redacted]`. Это гарантирует, что новые поля (`inn`, `passport`, `dob`) автоматически не утекут в Sentry до явного добавления в whitelist.

**Тест:** unit-test `PiiScrubberTest` для каждого pattern + integration-test, что `Sentry::captureException()` после scrubber'а содержит только safe-значения.

**Юридический эффект:** Sentry self-hosted в Yandex Cloud (Ю-7), но даже там попадание ПДн = breach по 152-ФЗ ст.6 (обработка вне согласованных целей). Whitelist-подход страхует от регрессии при добавлении новых фич.

### 22.13.5. Anti-DDoS (OPEN-И-21)

**Стек:**

1. **Nginx `limit_req_zone`** глобально 1000 RPS на upstream Laravel. Per-IP лимиты остаются как в §22.3 (10 RPS логин, 60 RPS API).
2. **Yandex SmartCaptcha** на публичных эндпоинтах:
   - `/register`
   - `/login` после 2 неудач (CAPTCHA challenge)
   - `/billing/topup` (cardholder verification)
3. **Disposable email blacklist** — список из ~50К доменов (`mailinator.com`, `tempmail.io`, etc.), обновляется раз в неделю. При регистрации с disposable email — отказ.
4. **Yandex Cloud Application Load Balancer DDoS-protection** — включён по умолчанию для нашего L7 ALB.

**Конфигурация:**

- Nginx: `nginx/conf.d/ratelimit.conf` (deployed через CI/CD).
- Yandex SmartCaptcha: `config/services.php` секция `yandex_captcha` — `client_key`, `server_key`. Frontend Vue компонент `<YandexCaptcha />` интегрируется в `<v-form>` нужных страниц.
- Disposable blacklist: ежедневный cron `accounts:refresh-disposable-list`, источник https://github.com/disposable-email-domains/disposable-email-domains.

**Бюджет:** Yandex SmartCaptcha — ~5 000 ₽/мес при ~50 000 challenges/мес. Учтено в смете.

### 22.13.6. TTL secrets (OPEN-И-17, OPEN-И-19)

**Изменения:**

- `api_keys.expires_at` теперь `NOT NULL DEFAULT NOW() + INTERVAL '365 days'` (v8.4 был NULL-able «бессрочный»). Миграция — backfill всем существующим NULL-ключам `NOW() + 365d` перед `SET NOT NULL`.
- `tenants.api_key_limit INT DEFAULT 5 NOT NULL CHECK (1..10)` — hard-limit количества активных ключей. Защита от DoS-через-создание-тысяч-ключей.
- Аналогичный TTL planned для `tenants.webhook_token` (rotation) и `outbound_webhook_subscriptions.secret_hash` (rotation) — реализация в §22.13.6.X cron.

**Cron `secrets:notify-expiring`** — раз в сутки в 09:00 МСК:

1. Выбирает `api_keys` с `expires_at <= NOW() + INTERVAL '30 days' AND is_active=TRUE`.
2. Группирует по `tenant_id` + `user_id` (владелец ключа).
3. Шлёт email: «Ваш API-ключ `{key_prefix}` истекает через {N} дней. Продлить → {URL}». UI «продлить» в `/api-keys` ставит `expires_at = NOW() + INTERVAL '365 days'` + audit в `activity_log`.

**Effect:** все секреты проекта имеют TTL ≤ 365 дней. Ротация — proactive (UI «продлить») с напоминанием за 30 дней.

### 22.13.7. Two-person impersonation + Ю-9 hard-block (CTO-15 + Ю-9)

**Проблема:** до v8.5 `SaasAdminAuthService::createImpersonationToken()` создавал токен для любого тенанта без проверки compliance-флагов. Если у тенанта `pd_subject_request.processing_restricted=TRUE` (152-ФЗ ст.21 ч.5 — субъект ПДн запретил обработку) — admin SaaS мог несмотря на это войти в кабинет тенанта и просмотреть/изменить данные. Юридически это нарушение.

**v8.5 решение:**

`SaasAdminAuthService::createImpersonationToken($tenantId, $reason, $requestedBy)`:

1. **Hard-block (Ю-9):** если у тенанта есть активный `pd_subject_request WHERE processing_restricted=TRUE` — токен можно создать **только** если `requestedBy.role = 'compliance'`. Иначе UI показывает заглушку: «Ограничение обработки ПДн (152-ФЗ ст.21). Доступ возможен только для роли compliance с письменным основанием».
2. **Two-person check (CTO-15):** дополнительно — если у тенанта `pd_subject_request.processing_restricted=TRUE` ИЛИ `chargeback_unrecovered_rub > 0`, то для подтверждения токена требуется второй админ роли `compliance`:
   - При `createImpersonationToken()` — токен сохраняется с `second_approver_id=NULL`, `used_at=NULL`, и сразу блокируется (`invalidated_at IS NULL` пока не одобрено).
   - На email второго `compliance`-админа — ссылка `/admin/imp/:token_id/approve` с описанием reason от первого админа.
   - Второй `compliance` нажимает «Одобрить» → `second_approver_id` + `second_approval_at` заполняются → токен активируется (можно вводить 6-значный код от тенанта).
   - Если второй `compliance` нажимает «Отклонить» → `invalidated_at = NOW()`, token deadlocked.

**Schema (v8.5):** `impersonation_tokens` + `second_approver_id BIGINT REFERENCES saas_admin_users(id)`, `second_approval_at TIMESTAMPTZ`. Если оба NULL — токен в обычном single-admin режиме (тенант без compliance-флагов).

**UX-требование:** в `/admin/tenants/:id` явный indicator «🔒 Two-person required» при наведении на «Войти как…» если флаги активны.

**Audit:** в `saas_admin_audit_log` для каждого создания/одобрения impersonation — отдельные записи action=`impersonation.token_created`, `impersonation.second_approval_granted`, `impersonation.second_approval_denied`.

### 22.13.8. Audit append-only hash chain (OPEN-И-15)

См. подробности в §14.8.1 (full реализация). Здесь — security-перспектива.

**Threat model для audit-журнала:**

- **Insider:** недобросовестный admin SaaS пытается стереть свои действия. Защита: `audit_block_mutation()` triggers + REVOKE UPDATE/DELETE на роль `crm_audit_writer` + audit cron `audit:verify-chain`.
- **Database compromise:** если злоумышленник получает `crm_admin_user` (BYPASSRLS) credentials. Защита: hash chain — пересчёт цепочки выявит точку модификации. Восстановление — из бэкапов до момента compromise.
- **Insider with PG superuser:** вне нашей threat model (если суперпользователь PostgreSQL скомпрометирован — компрометирована вся база). Yandex Cloud Managed PostgreSQL не даёт нам superuser напрямую — это смягчает риск.

**Cron `audit:verify-chain`** — раз в сутки в 04:00 МСК (низкий бизнес-трафик). Отчёт в Prometheus + Grafana dashboard «Audit chain integrity». При detected break — alert в OnCall + создание `incidents_log type='audit_chain_break' severity='high'`.

---

# 23. Инфраструктура и деплой

## 23.1. Окружения

| Окружение | Назначение | Инфраструктура |
|---|---|---|
| **local (dev)** | Разработка | Docker Compose: PHP-FPM, Postgres, Redis, MinIO, MailHog |
| **staging** | Тесты, QA | VPS / небольшой кластер; полная копия prod-конфигурации |
| **production** | Боевая среда | Кластер из 2+ нод приложения, master+replica Postgres, Redis Sentinel |

## 23.2. Docker (dev)

`docker-compose.yml` для локальной разработки:

```yaml
services:
  app:
    build: ./docker/app
    ports: ["8080:80"]
    volumes: ["./:/var/www/html"]
    environment:
      DB_HOST: postgres
      REDIS_HOST: redis
    depends_on: [postgres, redis]

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: crm
      POSTGRES_USER: crm
      POSTGRES_PASSWORD: secret
    volumes: ["postgres_data:/var/lib/postgresql/data"]
    ports: ["5432:5432"]

  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]

  worker:
    build: ./docker/app
    command: php artisan horizon
    volumes: ["./:/var/www/html"]
    depends_on: [postgres, redis]

  scheduler:
    build: ./docker/app
    command: |
      sh -c "while true; do php artisan schedule:run; sleep 60; done"
    volumes: ["./:/var/www/html"]

  mailhog:
    image: mailhog/mailhog
    ports: ["1025:1025", "8025:8025"]

  minio:
    image: minio/minio
    command: server /data --console-address ":9001"
    ports: ["9000:9000", "9001:9001"]
    environment:
      MINIO_ROOT_USER: minio
      MINIO_ROOT_PASSWORD: secretsecret

volumes:
  postgres_data:
```

## 23.3. Production-инфраструктура

### 23.3.1. Базовый вариант (старт, 1–500 тенантов)

```
┌─────────────────────────────────────────────────────┐
│           CDN (Cloudflare / VK CDN)                 │
└──────────────────────┬──────────────────────────────┘
                       ↓
┌─────────────────────────────────────────────────────┐
│   Nginx Load Balancer (1 нода, активный + резерв)  │
│   - SSL termination                                 │
│   - Rate limiting                                   │
│   - Static files (Vue SPA build)                    │
└────────┬─────────────────────────────────┬──────────┘
         ↓                                 ↓
┌──────────────────┐               ┌──────────────────┐
│ App Server #1    │               │ App Server #2    │
│ Nginx + PHP-FPM  │               │ Nginx + PHP-FPM  │
│ (Laravel API)    │               │ (Laravel API)    │
└────────┬─────────┘               └────────┬─────────┘
         ↓                                 ↓
┌─────────────────────────────────────────────────────┐
│              PostgreSQL Master                      │
│              (с активной репликацией)               │
└──────────────────────┬──────────────────────────────┘
                       ↓
┌─────────────────────────────────────────────────────┐
│              PostgreSQL Read Replica                │
│              (для аналитики и отчётов)              │
└─────────────────────────────────────────────────────┘

┌──────────────────┐    ┌──────────────────┐    ┌──────────────────┐
│ Redis Master     │    │ Redis Replica    │    │ Worker Pool      │
│                  │    │ (Sentinel)       │    │ (3 ноды,         │
└──────────────────┘    └──────────────────┘    │  Supervisor)     │
                                                 └──────────────────┘

┌─────────────────────────────────────────────────────┐
│   S3-storage (Yandex Object Storage / MinIO HA)     │
│   - PDF (счета, УПД)                                │
│   - Аватары                                         │
│   - Экспорты отчётов                                │
│   - Бэкапы (отдельный бакет)                        │
└─────────────────────────────────────────────────────┘
```

### 23.3.2. Масштабирование

При росте до 5000+ тенантов / 10М+ сделок:

- App Servers — добавить ноды за балансировщик (горизонтальное масштабирование stateless)
- Workers — добавить процессы Supervisor / перейти на Kubernetes для авто-скейлинга
- БД — read replicas под аналитику; рассмотреть Citus / PostgreSQL шардирование по `tenant_id`
- Redis — кластер (Sentinel → Cluster) для шардирования ключей
- Поиск — выделенный Meilisearch / Elasticsearch вместо встроенного FTS

### 23.3.3. Kubernetes (опционально, при росте)

```yaml
# webhook-receiver.yaml — отдельный deployment для Webhook-приёмника
apiVersion: apps/v1
kind: Deployment
metadata:
  name: webhook-receiver
spec:
  replicas: 4
  selector:
    matchLabels: { app: webhook-receiver }
  template:
    spec:
      containers:
      - name: php-fpm
        image: registry.example.com/crm-app:latest
        env:
        - { name: APP_ROLE, value: webhook }
        resources:
          requests: { cpu: 200m, memory: 256Mi }
          limits:   { cpu: 500m, memory: 512Mi }
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: webhook-receiver-hpa }
spec:
  scaleTargetRef: { name: webhook-receiver, kind: Deployment }
  minReplicas: 2
  maxReplicas: 20
  metrics:
  - type: Resource
    resource: { name: cpu, target: { type: Utilization, averageUtilization: 70 } }
```

## 23.4. CI/CD

GitHub Actions / GitLab CI пайплайн:

```yaml
# .github/workflows/main.yml
on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: composer install
      - run: ./vendor/bin/php-cs-fixer fix --dry-run
      - run: ./vendor/bin/phpstan analyse

  test:
    runs-on: ubuntu-latest
    services:
      postgres: { image: postgres:16, env: { POSTGRES_PASSWORD: test } }
      redis:    { image: redis:7 }
    steps:
      - uses: actions/checkout@v4
      - run: composer install
      - run: php artisan test --parallel

  frontend:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck
      - run: npm run test:unit
      - run: npm run build

  deploy-staging:
    needs: [lint, test, frontend]
    if: github.ref == 'refs/heads/develop'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: rsync ... staging.liderra.ru
      - run: ssh deploy@staging.liderra.ru 'cd /app && php artisan migrate --force && php artisan horizon:terminate'

  deploy-prod:
    needs: [lint, test, frontend]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production  # требует ручного подтверждения
    steps:
      - run: |
          # Blue-green деплой:
          # 1. Подготовить новую версию на отдельных нодах
          # 2. Переключить балансировщик на них
          # 3. Старые ноды держим 1 час для отката
```

## 23.5. Cron / Scheduled Tasks (Laravel `schedule.php`)

```php
// app/Console/Kernel.php
protected function schedule(Schedule $schedule): void
{
    // Партиции БД
    $schedule->command('db:create-partitions --months-ahead=2')->daily()->at('03:00');
    // v8.1, Ю-2: партиции supplier_lead_costs создаются синхронно с deals
    $schedule->command('db:archive-old-partitions --older-than-months=24')->weekly();

    // Архивация и очистка
    $schedule->command('webhook-log:cleanup')->daily()->at('04:00');
    $schedule->command('failed-jobs:cleanup --older-than=7-days')->daily();

    // Бизнес-логика
    $schedule->job(new DispatchRemindersJob)->everyMinute();
    $schedule->job(new ExpireInvoicesJob)->daily()->at('00:30'); // просрочка счетов
    $schedule->job(new NotifyLowBalanceJob)->hourly();
    $schedule->job(new CheckInactiveTenantsJob)->daily()->at('02:00');
    $schedule->job(new PurgeDeletedTenantsJob)->daily()->at('05:00');

    // v8.1, CTO-1: жизненный цикл подписок (применение scheduled в 00:00 МСК)
    $schedule->command('tariffs:apply-scheduled')->everyFiveMinutes();
    // v8.1, CTO-2: email за 7 дней / 1 день до истечения подписки
    $schedule->command('subscription:expiry-notify')->daily()->at('09:00');

    // v8.1, CTO-3: cron для pending транзакций (polling-fallback и hard timeout)
    $schedule->command('payments:cancel-stale')->everyFiveMinutes();

    // Аналитика
    $schedule->command('analytics:precompute-dashboard-cache')->everyTenMinutes();
    $schedule->command('reports:cleanup-expired')->daily(); // удаление файлов отчётов >7 дней

    // Бэкапы
    $schedule->command('backup:postgres-walg')->everyFifteenMinutes();
    $schedule->command('backup:full-snapshot')->daily()->at('01:00');
    $schedule->command('backup:test-restore')->weekly()->saturdays()->at('06:00');
}
```

**Новые cron-задачи v8.1:**

| Команда | Расписание | Назначение | Источник решения |
|---------|------------|------------|-------------------|
| `tariffs:apply-scheduled` | каждые 5 мин | Перевод `scheduled → active` в 00:00 МСК; `active → superseded` при наличии новой active; `active → expired` при `expires_at <= NOW()`; каскадное обновление `tenants.status` | CTO-1, CTO-2 |
| `payments:cancel-stale` | каждые 5 мин | Pending транзакции старше 30 минут — polling шлюза; старше 24ч — hard timeout → failed | CTO-3 |
| `subscription:expiry-notify` | ежедневно в 09:00 | Email-уведомления за 7 дней / 1 день до `expires_at` подписки | CTO-2 |

Запускается через системный cron каждую минуту:

```cron
* * * * * cd /var/www/html && php artisan schedule:run >> /dev/null 2>&1
```

## 23.6. Воркеры очередей (Supervisor)

```ini
# /etc/supervisor/conf.d/crm-workers.conf

[program:crm-webhook-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work redis --queue=webhooks --sleep=1 --tries=3 --max-time=3600
autostart=true
autorestart=true
numprocs=4
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/crm-workers/webhook.log

[program:crm-default-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work redis --queue=default,reports,emails --sleep=1 --tries=3 --max-time=3600
numprocs=2
autostart=true
autorestart=true
user=www-data
stdout_logfile=/var/log/crm-workers/default.log

[program:crm-horizon]
command=php /var/www/html/artisan horizon
autostart=true
autorestart=true
user=www-data
stopwaitsecs=3600
```

Альтернатива на K8s — `Deployment` с одинаковыми контейнерами + HPA.

## 23.7. Бэкапы

### 23.7.1. PostgreSQL

| Тип | Метод | Частота | Хранение |
|---|---|---|---|
| **WAL-сегменты (PITR)** | WAL-G → S3 | Непрерывно | 7 дней |
| **Полный снимок (base backup)** | WAL-G → S3 | Ежедневно (01:00) | 30 дней |
| **Логический дамп (pg_dump)** | Cron + S3 | Еженедельно | 6 месяцев |
| **Гео-копия** | rsync на S3 в другой регион | Ежедневно | 30 дней |

WAL-G позволяет восстановить БД на любую секунду в окне 7 дней. Это критично для случая случайного `DELETE` или ошибочной миграции.

### 23.7.2. Тестирование восстановления

Еженедельно (`backup:test-restore`):

1. Создаётся отдельный временный сервер (Docker / автоматически)
2. На него восстанавливается последний снимок + WAL до конкретной точки
3. Прогоняется минимальный smoke-test (запрос к БД, чтение нескольких таблиц)
4. Если успех — алерт в Slack «✅ Бэкап восстанавливается корректно»
5. Если ошибка — alert в Sentry + Slack/Telegram «❌ КРИТИЧНО: бэкап сломан»

Без этой проверки бэкапы могут "молча" портиться (например, корраптнутый WAL).

### 23.7.3. S3-объекты

S3 имеет встроенное версионирование + cross-region replication. Этого достаточно — отдельные бэкапы файлов не нужны.

## 23.8. Логи

| Источник | Куда |
|---|---|
| Laravel application | stdout (для Docker / K8s) или `/var/log/crm/laravel.log` |
| Nginx access log | `/var/log/nginx/access.log` (с `trace_id`) |
| Nginx error log | `/var/log/nginx/error.log` |
| PHP-FPM | `/var/log/crm/php-fpm.log` |
| PostgreSQL | `pg_log/` |
| Сборка логов | rsyslog / Filebeat → ELK (Elasticsearch) или Loki + Grafana |

Все application-логи в **JSON-формате** с обязательными полями: `timestamp`, `level`, `message`, `tenant_id`, `user_id`, `trace_id`, `request_id`. Это позволяет фильтровать логи в Kibana / Grafana по тенанту.

Retention: горячие логи 30 дней, холодные — 1 год в S3 Glacier.

## 23.9. Управление секретами

`.env` файлы **не** хранятся в git. Секреты:

| Решение | Применимость |
|---|---|
| **HashiCorp Vault** | Лучшая практика для продвинутых команд |
| **AWS Secrets Manager / Yandex Lockbox** | Для облачных деплоев |
| **doppler.com** | SaaS-вариант |
| **Encrypted .env в git** (`php artisan env:encrypt`) | Минимальный вариант для старта |

При деплое — секреты подтягиваются на этапе старта контейнера, не лежат на диске.

## 23.10. Админка SaaS

Внутренний инструмент оператора платформы (нас) для управления тенантами, биллингом, инцидентами и compliance. **Полная спецификация** — в [Приложении Г — `Админка_SaaS_v8_2.md`](Админка_SaaS_v8_2.md). Здесь — сводка для контекста инфраструктуры + правки v8.4 (Биз-16, Ю-1, Ю-2, OPEN-Д-5/И-1, workflow ПДн).

### 23.10.1. Позиционирование

- Отдельное Vue 3 SPA (не подмодуль клиентского приложения).
- Развёртывание под поддоменом `admin.liderra.ru` (внутреннего использования, не для клиентов; домен после Б-1).
- Отдельный backend-API под guard `saas_admin` с собственной session-инфраструктурой.
- Использует **отдельную роль БД** `crm_admin_user` с `BYPASSRLS` (см. §3 «Мультитенантность» + [`db/schema.sql`](../db/schema.sql) v8.4 секция «Роли»).

### 23.10.2. Ролевая модель

Шесть ролей с фиксированным enum (на MVP без кастомных ролей через UI — Биз-15 закрыт как Post-MVP):

| Роль | Доступ |
|------|--------|
| `super_admin` | Всё, включая управление другими админами, `system_settings`, `incidents_log` (закрытие) |
| `support` | Тенанты, биллинг (просмотр), impersonation (Ю-1), ручные начисления (с лимитами) |
| `finance` | Биллинг (полный), `refund_requests`, `supplier_invoices` (Ю-2), chargeback-алерты, дашборд маржи |
| `compliance` | 152-ФЗ — `pd_processing_log`, `tenant_consents`, `pd_subject_requests` (Прил. Д), `incidents_log` (тип `data_breach`) |
| `dev_oncall` | Очереди, `failed_webhook_jobs`, журналы, `incidents_log` (создание + работа с тех-инцидентами) |
| `read_only` | Только чтение всего, для аудита |

**Лимиты на ручные операции** (anti-fraud, см. Прил. Г §3): `support` — до 1000 ₽ или 10 лидов / 24ч на одного админа; >50 000 ₽ — `four-eyes` (двойное подтверждение). Возвраты на любую сумму — только `finance`/`super_admin`.

### 23.10.3. Безопасность

- **Обязательное 2FA TOTP** для всех админов (опционально для клиентов — Биз-9).
- **IP allow-list** на уровне Nginx + per-user в `saas_admin_users.allowed_ips` (JSONB).
- **Сессии:** 4ч idle / 12ч абсолют (вдвое жёстче клиентских — см. §22.4.3).
- **Bcrypt cost=12**, **zxcvbn ≥ 4**.
- **Four-eyes** для операций с суммой > 50 000 ₽ и для критичных `system_settings` (`trial_bonus_leads`, `inactive_delete_months`, `supplier_default_cost_rub`).
- **Полный audit log** в `saas_admin_audit_log` каждого write-действия с привязкой к `actor_admin_user_id`, `target_tenant_id`, `purpose`.

### 23.10.4. Карта экранов

Подробно в [Прил. Г §4](Админка_SaaS_v8_2.md). Кратко — 9 разделов:

| # | Раздел | Что | Прил. Г |
|---|---|---|---|
| 1 | **Дашборд** | KPI на сегодня/вчера/неделю: активные тенанты, MRR, поступления, объём webhook'ов, ошибки 5xx, состояние воркеров/Redis/БД, виджет «Требует внимания» | §4.1 |
| 2 | **Тенанты** | Список + карточка с 7 вкладками. Кнопки `Заблокировать`/`Разблокировать`/**`Войти как…`** (Ю-1)/`Удалить (152-ФЗ)`. Колонка «Желаемое × факт сегодня» (см. §23.10.5) | §4.2 |
| 3 | **Биллинг** | Реестры платежей/УПД, корректировочные УПД, возвраты (наши + chargeback), ручные подтверждения банковских переводов, ручные начисления, алерты finance | §4.3 |
| 4 | **Тарифы и юр.лица** | CRUD по `tariff_plans`, `legal_entities`, `payment_gateways` | §4.4 |
| 5 | **Глобальные настройки** | CRUD по `system_settings` (часть ключей — только `super_admin` с подтверждением) | §4.5 |
| 6 | **Очереди** | `failed_webhook_jobs`, дашборд Horizon (под guard `saas_admin`), интеграция/ссылка на Sentry | §4.6 |
| 7 | **152-ФЗ / Compliance** | `pd_processing_log`, `tenant_consents`, `pd_subject_requests` (см. §23.10.9), `saas_admin_audit_log` | §4.7 |
| 8 | **Поставщики и себестоимость** (Ю-2) | Дашборд маржи, реестр закупок, реестр счетов от поставщика со сверкой (см. §23.10.7) | §4.9 |
| 9 | **Управление админами** | CRUD по `saas_admin_users` — только `super_admin` | §4.8 |

Плюс отдельный экран **Журнал инцидентов** (см. §23.10.8) — точка входа из дашборда (виджет «Требует внимания») и из карточки тенанта (вкладка «Инциденты»).

### 23.10.5. Колонка «Желаемое × факт сегодня» в /admin/tenants (Биз-16, наше расширение)

**Зачем.** Оператор платформы должен видеть, насколько тенант покрывает свою декларированную потребность в лидах. Если тенант просил 100 лидов/день, а получает 30 — это сигнал на разговор: либо проекты тенанта не настроены, либо у нас нет источников под его географию. Оригинал такой колонки не имеет.

**Источники данных:**

- **Желаемое** — `tenants.desired_daily_numbers INT NULL` (заполняется тенантом при онбординге, опционально). DDL см. [`db/schema.sql`](../db/schema.sql) v8.4 раздел `Tenants`. CHECK: `IS NULL OR > 0`.
- **Факт сегодня** — `COUNT(*)` по `deals` со `status IN (<статусы оплачиваемых получений>)` за `DATE(received_at AT TIME ZONE tenant.timezone) = CURRENT_DATE` для данного `tenant_id`.

**SQL-формула** (упрощённая, реальная — через материализованное представление с обновлением каждые 5 минут, чтобы не насиловать партиционированную `deals`):

```sql
SELECT
    t.id,
    t.subdomain,
    t.desired_daily_numbers AS desired,
    COALESCE(SUM(CASE WHEN d.received_at::date = (NOW() AT TIME ZONE t.timezone)::date
                      THEN 1 ELSE 0 END), 0) AS fact_today,
    CASE
        WHEN t.desired_daily_numbers IS NULL THEN NULL
        WHEN COALESCE(SUM(...), 0) >= t.desired_daily_numbers THEN 'green'
        WHEN COALESCE(SUM(...), 0) >= t.desired_daily_numbers * 0.5 THEN 'yellow'
        ELSE 'red'
    END AS status_color
FROM tenants t
LEFT JOIN deals d ON d.tenant_id = t.id
WHERE t.status = 'active'
GROUP BY t.id;
```

**Отображение:** в таблице `/admin/tenants` колонка вида `12 / 30` (факт / желаемое) с цветовым индикатором:

- 🟢 **зелёный** — факт ≥ желаемое (норма);
- 🟡 **жёлтый** — факт ≥ 50% желаемого (внимание);
- 🔴 **красный** — факт < 50% желаемого (тревога — звонок тенанту);
- ⚪ **серый/прочерк** — `desired_daily_numbers IS NULL` (тенант не указал — не алерт).

**Сортировка:** доступна по проценту покрытия (`fact_today / desired`). Фильтр «Только красные» — для оперативного обхода `support`.

**Действие из колонки.** Клик по красному индикатору → модалка с разбивкой по проектам тенанта: лимит проекта × факт сегодня × причина простоя (если есть `project_limit_adjustments` или `effective_daily_limit_today < daily_limit_target`). Помогает понять, **где** именно недопоставка.

> **Где `desired_daily_numbers` редактируется тенантом:** в кабинете `/account/profile` (см. §18.3 «Профиль тенанта»). Поле опциональное, изменения логируются в `saas_admin_audit_log` через триггер.

### 23.10.6. Workflow Impersonation «Войти как клиент» (Ю-1)

**Принцип:** доступ к данным тенанта — только с явного согласия тенанта в момент обращения. Реализовано через **одноразовый код по email тенанту**.

Полный workflow — в [Прил. Г §4.2.1](Админка_SaaS_v8_2.md). Краткая раскадровка:

1. **Запрос.** Админ (`support`/`finance`/`super_admin`) в карточке тенанта нажимает «Войти как…», заполняет обязательное поле «Основание» (≥30 символов, например: «тикет #1234, клиент жалуется на пропадающие сделки»).
2. **Создание токена.** В `impersonation_tokens` пишется запись: `requested_by`, `code_hash` (bcrypt 6-значного кода), `reason`, `sent_to_email` = snapshot `tenant.contact_email`, `expires_at = NOW() + 15 минут`.
3. **Email тенанту.** На `tenant.contact_email` уходит письмо: «Сотрудник `<email админа>` запросил временный доступ. Основание: `<текст>`. Код: `XXXXXX`. Действителен 15 минут. Если не запрашивали — игнорируйте и смените пароль».
4. **Передача кода.** Клиент передаёт код любым способом (звонок, мессенджер).
5. **Валидация.** При вводе админом — проверка (не использован, не истёк, совпадает, `failed_attempts < 5`). После 5 неверных попыток токен инвалидируется. При успехе — сессия 1 час, красный баннер сверху всех экранов, действия дублируются в `saas_admin_audit_log` с флагом `actor_in_impersonation = true`.
6. **Ограничения в режиме impersonation** (HTTP 403 на endpoint-уровне): запрещены смена пароля, смена email, смена `tenant.contact_email` (вектор атаки), удаление аккаунта, отзыв сессий, удаление сделок/проектов, экспорт всех данных, создание/удаление API-ключей, изменение 2FA-настроек.
7. **Финал.** По таймауту/выходу — email тенанту: «Сессия завершена. Длительность: X минут. Действия админа: список».

DDL `impersonation_tokens` — [`db/schema.sql`](../db/schema.sql) v8.4 раздел «Impersonation».

### 23.10.7. Раздел «Поставщики и себестоимость» (Ю-2)

**Контекст.** Лидерра покупает лиды у crm.bp-gr.ru как реселлер (модель закрепилась в Ю-2). В админке — отдельный раздел для управления маржой и сверкой счетов от поставщика. Полная спецификация — [Прил. Г §4.9](Админка_SaaS_v8_2.md).

**5 подразделов:**

1. **Дашборд маржи** — выручка / себестоимость / валовая прибыль за период. Разбивка по тенантам (топ-20 прибыльных и топ-20 убыточных), по тарифным планам. Источник: `JOIN balance_transactions` (выручка) с `supplier_lead_costs` (себестоимость) через `deal_id`. Доступ: `finance`, `super_admin`.

2. **Реестр закупок** (`supplier_lead_costs`) — таблица с фильтрами по периоду / тенанту / проекту. Каждая строка: дата, тенант, `deal_id`, цена закупки, цена продажи, маржа. Экспорт XLSX через `report_jobs` (см. §13).

3. **Реестр счетов от поставщика** (`supplier_invoices`):
   - **Форма ввода счёта.** `finance` загружает PDF от crm.bp-gr.ru, вводит вручную: `invoice_number`, период, кол-во лидов по счёту, сумма по счёту.
   - **Кнопка «Сверить».** Автоматически считает `leads_count_actual = COUNT(supplier_lead_costs WHERE received_at BETWEEN period_from AND period_to)` и `amount_rub_actual = SUM(cost_rub)`. Generated column `discrepancy_rub` показывает расхождение. При расхождении > 1% — визуальный алерт.
   - **Действия:** `Сверено` → статус `reconciled`; `Оплачено` → `paid` + `paid_at`/`paid_by`; `Спорить` → `disputed` + обязательные `notes`.

4. **Алерты для `finance`:**
   - **Отрицательная маржа на тенанте** за месяц (выручка < себестоимость) → пересмотр тарифа клиента или индивидуальные `tariff_subscriptions.custom_overrides`.
   - **Расхождение в счёте от поставщика > 1%** → разбор с crm.bp-gr.ru, при необходимости статус `disputed`.
   - **Новый счёт от поставщика старше 5 рабочих дней без обработки** → напоминание.

5. **Управление закупочной ценой** (`system_settings.supplier_default_cost_rub`):
   - Изменение через стандартный экран `system_settings`, но с дополнительным полем «Дата вступления в силу» (по умолчанию = `NOW()`, можно отложить).
   - Обязательное поле «Причина изменения». Запись в `saas_admin_audit_log`.
   - **Историческая себестоимость не пересчитывается** — только новые лиды получают новую цену. Старые `supplier_lead_costs` хранят snapshot цены на момент получения.
   - Изменение цены требует `four-eyes` (если меняет `finance`).

DDL `suppliers`, `supplier_lead_costs`, `supplier_invoices`, `project_suppliers` — [`db/schema.sql`](../db/schema.sql) v8.4.

### 23.10.8. Журнал инцидентов (`incidents_log`, OPEN-Д-5 + OPEN-И-1)

**Зачем.** Централизованный журнал SaaS-инцидентов, привязанных к ролям `dev_oncall`, `compliance`, `super_admin`. Источник истины для отчётности 152-ФЗ ст.18.1 ч.3.1 (уведомление РКН в течение 24 часов при утечке ПДн), для Runbook on-call (см. отдельный документ `Runbook_ekspluatatsii_v8_2.md`) и для постмортемов.

**8 типов инцидентов** (`incidents_log.type`):

| Тип | Что | Кто заводит | Связь |
|---|---|---|---|
| `service_outage` | Недоступность сервиса (полная или частичная) | `dev_oncall` | — |
| `data_breach` | Утечка ПДн или иных конфиденциальных данных | `compliance` + `super_admin` | `pd_subject_requests` (через `related_pd_subject_request_ids`), `rkn_notified_at` обязателен |
| `security_incident` | Иной инцидент безопасности (брутфорс, попытка SSRF, подозрительный admin-доступ) | `dev_oncall` или `compliance` | — |
| `billing_failure` | Сбой платёжного шлюза, задержка УПД, проблемы с эквайрингом | `dev_oncall` или `finance` | `saas_transactions.failure_reason` |
| `data_corruption` | Порча данных (рассинхронизация реплик, ошибочный массовый UPDATE) | `dev_oncall` | — |
| `integration_failure` | Сбой внешней интеграции (CRM-коннектор, поставщик webhook) | `dev_oncall` | `failed_webhook_jobs`, `outbound_webhook_deliveries` |
| `performance_degradation` | Деградация производительности (медленные запросы, replica lag) | `dev_oncall` | — |
| `other` | Прочее (заполняется в свободной форме) | любая роль | — |

**4 уровня severity:** `low` (плановый, без воздействия) / `medium` (ограниченное число тенантов) / `high` (значительная часть платформы) / `critical` (полный downtime, утечка ПДн).

**Workflow заведения инцидента:**

1. **Создание.** Дежурный `dev_oncall` нажимает «Создать инцидент» → форма: `type`, `severity`, `started_at` (когда реально началось), `detected_at` (когда заметили), `summary` (1–2 предложения). Автозаполнение `created_by_admin_id`. Запись в `incidents_log`.
2. **Расследование.** Поле `root_cause` заполняется после анализа (может быть отложено на 24–72 часа). Ссылка на постмортем — `postmortem_url`.
3. **Воздействие.** Поле `affected_tenant_ids BIGINT[]` — массив тенантов, `affected_users_count INT` — приблизительная оценка.
4. **Уведомление пострадавших.** При `severity IN ('high', 'critical')` или `type = 'data_breach'` — обязательная рассылка пострадавшим тенантам (email-шаблон + статус-страница). Время рассылки — в `notification_sent_at`.
5. **Уведомление РКН** (только для `type = 'data_breach'`): обязательное поле `rkn_notified_at` должно быть заполнено в течение **24 часов** с `detected_at` (152-ФЗ ст.18.1 ч.3.1). Алерт `compliance` если `detected_at + 24h < NOW() AND rkn_notified_at IS NULL`.
6. **Закрытие.** Поле `resolved_at` заполняется при разрешении. CHECK гарантирует `resolved_at >= started_at`. Закрытие требует `closed_by_admin_id` и заполненного `root_cause` (если не `severity = low`).

**UI в админке:**

- **Список инцидентов** (вкладка «Инциденты» в разделе «Очереди и инциденты»): фильтры по типу, severity, периоду, открытые/закрытые. Сортировка по `started_at DESC`.
- **Виджет на дашборде** «🔴 Активные инциденты»: счётчик `WHERE resolved_at IS NULL`, цвет = max severity открытых.
- **Карточка инцидента**: timeline (started → detected → resolved), список затронутых тенантов с переходом в карточку, ссылка на постмортем, ссылки на связанные `pd_subject_requests` (если `type = 'data_breach'`).
- **Карточка тенанта → вкладка «Инциденты»**: фильтр по `affected_tenant_ids @> ARRAY[tenant.id]`. Тенант сам видит только свои инциденты в `/account/incidents` (компонент после v2 — Post-MVP).

**RLS и роли БД.** Таблица **не tenant-уровневая** — RLS не применяется. Доступ контролируется на уровне backend через guard `saas_admin` + проверку роли. Чтение разрешено всем шести ролям (для аудита), запись — только `dev_oncall`/`compliance`/`super_admin`. См. COMMENT ON TABLE в [`db/schema.sql`](../db/schema.sql).

DDL `incidents_log` — [`db/schema.sql`](../db/schema.sql) v8.4, 4 индекса (`started_at DESC`, `severity` partial для unresolved, `(type, started_at)`, `data_breach + rkn_notified_at`).

### 23.10.9. Workflow обращений субъектов ПДн (`pd_subject_requests`, 152-ФЗ)

**Контекст.** Физлицо-лид (не тенант, а тот, чьи данные обрабатываются) имеет право по 152-ФЗ ст.14 потребовать: (а) выписку о своих данных, (б) удаление, (в) корректировку. Срок обработки — **30 календарных дней с момента получения**. Это нерушимый дедлайн.

Полный workflow — в [Прил. Д — `Workflow_pd_subject_requests_v8_2.md`](Workflow_pd_subject_requests_v8_2.md). Здесь — сводка для контекста админки.

**State machine** (`pd_subject_requests.status`):

```
received → identity_verification → identity_verified → processing → completed
    ↓              ↓                       ↓                ↓
    └──────────────┴───────────────────────┴────────────────┴──→ rejected
```

| Статус | Что | Внутренний SLA от `received_at` |
|---|---|---|
| `received` | Запрос принят, заявителю отправлено письмо-подтверждение | 0 |
| `identity_verification` | Запрошено подтверждение личности (письмо №2) | до 3 дней |
| `identity_verified` | Личность подтверждена, ищем данные | до 7 дней |
| `processing` | Удаляем/выгружаем; уведомляем вторичных операторов (вверх — crm.bp-gr.ru, вниз — тенантов-получателей) | до 25 дней |
| `completed` | Запрос выполнен, заявителю — финальное письмо | до 30 дней (жёстко) |
| `rejected` | Отклонён (`identity_not_confirmed` / `no_data_found` / `out_of_scope` / `duplicate` / `legal_hold`) | до 30 дней (жёстко) |

**Запрещённые переходы** (CHECK-constraint в [`db/schema.sql`](../db/schema.sql)): `completed → *` (нельзя переоткрыть), `rejected → completed` (только новой записью), `received → completed` напрямую (обязательно через `identity_verified`).

**Алерты в админке** (роль `compliance`):

- 🟢 **Зелёный** — `received_at < NOW() - 7 days`, статус ещё `received`/`identity_verification` — в графике.
- 🟡 **Жёлтый** — `deadline_at - NOW() < 5 days`, не `completed`/`rejected` — пора заканчивать.
- 🔴 **Красный** — `deadline_at < NOW()`, не `completed`/`rejected` — **нарушение 152-ФЗ**, **обязательная фиксация инцидента** в `incidents_log` (`type = 'security_incident'` или `'data_breach'` если требование о выписке проигнорировано).

Cron `compliance:check-pd-deadlines` (каждые 6 часов) расставляет приоритеты и шлёт email админам `compliance` + `super_admin`.

**UI в админке:**

- **Список обращений** (вкладка «Запросы субъектов ПДн» в разделе «152-ФЗ / Compliance», см. §23.10.4): фильтры по статусу, типу запроса (`access`/`deletion`/`correction`), дедлайну. Сортировка по `deadline_at ASC` (горящие — наверху).
- **Карточка обращения** (Прил. Д §8.2): timeline статусов, заявитель (email/phone, маскированно), результат поиска данных (`data_inventory_json`), список уведомлений вторичным операторам, кнопка «Сменить статус» с обязательным основанием.
- **Виджет на дашборде** для роли `compliance`: счётчики «🟢 в графике / 🟡 жёлтые / 🔴 красные».

**Связь с `incidents_log`.** При красном статусе автоматически предлагается создание инцидента (форма prefill: `type = 'security_incident'`, `related_pd_subject_request_ids = [request.id]`).

**Шаблоны писем** (7 штук: подтверждение получения / запрос личности / запрос доп. инфо / выполнено / отклонено / уведомление crm.bp-gr.ru / уведомление тенанта) — см. [Прил. Д §7](Workflow_pd_subject_requests_v8_2.md).

DDL `pd_subject_requests` — [`db/schema.sql`](../db/schema.sql) v8.4.

### 23.10.10. Конкурентное преимущество vs оригинал crm.bp-gr.ru

В оригинале crm.bp-gr.ru **полноценной админки SaaS-уровня нет** — есть только клиентский интерфейс, в котором данные оператора платформы и тенантов перемешаны (см. Прил. М §6, аудит партий 1–15). Для нашего MVP админка SaaS — отдельная архитектурная плоскость, дающая 5 эксплуатационных преимуществ:

| Возможность | Лидерра | Оригинал crm.bp-gr.ru |
|---|---|---|
| Отдельный поддомен и роль БД с `BYPASSRLS` | ✅ `admin.liderra.ru` + `crm_admin_user` | ❌ нет разделения, один интерфейс |
| Колонка «Желаемое × факт сегодня» | ✅ Биз-16 | ❌ нет такой метрики |
| Workflow impersonation с одноразовым кодом email | ✅ Ю-1, 15 мин TTL | ❌ нет такого workflow (есть подмена сессии без согласия) |
| Дашборд маржи + сверка счетов от поставщика | ✅ Ю-2 | ❌ нет (партия 1–15 не показала) |
| Журнал инцидентов с привязкой к РКН-уведомлению | ✅ `incidents_log` + 24ч SLA для `data_breach` | ❌ нет журнала |
| Workflow обращений субъектов ПДн | ✅ Прил. Д, 30-дневный SLA | ❌ нет workflow |
| Полный audit log админских действий | ✅ `saas_admin_audit_log` | ❌ только error log |

> **Полная спецификация админки** — карта экранов, DDL новых таблиц (`saas_admin_users`, `saas_admin_audit_log`), дополнения к чек-листу заказчика, влияние на план спринтов — в [Прил. Г](Админка_SaaS_v8_2.md). Workflow ПДн — в [Прил. Д](Workflow_pd_subject_requests_v8_2.md).

### 23.10.11. v8.5: Yandex 360 SSO + break-glass + two-person impersonation

> **Источник:** реестр v1.12 §13.10 (OPEN-И-13, CTO-15, Ю-9). Для углублённого описания см. также §22.13.1 (SSO flow), §22.13.7 (two-person + Ю-9).

**Изменения в админке SaaS v8.5:**

1. **Login screen `/admin/login` (OPEN-И-13).** Primary button — «Войти через Yandex 360» (OIDC). Secondary, маленький — «Аварийный вход» для break-glass-аккаунта. После успешного OIDC-callback — JIT-create `saas_admin_users` если новый пользователь, role default = `read_only`. Локальный 2FA выключен для SSO-юзеров (используется 2FA Yandex 360).

2. **Break-glass dashboard (OPEN-И-13).** В `/admin/system/break-glass` для `super_admin` — список текущих break-glass-входов (живые сессии + последние 30 дней). Каждый break-glass-вход формирует Sentry alert + email всем `super_admin`. Цель — раннее обнаружение злоупотребления break-glass (оно должно быть редким, при недоступности IDP).

3. **Two-person impersonation indicator (CTO-15).** В `/admin/tenants/:id` рядом с кнопкой «Войти как клиент»:
   - Серый замок «🔒 Two-person required» — при наличии активного `pd_subject_request.processing_restricted=TRUE` ИЛИ `chargeback_unrecovered_rub > 0`. Hover показывает причину.
   - При клике — модальное окно с двумя шагами: (а) основание (≥30 символов как раньше) + (б) выбор второго `compliance`-админа из списка.
   - После Submit — токен сохраняется в pending-state (`second_approver_id=NULL`, `invalidated_at` пока не одобрено), email второму админу с deep-link на approve/decline.

4. **Approval queue для compliance-админов (CTO-15).** Новый экран `/admin/imp/pending-approvals` — список pending-токенов impersonation, ожидающих второго одобрения от текущего `compliance`-юзера. Можно одобрить (заполнить `second_approver_id`/`second_approval_at`) или отклонить (`invalidated_at = NOW()`). Все действия → `saas_admin_audit_log`.

5. **152-ФЗ ст.21 hard-block UI (Ю-9).** Если `pd_subject_request.processing_restricted=TRUE` И `requestedBy.role != 'compliance'`:
   - Кнопка «Войти как клиент» **полностью скрыта** (не disabled, а скрыта).
   - Вместо неё — серый блок: «🔒 Доступ ограничен по 152-ФЗ ст.21. Только роль `compliance` с письменным основанием».
   - Если admin всё-таки попадёт через прямой URL `/admin/tenants/:id/imp/create` (например, кэшированная ссылка) — `SaasAdminAuthService` вернёт 403 + audit-запись `event='imp_blocked_152fz_st21'`.

**Cross-references:**

- Полный flow OIDC + конфигурация — Прил. И (Runbook) v8.5 «Yandex 360 SSO setup».
- Schema-изменения — `saas_admin_users.sso_provider/is_break_glass`, `impersonation_tokens.second_approver_id/second_approval_at` (см. CHANGELOG_schema.md §Y.2.1, §Y.2.2).
- Threat model two-person — §22.13.7.

---

# 24. Тестирование

## 24.1. Стратегия

| Слой | Покрытие | Инструмент |
|---|---|---|
| **Unit** | 70%+ для бизнес-логики (services, calculators) | PestPHP / PHPUnit |
| **Feature/Integration** | Все API-эндпоинты, все flow | PestPHP + Database transactions |
| **Browser** | Критичные пользовательские сценарии: регистрация, оплата, приём Webhook, drag-and-drop | Laravel Dusk / Playwright |
| **Frontend unit** | Компоненты, composables, stores | Vitest |
| **Frontend e2e** | Smoke-tests | Playwright или Cypress |
| **Performance** | Webhook-приёмник, дашборд под нагрузкой | k6 / Locust |
| **Security** | OWASP Top 10 | Snyk / dependabot + ручной аудит |

## 24.2. Обязательное покрытие 100%

Эти модули **должны** иметь 100% покрытие тестами:

1. **Лид-биллинг** (`balance_transactions`) — атомарность списания, защита от двойного списания, обработка нулевого баланса
2. **SaaS-биллинг** (`saas_transactions`) — конвертация ₽ → лиды, корректный расчёт НДС, идемпотентность платежей
3. **Webhook-приёмник** — все валидации, все крайние случаи (см. раздел 5.6)
4. **Изоляция тенантов** — каждый защищённый эндпоинт проверяется на cross-tenant утечку
5. **Маппинг русских статусов в slug** при CSV-импорте — все 14 статусов + неизвестные значения
6. **Нумерация документов** — отсутствие дыр и дублей при параллельных запросах

## 24.3. Тесты Webhook (обязательный набор)

```php
test('webhook accepted with valid payload', function () { /* ... */ });
test('webhook rejected with invalid token (404)', function () { /* ... */ });
test('webhook rejected when tenant suspended (403)', function () { /* ... */ });
test('webhook rejected with invalid phone format (422)', function () { /* ... */ });
test('webhook rate limited at 100 RPS (429)', function () { /* ... */ });
test('duplicate webhook does not double-charge balance', function () { /* ... */ });
test('webhook with B2_ prefix strips it', function () { /* ... */ });
test('webhook with B3_ prefix strips it', function () { /* ... */ });
test('webhook with zero balance logs to rejected_deals_log', function () { /* ... */ });
test('webhook job retries on db error', function () { /* ... */ });
test('webhook job logs to failed_jobs after 3 retries', function () { /* ... */ });
test('webhook response time under 100ms', function () { /* ... */ });
```

## 24.4. Тесты SaaS-биллинга (обязательный набор)

```php
test('topup creates pending transaction', function () { /* ... */ });
test('topup webhook with valid signature succeeds', function () { /* ... */ });
test('topup webhook with invalid signature rejected', function () { /* ... */ });
test('duplicate gateway webhook (same idempotence_key) is idempotent', function () { /* ... */ });
test('balance_rub correctly converts to leads at tariff rate', function () { /* ... */ });
test('vat calculation for vat20 correct', function () { /* ... */ });
test('vat calculation for usn_6 returns no_vat', function () { /* ... */ });
test('document_sequences are gap-free even with concurrent requests', function () { /* ... */ });
test('refund only allowed for full amount, not partial', function () { /* ... */ });
test('refund creates corrective UPD', function () { /* ... */ });
```

## 24.5. Performance / нагрузочные тесты

Webhook-приёмник проверяется на способность держать **1000 RPS** (запас х10 от лимита 100 RPS на тенант, при предполагаемой одновременной активности 10 тенантов).

```js
// k6 script
import http from 'k6/http';
import { check } from 'k6';

export const options = {
    stages: [
        { duration: '30s', target: 100 },
        { duration: '2m',  target: 1000 },
        { duration: '30s', target: 0 },
    ],
    thresholds: {
        http_req_duration: ['p(95)<100'],   // 95% запросов <100мс
        http_req_failed:   ['rate<0.01'],   // ошибок <1%
    },
};

export default function () {
    const payload = JSON.stringify({
        vid: Math.floor(Math.random() * 1e9),
        project: 'B2_Test',
        tag: 'Test',
        phone: '79' + String(Math.floor(Math.random() * 1e9)).padStart(9, '0'),
        time: Math.floor(Date.now() / 1000),
    });
    const res = http.post(`https://staging.liderra.ru/webhook/${__ENV.TOKEN}`, payload, {
        headers: { 'Content-Type': 'application/json' },
    });
    check(res, { 'status 200': (r) => r.status === 200 });
}
```

## 24.6. Pre-release smoke tests

Перед каждым деплоем в production — обязательный прогон:

1. Регистрация нового тенанта от начала до конца
2. Приём 10 тестовых Webhook
3. Изменение статуса сделки в Kanban
4. Создание счёта на оплату
5. Проверка дашборда — открывается без ошибок
6. Проверка экспорта CSV
7. Логин с 2FA
8. API: GET /api/v1/deals возвращает данные

Если хоть один шаг упал — деплой откатывается на предыдущую версию через blue-green.

---

# 25. Мониторинг и SLA

## 25.1. Целевой SLA

| Метрика | Цель (целевая) | Цель (MVP) |
|---|---|---|
| **Доступность приложения** | 99.9% (4.4 мин простоя в месяц) | 99.0–99.5% (от 3.5 до 7.5 часов простоя в месяц) |
| **Webhook-приёмник** | 99.99% (4 сек/час) — критичен | 99.9% |
| **Время ответа API (p95)** | <300 мс | <500 мс |
| **Время ответа Webhook (p95)** | <100 мс | <200 мс |
| **Время доставки уведомлений** | <30 сек | <60 сек |
| **RPO** (recovery point objective) | 15 минут (PITR) | 1 час |
| **RTO** (recovery time objective) | 1 час | 4 часа |

Архитектура с самого начала проектируется под 99.9%. Фактическое значение зависит от качества инфраструктуры, отлаженности процессов реагирования и зрелости мониторинга.

## 25.2. Метрики приложения

| Метрика | Источник | Алерт |
|---|---|---|
| `http_request_duration_seconds` | Laravel middleware | p95 > 500мс — warning |
| `http_requests_total` (по статусам) | Laravel | 5xx rate > 1% — critical |
| `webhook_received_total` | Laravel | Резкое падение — warning (источник лидов сломался?) |
| `webhook_processing_seconds` | Worker | p95 > 5с — warning |
| `queue_jobs_pending` | Horizon | > 1000 — warning, > 10000 — critical |
| `queue_jobs_failed_total` | Horizon | > 0 за 5 минут — warning |
| `database_connections_active` | PostgreSQL | > 80% от max — warning |
| `database_query_duration` | PostgreSQL pg_stat_statements | Медленные запросы > 1с |
| `redis_memory_used_bytes` | Redis | > 80% — warning |
| `disk_free_bytes` | Node exporter | < 20% — warning, < 5% — critical |

## 25.3. Стек мониторинга

```
┌─────────────────────────────────────────────────────┐
│  Приложение (Laravel) → expose /metrics endpoint    │
│  (используем prom-php / Prometheus client)          │
└──────────────────────┬──────────────────────────────┘
                       ↓
┌─────────────────────────────────────────────────────┐
│  Prometheus (scrape /metrics каждые 15с)            │
└──────────┬───────────────────────────────────┬──────┘
           ↓                                   ↓
┌──────────────────────┐         ┌──────────────────────┐
│  Grafana              │         │  Alertmanager        │
│  (дашборды)           │         │  → Slack             │
│                       │         │  → Telegram          │
│                       │         │  → Email (on-call)   │
└──────────────────────┘         └──────────────────────┘

┌─────────────────────────────────────────────────────┐
│   Sentry (отдельно, для исключений приложения)      │
└─────────────────────────────────────────────────────┘
```

## 25.4. Healthcheck эндпоинт

`GET /health` — публичный, без авторизации:

```json
{
  "status": "healthy",
  "timestamp": "2026-04-15T12:34:56Z",
  "version": "8.0.0",
  "checks": {
    "database": "ok",
    "redis": "ok",
    "storage": "ok",
    "queue": "ok"
  }
}
```

Если хоть один check failed — статус `degraded` или `unhealthy` + HTTP 503. Используется балансировщиком (Nginx/K8s) для решения о выводе ноды из ротации.

```php
// app/Http/Controllers/HealthController.php
public function check(): Response
{
    $checks = [
        'database' => $this->checkDatabase(),
        'redis'    => $this->checkRedis(),
        'storage'  => $this->checkStorage(),
        'queue'    => $this->checkQueue(),
    ];

    $allOk = !in_array('error', $checks);
    $httpCode = $allOk ? 200 : 503;

    return response()->json([
        'status'    => $allOk ? 'healthy' : 'unhealthy',
        'timestamp' => now()->toIso8601String(),
        'version'   => config('app.version'),
        'checks'    => $checks,
    ], $httpCode);
}

private function checkDatabase(): string
{
    try {
        DB::select('SELECT 1');
        return 'ok';
    } catch (\Throwable $e) {
        return 'error: ' . $e->getMessage();
    }
}
// ... аналогично для остальных
```

Дополнительно — отдельный эндпоинт `GET /health/deep` (с авторизацией для админов) с расширенными метриками: lag репликации, количество failed jobs, размер очередей и т.д.

## 25.5. Алерты

**Каналы:**

| Серьёзность | Каналы | SLA реагирования |
|---|---|---|
| **Critical** (production down, биллинг сломан) | Slack #alerts + Telegram + SMS дежурному | 15 минут |
| **Warning** (high error rate, очередь растёт) | Slack #alerts | 2 часа в рабочее время |
| **Info** (health-check на staging) | Slack #notifications | Best effort |

**Примеры правил Alertmanager:**

```yaml
groups:
- name: critical
  rules:
  - alert: HighErrorRate
    expr: rate(http_requests_total{status=~"5.."}[5m]) > 0.01
    for: 5m
    labels: { severity: critical }
    annotations:
      summary: "5xx rate > 1% on {{ $labels.instance }}"

  - alert: WebhookEndpointDown
    expr: rate(webhook_received_total[10m]) == 0
    for: 30m
    labels: { severity: critical }
    annotations:
      summary: "No webhooks received for 30 minutes"

  - alert: DatabaseDown
    expr: up{job="postgres"} == 0
    for: 1m
    labels: { severity: critical }

  - alert: QueueBacklog
    expr: queue_jobs_pending > 10000
    for: 5m
    labels: { severity: warning }

  - alert: DiskSpaceLow
    expr: disk_free_bytes / disk_total_bytes < 0.1
    for: 10m
    labels: { severity: warning }
```

## 25.6. On-call rotation

Минимум 2 человека в команде разработки, дежурят неделями. Дежурный имеет:

- Доступ к Sentry, Grafana, серверам (SSH через bastion)
- **Runbook** с типовыми инцидентами и порядком действий — **Приложение И — `Runbook_ekspluatatsii_v8_1.md`**: 9 типовых сценариев (пропавший webhook, backlog очередей, late_webhook_ignored, RLS leak, chargeback unrecovered, сбой БД и др.) с пошаговой диагностикой и эскалацией
- Контакты эскалации (тех. директор, владелец)

## 25.7. Status page

Публичная страница `https://status.liderra.ru` с историей инцидентов и текущим статусом компонентов. Можно поднять на:

- self-hosted: Cachet, Statping
- SaaS: Statuspage.io, Atlassian Statuspage

В моменты инцидентов — публичные апдейты с указанием времени восстановления. Это важно для доверия клиентов.

---

# 26. Дизайн-система

> **⚠ Обновлено в v8.4 (06.05.2026):** раздел переписан под брендбук v1.1 (Диз-2 закрыт 04.05.2026). Старая палитра `#3B82F6` (синий) и сетка 8px заменены на актуальные. Источник истины по визуалу — **`brandbook.md` v1.1**, не этот раздел.

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

Полная дизайн-система — в файле **[liderra_v8_handoff/docs/BRANDBOOK_v2.md](../liderra_v8_handoff/docs/BRANDBOOK_v2.md) v8 Forest** (палитра Forest, типографика Inter+JBM, лого L-Square SVG, Vuetify-тема). Этот раздел — **краткая выжимка ключевых решений**, без дублирования. *Старый `docs/brandbook.md` v1.1 удалён 08.05.2026 после ребрендинга и заменён handoff'ом Платона — см. CLAUDE.md §0 + memory `reference_archive.md`.*

| Тема | Где смотреть в брендбуке |
|---|---|
| Палитра (Teal + нейтральная + семантическая) | §3 |
| Типографика (Inter + JetBrains Mono) | §4 |
| Сетка и отступы (4px) | §5 |
| Тон коммуникации | §6 |
| Логотип (5 SVG inline) | §9.1–9.5 |
| CSS-переменные | §8.1 |
| Vuetify-тема `liderraLight` | §8.2 |

## 26.2. Ключевые токены (резюме)

**Primary:** `#0F6E56` (Teal 600). Контраст на белом — 6.42:1 (WCAG AA).
**Шрифты:** `Inter` (UI, weights 400/500/600/700) + `JetBrains Mono` (код, ID транзакций, токены).
**Сетка:** базовая единица **4px**. Отступы кратны 4: 4 / 8 / 12 / 16 / 24 / 32 / 48 / 64.
**Контейнер:** max-width 1440px, отступы 24px (десктоп) / 16px (мобильный).
**Border-radius:** 6 / 8 / 12 / 999 (sm / md / lg / pill).

Семантические цвета (success/warning/danger/info) и статусы воронки — в брендбуке §3.3 и БД (`lead_statuses.color_hex`).

## 26.3. Компоненты Vuetify 3

Стандартные компоненты используются как есть, без замены сторонних UI-китов (см. также Прил. М §6.6 о маскировании credentials).

| Компонент | Применение |
|---|---|
| `v-app-bar` + `v-navigation-drawer` | Шапка с меню пользователя/колокольчиком + боковое меню |
| `v-data-table-server` | Таблица сделок (серверная пагинация, фильтры) |
| `v-card` / `v-chip` / `v-dialog` / `v-snackbar` | Базовые элементы |
| `v-form` + `v-text-field` | Все формы с валидацией |
| `v-text-field type="password"` + `:append-inner-icon="mdi-eye"` | **Все credentials** (api_key, client_secret, token, password) — с toggle «👁 показать». Антипаттерн оригинала: plaintext в `<input type="text">` (Прил. М §2.19, партии 14.3.4 и 15.2). |
| `v-otp-input` | 6-значный код 2FA |
| `v-date-picker` / `v-autocomplete` | Date range / селекты с поиском |
| `v-skeleton-loader` / `v-progress-*` | Состояния загрузки |

Drag-and-drop для Kanban — `vue-draggable-plus` или `@formkit/drag-and-drop` (см. §11.4).
Графики — `vue3-apexcharts` (фиксировано в §12.3.1).

## 26.4. Доступность (WCAG 2.1 AA)

- Контраст текста к фону ≥ 4.5:1 (3:1 для крупного ≥18px). Проверка — Pa11y (см. Прил. Н).
- Все интерактивные элементы доступны с клавиатуры (`tab`, видимый focus 2px outline в Teal 600).
- ARIA-атрибуты на нестандартных компонентах (custom dropdown, Kanban DnD).
- Минимальный размер таргета для тача — 44×44px (WCAG 2.5.5).
- Поддержка `prefers-reduced-motion: reduce` — отключение анимаций (drag preview, skeleton, push).
- Цвет — не единственный носитель смысла (для статусов всегда есть и иконка/текст).
- `<label>` всегда связан с `<input>`, ошибки рядом с полем + текст (не только цвет).

## 26.5. Темы

- **MVP — только светлая** (`liderraLight`).
- Тёмная тема `liderraDark` определена в брендбуке §8.2, но макетов не делаем (Q-Диз-G/Диз-11 — Post-MVP).
- Переключатель тем в UI — не делаем на MVP.

## 26.6. Иконки

- **Material Design Icons** (`@mdi/font`) — стандартный набор для Vuetify.
- **Lucide-icons** — fallback для специфичных случаев.
- **Не смешивать** в одном экране — выбрать один набор.

## 26.7. Локализация и форматы

`vue-i18n` — все тексты в `locales/ru.json`. Английская версия — Post-MVP (Диз-11). Даты через `dayjs` + локаль `ru`.

| Формат | Пример |
|---|---|
| Дата | `15.04.2026` (`DD.MM.YYYY`) |
| Время | `14:30` (`HH:mm`) |
| Дата+время | `15.04.2026 14:30` |
| Деньги | `1 234,56 ₽` (пробелы для тысяч, запятая для копеек, ₽ после с пробелом) |
| Телефон | `+7 (900) 123-45-67` |
| Маскированный телефон | `+7 (909) ***-67-89` (для Kanban-карточек, preview) |

## 26.8. HTML-прототипы

Референсные прототипы — в [web/](../web/): `01-login.html`, `02-dashboard.html` готовы; `03–08` в очереди (Диз-1, в работе у Claude). Это **референс по визуалу и поведению**, не финальный дизайн — Figma-макеты делаются на их основе.

---

# 27. Чек-лист готовности к разработке

## 27.1. Что зафиксировано в этом документе

✅ **Архитектурные решения**

- Многотенантная SaaS-платформа
- Один пользователь = один тенант (на MVP)
- Изоляция через `tenant_id` + global scope + 4 уровня защиты
- Партиционирование `deals` помесячно по `received_at`
- PostgreSQL 16 + Redis + Laravel 13 + Vue 3 + Vuetify 3

✅ **Источник данных**

- Webhook от crm.bp-gr.ru — основной канал; CSV-импорт — опциональный модуль (раздел 6) для миграции исторических лидов; ручное создание сделок — для редких кейсов (раздел 10.7)
- Объединение B1/B2/B3 в один источник, обрезка префиксов B2_/B3_
- Очередь Redis с асинхронной обработкой
- Идемпотентность через `webhook_dedup_keys.PRIMARY KEY (tenant_id, source_crm_id)` — отдельная не-партиционированная таблица с composite FK на `deals(id, received_at)` (v8.6, CTO-17: UNIQUE на партиционированной `deals` невозможен)

✅ **Биллинг**

- Кошельковая модель: разовое пополнение, без рекуррентов
- Расширяемая схема тарифов (per_lead, monthly, hybrid, custom)
- Расширяемая схема юр. лиц (несколько ООО/ИП)
- Расширяемая схема платёжных шлюзов (Driver pattern)
- Сгорание баланса при отмене (без частичных возвратов)
- Полные возвраты только администратором SaaS

✅ **Жизненный цикл клиента**

- Самостоятельная регистрация
- Стартовый бонус (50 лидов, настраивается в `system_settings`)
- Email-подтверждение
- Удаление неактивных через 12 месяцев простоя

✅ **Функционал**

- 14 статусов воронки (свободные переходы, кастомизация 8 настраиваемых)
- Таблица сделок + Kanban + Дашборд + расширенная аналитика
- Шаблоны комментариев (личные + глобальные)
- Теги и метки
- Напоминания + Web Push (без Telegram)
- 2FA (TOTP) + recovery codes
- REST API с OpenAPI-спецификацией

✅ **Безопасность**

- Bcrypt cost=12, политика пароля min:10 + zxcvbn ≥3
- Rate limiting на login, register, webhook, API
- Шифрование чувствительных полей (`Crypt::encrypt`)
- 152-ФЗ: согласия, журналы, право на удаление
- Аудит-логи (auth_log, activity_log, pd_processing_log)

✅ **Инфраструктура**

- PostgreSQL с PITR через WAL-G + гео-копия
- Redis с репликацией / Sentinel
- S3-хранилище в РФ
- Healthcheck + Prometheus + Grafana + Sentry
- Бэкапы каждые 15 минут (WAL) + ежедневный полный, тестирование восстановления еженедельно

✅ **Поддержка**

- Email-helpdesk + чат в интерфейсе + телефон

✅ **SLA**

- Целевой 99.9%, фактический 99.0–99.5% на MVP

✅ **v8.1: Архитектурные решения P0-блока (новое)**

| ID | Решение | Раздел |
|----|---------|--------|
| Ю-2 | Реселлерская модель: мы покупаем лиды у crm.bp-gr.ru, перепродаём тенантам. Самостоятельный оператор ПДн в трёхзвенной цепочке | 1.5, 1.6, 22.9 |
| Ю-2 | Себестоимость лидов: единая константа `system_settings.supplier_default_cost_rub`, snapshot на каждый лид в `supplier_lead_costs`, счета в `supplier_invoices` | 20.12 |
| Ю-3 | Chargeback workflow: автосоздание `refund_requests(source='external_chargeback')`, защита от минусового баланса через `tenants.chargeback_unrecovered_rub` | 20.6.1 |
| Ю-1 | Impersonation «Войти как клиент»: одноразовый код email-ом тенанту, 15 мин TTL, 5 попыток, сессия 1 час, чёрный список write-действий | 22.9.6, 23.10 |
| CTO-1 | Жизненный цикл `tariff_subscriptions`: 5 состояний enum (`scheduled/active/superseded/expired/cancelled`), уникальный частичный индекс, cron `tariffs:apply-scheduled` в 00:00 МСК, бессрочное хранение истории | 20.2.3 |
| CTO-2 | Поведение при `expired`: жёсткая блокировка тенанта, webhook → 402, лиды теряются. Намеренная асимметрия с per_lead+нулевой баланс | 20.2.3 |
| CTO-3 | Pending транзакции: 30 мин soft + polling-fallback от cron, 24 часа hard timeout. `failed → success` запрещён | 20.5 |
| CTO-4 | `users.notification_preferences` JSONB матрица 8×3 + отдельный `sound_enabled` BOOLEAN | 7.3, 18.5 |
| CTO-5 | PostgreSQL Row-Level Security включён сразу на MVP. 34 политики на 34 защищённых таблицах (1:1), 3 роли БД (`crm_app_user`, `crm_admin_user` BYPASSRLS, `crm_migrator` BYPASSRLS), `TenantAwareJob` для очередей | 3.2, 22.6 |
| Биз-1 | 4 стартовых тарифа-заглушки (`start/basic/pro/enterprise`), цены = 1.00 ₽ заглушки | 20.2.2 |
| Биз-2 | Кнопки «Отменить подписку» нет: уход через смену тарифа на `start` или удаление аккаунта | 20.2.3 |

## 27.2. Что нужно от заказчика перед стартом

> **v8.1:** полный сводный список открытых вопросов к заказчику с разбивкой по 6 адресатам (юрист, бухгалтер, CTO, бизнес, дизайнер, DevOps), приоритетами P0/P1/P2, привязкой к спринтам и 13 предложенными дефолтами — в **Приложении Е — `Открытые_вопросы_v8_1.md`**. Там же — статус решений P0-блока (11 закрыто, 9 P0 остаются открытыми).

Краткий список ниже — для общей картины. Детали и все рабочие вопросы — в Приложении Е.

| № | Что нужно | Срок | Кто отвечает | Статус v8.1 |
|---|---|---|---|---|
| 1 | **Реквизиты юр. лиц** (1+ ООО/ИП): ИНН, КПП, ОГРН, банковские реквизиты, адрес, ФИО директора, скан печати, скан подписи (PNG), система налогообложения | До спринта 1 | Заказчик + бухгалтер | ⏳ Б-1, P0 |
| 2 | **Тарифные планы** — реальные цены, лимиты, фичи (4 тарифа `start/basic/pro/enterprise` уже зафиксированы как структура с заглушками 1.00 ₽, заказчик заменяет через админку) | До публичного запуска | Заказчик + бизнес | ✅ структура (Биз-1), ⏳ цены |
| 3 | **Платёжные шлюзы**: подключение к ЮKassa, получение `shop_id` и `secret_key` | До спринта 8 | Заказчик | ⏳ |
| 4 | **Контактная инфо**: телефон поддержки, email helpdesk, адреса для подвала сайта | До спринта 14 | Заказчик | ⏳ |
| 5 | **Юридические документы**: оферта, политика конфиденциальности, согласие на обработку ПДн (152-ФЗ) — **с учётом реселлерской модели и трёхзвенной цепочки ПДн** | До спринта 14 | Заказчик + юрист | ⏳ Ю-5 (производные Ю-2-доп, Ю-3-юр, Ю-8) |
| 6 | **Уведомление в Роскомнадзор** о деятельности оператора ПДн (как «оператор», не «обработчик») | До запуска prod | Заказчик | ⏳ Ю-4, P0 |
| 7 | **Домен и SSL**: основной домен, wildcard SSL `*.liderra.ru` | До спринта 1 | Заказчик | ⏳ DO-2 |
| 8 | ✅ **Облачная инфраструктура**: Yandex Cloud, ru-central1 (зафиксировано 04.05.2026, см. Прил. К) | Спринт 0 | Заказчик | ✅ DO-1 |
| 9 | **Брендинг + Figma-макеты** ~8 ключевых экранов (включая UI отсечки 00:00 МСК для тенантов в нечётных часовых поясах) | До спринта 4 | Заказчик + дизайнер | ⏳ Диз-1, P0 |
| 10 | **Формат выгрузки в 1С** — версия 1С (8.3?), формат файла, требуемые поля | До спринта 8 | Заказчик + бухгалтер | ⏳ Б-2, P0 |
| 11 | **Список сотрудников оператора SaaS** с ролями для админки + IP allow-list | До спринта 14 | Заказчик + DevOps | ⏳ |
| 12 | **HTML-прототип дашборда** + образцы Webhook от crm.bp-gr.ru | ✅ Готово | — | ✅ |

## 27.3. Оценка по спринтам

Двухнедельные спринты, команда: 1 backend (Laravel), 1 frontend (Vue), 0.5 DevOps, 0.5 QA, 0.5 PM, 0.25 designer (на review). Считая ~120 часов разработки за спринт.

| Спринт | Цель | Содержание | Демо |
|---|---|---|---|
| **0. Подготовка** | Окружения | Репозиторий, CI/CD, dev-инфраструктура (Docker), staging, выбор облака (DO-1) | — |
| **1. Каркас** | Каркас приложения | Установка Laravel + Vue, аутентификация, базовые миграции, реквизиты юр. лица в БД (Б-1) | Логин работает |
| **2. Мультитенантность + RLS** *(v8.1: расширен)* | Изоляция данных | `tenants`, `users`, middleware tenant-resolver, global scope, **PostgreSQL Row-Level Security (CTO-5)**: 29 политик, 3 роли БД, `TenantAwareJob` для очередей, линтер моделей на CI, `assertTenantIsolation` утилита, тесты изоляции | Регистрация → попадание в свой тенант, RLS защищает даже сырой SQL |
| **3. Webhook-приёмник + интеграция с crm.bp-gr.ru** *(v8.1: расширен)* | Приём данных | Endpoint, Redis Queue, ProcessWebhookJob, **исходящий API к crm.bp-gr.ru для передачи проектов (Ю-2)**, `supplier_lead_costs` запись в той же транзакции, тесты | Проект создаётся у нас → передаётся в crm.bp-gr.ru → лид приходит → списывается баланс + себестоимость |
| **4. Сделки и проекты (CRUD)** | Базовая работа со сделками | Таблица сделок, фильтры, карточка сделки, projects, статусы. **Подключение Figma-макетов (Диз-1)** | Можно работать с лидами в таблице |
| **5. Kanban + теги + комментарии** | Расширенный UI | Kanban-доска, drag-and-drop, теги, шаблоны комментариев | Полноценная работа менеджера |
| **6. Дашборд + базовая аналитика** | Аналитика | KPI-карточки, графики (ApexCharts), последние сделки | Дашборд работает |
| **7. Расширенная аналитика + отчёты** | Углублённая аналитика | Funnel, heatmap, сравнение периодов, отчёты в очередь | Все отчёты работают |
| **8. SaaS-биллинг + жизненный цикл подписок + поставщики** *(v8.1: значительно расширен)* | Документооборот + биллинг | `legal_entities`, `tariff_plans` (4 заглушечных тарифа Биз-1), счета, УПД, нумерация, PDF-генерация. **Жизненный цикл `tariff_subscriptions` 5 состояний (CTO-1)**, **cron `tariffs:apply-scheduled` в 00:00 МСК (CTO-1)**, **поведение при `expired` (CTO-2)**, **`supplier_invoices` админский экран (Ю-2)**, **формат выгрузки в 1С (Б-2)**. ⚠️ Спринт утяжелён, возможно потребуется **8a** или растягивание | Счёт можно выставить, тарифы переключаются в 00:00 МСК, поставщиковая отчётность работает |
| **9. Платёжные шлюзы + chargeback workflow** *(v8.1: расширен)* | Приём денег | Driver-интерфейс, ЮKassa-driver, callback, конвертация ₽ → лиды. **Cron `payments:cancel-stale` (CTO-3)**, **chargeback workflow (Ю-3)** с защитой от минусового баланса | Можно оплатить картой, поздние webhook алертят finance, chargeback автоматически суспендит тенанта |
| **10. Лид-биллинг** | Внутренний баланс | `balance_transactions`, низкий баланс, нулевой баланс, `rejected_deals_log`. Асимметрия с suspended (CTO-2) | Списание/начисление работает |
| **11. Уведомления (push, email, напоминания)** *(v8.1: уточнён)* | Уведомления | Web Push (Service Worker, VAPID), email-templates, `reminders` cron. **JSONB матрица 8×3 (CTO-4)**, экран настроек | Push приходит, напоминания срабатывают, можно настроить каждый тип/канал отдельно |
| **12. Профиль + 2FA + Activity Log** | Безопасность пользователя | 2FA TOTP, recovery codes, активные сессии, журнал действий по сделке | 2FA работает, аудит виден |
| **13. REST API + OpenAPI** | Внешние интеграции | API-keys, эндпоинты, OpenAPI 3.1, rate limiting, документация Swagger | Можно дёргать API из внешней системы |
| **14. Поддержка + чат + админка SaaS + impersonation** *(v8.1: значительно расширен)* | Helpdesk + админка | Чат-виджет (рекомендация: внешний сервис, Биз-5), страница /support. **Полная админка SaaS (Приложение Г)**: тенанты, биллинг, **impersonation workflow с email-кодом (Ю-1)**, админский раздел поставщиков (§4.9), four-eyes для операций > 50K, `pd_subject_requests` workflow. **Юридические документы (Ю-5) с учётом реселлерской модели**: оферта, политика, согласие на обработку ПДн, формулировка про impersonation, зеркальные гарантии (Ю-8) | Поддержка работает, админ может зайти как клиент по коду |
| **15. Прод-стенд + бэкапы + мониторинг** | Готовность к запуску | Деплой prod, WAL-G, Prometheus + Grafana, Sentry, healthcheck, алерты. **Уведомление в Роскомнадзор подано (Ю-4)** | Прод стоит, метрики идут |
| **16. Stabilization + UAT** | Финал | Багфиксы, нагрузочные тесты под 1000 RPS, юзабилити-тестирование, документация для клиентов | Готов к открытию |

**Итого:** 16 спринтов = 32 недели = ~7.5 месяцев календарного времени, с одной командой.

**⚠️ v8.1: уточнённая нагрузка спринтов:**

- **Спринт 2** утяжелён за счёт RLS (CTO-5) — +1–2 дня на настройку 3 ролей БД, 34 политик, базового класса `TenantAwareJob`. Реалистично — полный спринт с +20%.
- **Спринт 3** утяжелён за счёт исходящего API к crm.bp-gr.ru и записи `supplier_lead_costs` (Ю-2) — +2–3 дня.
- **Спринт 8** — самый перегруженный: лекарственный набор биллинга (счета, УПД, тарифы), плюс CTO-1 (cron 00:00 МСК + 5 состояний enum + scheduled), плюс CTO-2 (поведение при expired), плюс Ю-2 (поставщиковая админка). **Реалистично — спринт 8 + спринт 8a** (всего 4 недели вместо 2).
- **Спринт 9** утяжелён CTO-3 + Ю-3 — +2–3 дня.
- **Спринт 14** утяжелён Ю-1 (impersonation), Приложение Г админки целиком + юридическим треком — реалистично 2 недели не хватит, **рекомендация — спринт 14 + 14a** (всего 4 недели).

**Скорректированный итог:** 16 + 2 (8a + 14a) = 18 спринтов = 36 недель ≈ **8.5 месяцев** календаря, с командой 1 BE + 1 FE + 0.5 DevOps + 0.5 QA + 0.5 PM.

При выделении большего числа разработчиков (2+ backend, 2+ frontend) — параллелизация спринтов 4–10 → возможное сокращение до 5 месяцев.

## 27.4. Параллельные треки

Некоторые работы могут идти параллельно с основными спринтами:

| Трек | Где идёт | Ответственный |
|---|---|---|
| **Юридические документы** (оферта, политика) | Спринты 1–14 | Юрист заказчика |
| **Брендинг** (логотип, favicon, цвета) | Спринты 1–10 | Дизайнер заказчика |
| **Подключение шлюзов** (ЮKassa, Tinkoff) | Спринты 1–9 | Заказчик + банк |
| **Контентное наполнение** (тексты, FAQ, лендинг) | Спринты 10–16 | Маркетинг заказчика |
| **Уведомление в Роскомнадзор** | Спринты 14–16 | Юрист заказчика |
| **Регистрация юр. лиц** (если новые) | До спринта 8 | Заказчик |

## 27.5. Допущения

Документ исходит из следующих допущений; при их изменении трудозатраты могут измениться:

1. **Один разработчик на стек** (один backend + один frontend). Удвоение команды → ~30% сокращение календаря (не пропорциональное из-за коммуникации).
2. **Не учтена локализация на английский** — все тексты на русском. Английская версия — +1 спринт.
3. **Не учтена мобильная версия** — только адаптивная веб-версия. Нативные iOS/Android приложения — +6-8 спринтов.
4. **Не учтены интеграции с телефонией** (Mango, UIS) — +2 спринта на интеграцию.
5. **Не учтён модуль чата с клиентами** — заложен только встроенный live-chat для нашей поддержки. Чат с конечными покупателями — отдельный модуль.
6. ✅ **Облачная инфраструктура — Yandex Cloud, ru-central1** (зафиксировано 04.05.2026, см. Прил. К). Свой bare-metal — +1 спринт на настройку, не делаем.

## 27.6. Риски и митигация

| Риск | Вероятность | Воздействие | Митигация |
|---|---|---|---|
| Изменение API crm.bp-gr.ru (формат Webhook) | Средняя | Высокое | Версионирование Webhook-приёмника, контракт с crm.bp-gr.ru на оповещение, мониторинг входящего потока |
| Долгая модерация в платёжных шлюзах | Высокая | Высокое (блокирует выпуск) | Подавать документы на ЮKassa в спринте 1, не в спринте 8 |
| Несоответствие 152-ФЗ при аудите Роскомнадзора | Низкая | Очень высокое | Юрист на ранних этапах, сертификация на готовом продукте |
| Утечка данных между тенантами из-за бага | Средняя | Очень высокое | Обязательные тесты изоляции для каждого эндпоинта; PostgreSQL RLS как четвёртый уровень защиты |
| Падение Webhook-приёмника при пиковой нагрузке | Средняя | Высокое | Нагрузочные тесты в спринте 3, авто-скейлинг воркеров, алерты |
| Зависимость от одного шлюза (ЮKassa) | Средняя | Среднее | Сразу подключить 2 шлюза (ЮKassa + Robokassa), переключение по флагу |
| Низкое качество мониторинга → долгие даунтаймы | Средняя | Высокое | Прометей + Grafana + Sentry с самого начала, обязательное healthcheck-эндпоинт, runbook для дежурного |

## 27.7. Дальнейшее развитие (post-MVP)

Идеи на следующие итерации (не входят в эти 16 спринтов):

1. **Множественные пользователи на тенант** + роли (админ / менеджер / руководитель)
2. **Командная работа**: назначение сделок менеджерам, KPI-отчёты
3. **CSV-импорт** исторических данных (модуль уже подготовлен в схеме БД)
4. **Outbound Webhook** (наш аналог отправляет в AmoCRM/Битрикс24)
5. **Интеграции с телефонией**: Mango Office, UIS, Скорозвон
6. **Email/SMS-кампании** по сделкам
7. **Автоматизации**: правила «если X, то Y» (статус → авто-задача, тег → авто-комментарий)
8. **Кастомные домены** клиентов (`crm.client.com` → наш сервер)
9. **Тёмная тема**
10. **Английская локализация**
11. **Мобильные приложения** (React Native / Flutter)
12. **AI-ассистент**: автоматическая категоризация, подсказки менеджеру

---

## Заключение

Документ зафиксирован в версии **8.4** на 06.05.2026 (финал, все 13 разделов плана v8.4 переписаны; см. блок «Что нового в v8.4» в шапке). Из 11 P0-блокеров сессии v8.0 → v8.1 закрыто 10. Все архитектурные решения согласованы, противоречия разрешены, технические риски оценены. Документ готов служить полным техническим заданием для разработки.

**Следующие шаги:**

1. Получить от заказчика последний оставшийся **P0-артефакт — Б-1** (реквизиты юр. лица; ждёт регистрации ООО). От него зависят также Диз-3 (логотип с реальным юр. именем), DO-2 (домен `liderra.ru` + SSL), DO-4 (Notion private + 1Password DevOps account). Все остальные P0/P1 — закрыты или у Claude (Диз-1: HTML-прототипы, 3/8 готово).
2. Закрыть Б-1 → разблокировать Диз-3, DO-2, DO-4.
3. Финальная редактура юристом приложений Ж/З (оферта, уведомление РКН).
4. `composer create-project laravel/laravel app` → старт фазы 1 (backend), процедура §10.1 Прил. Н.
5. Спринт 0 — настройка инфраструктуры Yandex Cloud (DO-1 ✅).

---

# 28. Приложения

В **v8.3** пакет документации состоит из основного narrative (этот файл) и **11 приложений**, вынесенных в отдельные файлы для удобства работы команды. Каждое приложение — самостоятельный документ со своей внутренней структурой, который можно открыть рядом с кодом или передать конкретному специалисту (бухгалтеру, юристу, дизайнеру, дежурному).

> **📜 ИСТОРИЧЕСКАЯ ЗАПИСЬ (срез на 04.05.2026 до v8.3++ optimized).** Таблица ниже отражает состояние архива на момент выпуска narrative v8.3 (04.05.2026) и сохраняется без переписывания **намеренно** — для прослеживаемости решений (§4.4 правил работы Claude). Имена файлов, размеры и счётчики OPEN-* в этой таблице **устарели** относительно текущего состояния архива. Актуальное состояние — `README_АРХИВ_v8_3.md` v8.3++ optimized, актуальная сводка вопросов — `Открытые_вопросы_v8_3.md` v1.7. См. также ⚠-врезку под таблицей с конкретным маппингом «было → стало».

| Шифр | Файл | Размер | Назначение | Кому полезно |
|------|------|--------|------------|--------------|
| **А** | `schema.sql` | 90 КБ, 1523 строки | Единая БД-схема: 47 таблиц + 12 партиций, 81 FK, 67 индексов, 33 CHECK, 29 RLS-политик, 3 роли БД, 4 заглушечных тарифа. **В v8.3 потребуются миграции:** `deals.processing_restricted` (OPEN-Д-1), `incidents_log` (OPEN-Д-5/И-1), `crm_connections`/`crm_field_mappings` (OPEN-И-11), `report_jobs.retry_count`+`parent_report_job_id` (CTO-6), 2FA на всех тарифах (Биз-9). | Backend-разработчик, DevOps |
| **Б** | `ER_диаграмма_v8_1.md` | 33 КБ, 963 строки | 6 доменных Mermaid-подграфов + flowchart-карта между доменами + текстовый справочник 81 FK + рейтинг таблиц | Архитектор, новые разработчики при онбординге |
| **В** | `State_machines_v8_1.md` | 23 КБ, 349 строк | 4 машины состояний в Mermaid + триггеры переходов: `saas_transactions`, `tariff_subscriptions` (5 состояний), `report_jobs`, `refund_requests` | Backend-разработчик биллинга, QA для тест-кейсов |
| **Г** | `Админка_SaaS_v8_1_драфт.md` | 48 КБ, 453 строки | Полная спецификация админки SaaS: ролевая модель, экраны, workflow impersonation (Ю-1), раздел поставщиков (Ю-2), chargeback (Ю-3) | Backend + Frontend разработчики админки, продакт-менеджер |
| **Д** | `Workflow_pd_subject_requests_v8_2.md` | ~64 КБ, ~660 строк | **Workflow обращений субъектов ПДн (152-ФЗ ст. 14, 21):** state machine из 6 статусов, временна́я раскадровка внутри 30 дней, процедура проверки личности (4 способа), фаза уведомления вторичных операторов (Ю-2), 7 готовых шаблонов писем, UI-экраны для админки. **v8.2:** OPEN-Д-1, OPEN-Д-5 закрыты. | Compliance-админ, юрист, разработчик соответствующего модуля |
| **Е** | `Открытые_вопросы_v8_3.md` | ~36 КБ, ~290 строк | Сводный workplan для заказчика. **v1.4:** 30 решений сессии 04.05.2026 v2 зафиксированы. Истинно-открытых вопросов: **0**. Истинный блокер: только Б-1. | Заказчик, юрист, бухгалтер, бизнес, дизайнер, DevOps |
| **Ж** | `Oferta_i_Politika_v8_1.md` | 107 КБ, 834 строки | **Структурный шаблон публичной оферты и Политики конфиденциальности.** Часть А: Оферта (14 разделов). Часть Б: Политика конф. (12 разделов). Часть В: 20 открытых вопросов для юриста. Структурно закрывает Ю-5, Ю-2-доп, Ю-3-юр, Ю-8 | Юрист (для редактуры), бизнес-владелец, продакт |
| **З** | `Uvedomlenie_RKN_v8_1.md` | 60 КБ, 462 строки | **Шаблон уведомления Роскомнадзора** по форме Приказа РКН №180 от 28.10.2022. 11 разделов, **матрица 7 целей** обработки, 14 открытых вопросов. Структурно закрывает Ю-4. **В v8.3** пункт 9 ЦОД — Yandex Cloud, ru-central1. | Юрист, бизнес-владелец (подача), DevOps |
| **И** | `Runbook_ekspluatatsii_v8_2.md` | ~50 КБ, ~647 строк | **Runbook эксплуатации** для on-call дежурного: 9 типовых инцидентов с диагностикой, командами, эскалацией. **v8.2:** добавлена DDL `incidents_log` в §13.1, OPEN-И-1 и OPEN-И-11 закрыты. | Дежурный разработчик (`dev_oncall`), DevOps, тех. директор |
| **К** | `Vybor_oblaka_v8_3.md` | ~30 КБ, ~310 строк | **Сравнение российских облачных провайдеров.** Закрывает DO-1: выбран Yandex Cloud (план A). 8 решений по К-1..К-8. | Заказчик, DevOps, CTO |
| **Л** | `Brandbook_Liderra_v1_0.md` + 5 SVG | ~17 КБ + 3 КБ SVG | **Брендбук Лидерра.** Закрывает Диз-2: название, логотип («Поток», 3 круга), палитра, типографика, CSS-переменные, готовая Vuetify-тема. | Дизайнер, frontend-разработчик, маркетолог |

**Где взять:** все приложения — в той же директории, что и этот основной файл.

> **⚠ Важно для читателя на 05.05.2026 (v8.3++ optimized):** таблица выше отражает состояние архива v8.3 от 04.05.2026 и оставлена как **историческая запись для прослеживаемости решений**. В актуальной версии архива (v8.3++ optimized) структура изменена в рамках оптимизации:
>
> - **Прил. Б + Прил. В** объединены в файл `Приложение_Б_В_БД_диаграммы_v8_3.md` (Часть Б — ER, Часть В — state machines).
> - **Прил. Е** обновлено до v1.6 (`Открытые_вопросы_v8_3.md`) — добавлены разделы 11, 12 (Биз-10..16).
> - **Прил. Ж** — `Oferta_i_Politika_v8_2.md` (не v8_1).
> - **Прил. З** — `Uvedomlenie_RKN_v8_2.md` (не v8_1).
> - **Прил. И** — `Runbook_ekspluatatsii_v8_2.md` (без изменений).
> - **Прил. Л (Брендбук)** — теперь `brandbook.md` v1.1 (не `Brandbook_Liderra_v1_0.md`); 5 SVG-файлов логотипа интегрированы inline в § 9.1–9.5.
> - **Прил. М (новое в v8.3+, обновлено в v8.3++)** — `Analiz_originala_v8_3.md` v1.1 — Анализ оригинала crm.bp-gr.ru.
> - **schema.sql** обновлена до v8.3 (было v8.1; через v8.2 — см. `CHANGELOG_schema.md`).
> - Дополнительно: `CHANGELOG_schema.md` (объединённый журнал v8.2 + v8.3), `Аудит_partii_12_15_originala_v8_3.md` (аудит партий 12–15), `Объединённый_конспект.md` (свод по 6 исходным конспектам сессий 03–05.05; заменил `Konspekt_sessii_05_05_2026.md`, который удалён в шаге «v8.3++ optimized + правила Claude»).
>
> Полный актуальный состав — см. `README_АРХИВ_v8_4.md` v8.4.

**Версионирование v8.3:**

- Без изменений с v8.1: А (schema.sql — нужны новые миграции, см. выше), Б (ER), В (state machines), Г (админка), Ж (оферта — отдельный трек юр. редактуры), З (уведомление РКН — пункт 9 уточнён в v8.3 narrative).
- Обновлены до v8.2: Д (`processing_restricted`, `incidents_log`), И (`incidents_log` DDL, OPEN-И-11).
- Обновлено до v1.4: Е (Открытые вопросы).
- Новые в v8.3: К (Прил. К — выбор облака), Л (Прил. Л — Брендбук).

**Сводка по адресатам:**

| Кому | Приоритетные приложения |
|------|------------------------|
| Backend-разработчик | А (схема), В (state machines), Г (админка), И (runbook) |
| Frontend-разработчик | Г (UI админки), А (модели для типов) |
| DevOps | А (миграции, RLS-роли), И (runbook), З (для пункта 9 — местонахождение БД), главный файл § 23 |
| Архитектор / новый разработчик | Б (ER), главный файл § 1–7 |
| QA | В (state machines для тест-кейсов), Г (сценарии админки), И (для chaos-тестов) |
| Юрист | **Ж** (оферта + политика — главный документ), **З** (уведомление РКН), Д (workflow обращений), Е (юр. вопросы) |
| Бухгалтер | Е (Б-1, Б-2 от заказчика), главный файл § 20 |
| Compliance-админ SaaS | Д (workflow обращений), Г §4 (роли) |
| Бизнес-владелец / заказчик | Е (workplan), Ж (оферта для согласования), З (подача в РКН) |
| Дизайнер | Е (Диз-1) |

> Шифры исчерпаны для основного пакета v8.2: А, Б, В, Г, Д, Е, Ж, З, И. Дальнейшие приложения (если понадобятся в v8.3+) — К, Л, М...

---

*Конец документа CRM_bp-gr_Инструкция_v8_5.md (07.05.2026, v8.5 — реализация 27 решений аудита C). Финал v8.4 был 06.05.2026.*
