# Приложение Б+В — ER-диаграмма + State machines (v8.3)

**Назначение:** консолидированный документ по структуре БД и состояниям. Раньше был разнесён на два файла (Прил. Б — `ER_диаграмма_v8_1.md`, Прил. В — `State_machines_v8_1.md`); объединён в рамках оптимизации архива v8.3++ optimized 05.05.2026 как тесно связанный технический артефакт (один читатель — CTO/backend, оба источника — `schema.sql`).

**Состав:**

- **Часть Б** — ER-диаграмма (Mermaid `erDiagram` × 6 доменов + flowchart-карта + текстовый справочник 81 FK + рейтинг таблиц + дельта к v8.0).
- **Часть В** — State machine диаграммы (Mermaid `stateDiagram-v2` × 4: `saas_transactions`, `tariff_subscriptions`, `report_jobs`, `refund_requests`).

**Источник фактов для обеих частей:** `db/schema.sql` (актуальная — **v8.5**: 54 логических таблицы + 12 партиций, 91 индекс, 35 RLS-политик с WITH CHECK на 2, 35 ENABLE RLS, 4 роли, 12 триггеров, 4 функции). При расхождении ER/state machines с этой версией — приоритет за `schema.sql`.

**Замечания по версионированию:**

- ER-диаграмма (Часть Б) исходно от v8.1 (`ER_диаграмма_v8_1.md`). Изменения схемы v8.2/v8.3/v8.4/v8.5 **не отражены в Mermaid-диаграммах** — перерисовка отложена до спринта 0/1, когда схема стабилизируется. До этого момента — **читать ER + сверяться с актуальной `db/schema.sql` v8.5 + блоком «Дельта v8.2 → v8.5» ниже** + `db/CHANGELOG_schema.md` (записи Y, Z, A, B) + `Прил_М_Analiz_originala_v8_3.md` §3.1, §3.5.
- State machines (Часть В) от v8.1 — изменений в state machines между v8.1 и v8.5 не было (один новый workflow v8.5 — pending-state у `impersonation_tokens` для two-person approval — описан текстом в narrative §22.13.7 без отдельной диаграммы; добавление в Часть В возможно в спринте 1).

**Связано:** `db/schema.sql` v8.5, `db/CHANGELOG_schema.md` (записи Y=v8.5, Z=v8.4), `Прил_М_Analiz_originala_v8_3.md` v1.1 §3, `CRM_bp-gr_Инструкция_v8_5.md` §7.1.

---

## Дельта v8.2 → v8.5 (не перерисовано в Mermaid, читать вместе с диаграммами v8.1 ниже)

Поскольку Mermaid-диаграммы фиксируют срез v8.1 (47 таблиц, 81 FK), для актуальной картины их нужно дополнять перечнем изменений v8.2..v8.5. Ниже — компактная сводка, что нужно мысленно добавить к диаграммам.

### v8.2 (04.05.2026) — реселлерская модель + 152-ФЗ

**Новые таблицы (+5):** `suppliers` (B1/B2/B3), `project_suppliers` (M2M), `project_limit_adjustments`, `pd_subject_requests`, `incidents_log`. **Новые колонки в существующих:** `users.notification_preferences JSONB` (8×3), `tenants.chargeback_unrecovered_rub`, `pd_subject_requests.processing_restricted` (OPEN-Д-1 152-ФЗ ст.21), `refund_requests.source`. **+`projects` 6 полей:** `daily_limit_target`, `effective_daily_limit_today`, `region_mask`, `region_mode`, `delivery_days_mask`, и др.

### v8.3 (05.05.2026) — аудит партий 12–15

**Переписана `reminders`:** `created_by` (вместо `user_id`), `assignee_id NULL`, `completed_at` (вместо `is_done`). **Удалены `deals.reminder_text/reminder_at/idx_deals_reminder`** — множественные напоминания через таблицу `reminders`. **+`suppliers` 5 capabilities:** `channel`, `supports_sender_name/keyword/csv_upload/domains_list`. **+`tenants.desired_daily_numbers`** (Биз-16, сигнал саппорту).

### v8.4 (06.05.2026) — outbound webhook

**Новые таблицы (+2):** `outbound_webhook_subscriptions`, `outbound_webhook_deliveries`. **+RLS-политика для `deal_tag_pivot`** (hotfix через JOIN на `deal_tags.tenant_id`). **Метрики:** 51→53 табл, 81→86 инд, 31→34 RLS на 34 защищённых таблицах.

### v8.5 (07.05.2026) — реализация 27 решений аудита C

**Новые таблицы (+1):**

- **`project_user_assignments(project_id, user_id, skills JSONB, is_active)`** (CTO-16) — пул менеджеров проекта для `assignment_strategy IN ('round_robin','least_loaded')` Post-MVP. RLS через JOIN на `projects.tenant_id` (USING + WITH CHECK). PK = `(project_id, user_id)`. Индекс `idx_project_user_assignments_user(user_id) WHERE is_active`.

**Новые колонки (+26):**

| Таблица | Колонки | Источник |
|---|---|---|
| `suppliers` | `quality_score NUMERIC(3,2) DEFAULT 1.00` | Биз-22 |
| `saas_admin_users` | `sso_provider VARCHAR(32) DEFAULT 'yandex360'`, `is_break_glass BOOLEAN` | OPEN-И-13 |
| `impersonation_tokens` | `second_approver_id BIGINT REFERENCES saas_admin_users(id)`, `second_approval_at TIMESTAMPTZ` | CTO-15 + Ю-9 |
| `tenants` | `api_key_limit INT DEFAULT 5 CHECK (1..10)`, `telegram_bot_token TEXT` | OPEN-И-19, Биз-20 |
| `projects` | `assignment_strategy VARCHAR(32) DEFAULT 'manual'`, `ttfr_target_minutes INT DEFAULT 15` | Биз-17, Биз-18 |
| `users` | `telegram_user_id BIGINT` | Биз-20 |
| `deals` | `assigned_at TIMESTAMPTZ`, `escalated_count INT DEFAULT 0`, `duplicate_of_id BIGINT` (без FK), `utm_source/utm_medium/utm_campaign/utm_content VARCHAR(100)`, `region_code VARCHAR(8)`, `city VARCHAR(100)`, `time_in_form_seconds INT`, `lead_score NUMERIC(5,2)` | OPEN-И-25, Биз-19, CTO-14, Биз-23, Биз-22 |
| `auth_log`, `activity_log`, `pd_processing_log`, `saas_admin_audit_log`, `balance_transactions` | `log_hash BYTEA` | OPEN-И-15 |

**ALTER (1):** `api_keys.expires_at SET NOT NULL DEFAULT NOW() + INTERVAL '365 days'` (OPEN-И-17 + OPEN-И-19; миграция backfill всем NULL-ключам).

**Новые индексы (+5):** `(tenant_id, utm_source) WHERE NOT NULL`, `(tenant_id, region_code) WHERE NOT NULL`, `(duplicate_of_id) WHERE NOT NULL`, `(tenant_id, assigned_at) WHERE status NOT IN ('closed','rejected')`, `idx_project_user_assignments_user(user_id) WHERE is_active`.

**Новые политики RLS (+1):** `tenant_isolation` на `project_user_assignments` через JOIN на `projects.tenant_id` (USING + WITH CHECK).

**WITH CHECK добавлен к существующим политикам (+2):** `tenant_isolation` на `deal_tag_pivot` (через `deal_tags.tenant_id`) и `saas_invoice_items` (через `saas_invoices.tenant_id`). До v8.5 защита была только на USING — INSERT мог пройти при `tag_id`/`invoice_id` чужого тенанта.

**REVOKE ALL от `crm_app_user` (+6 таблиц):** `saas_admin_users`, `saas_admin_sessions`, `saas_admin_audit_log`, `incidents_log`, `pd_subject_requests`, `impersonation_tokens` (defense-in-depth, OPEN-И-14).

**Новые роли (+1):**

- **`crm_audit_writer`** — INSERT-only на 5 audit-таблицах. UPDATE/DELETE заблокированы и триггерами, и REVOKE'ом. Application пишет в audit под этой ролью через temporary `SET ROLE`.

**Новые функции (+4):**

- **`audit_chain_hash() RETURNS TRIGGER`** (OPEN-И-15) — заполняет `NEW.log_hash = sha256(prev_hash || NEW::text)` перед INSERT в audit-таблицы.
- **`audit_block_mutation() RETURNS TRIGGER`** (OPEN-И-15) — `RAISE EXCEPTION` при UPDATE/DELETE.
- **`report_jobs_log_export() RETURNS TRIGGER`** (OPEN-И-20) — auto-INSERT в `pd_processing_log` action='exported' при создании `report_jobs`.
- **`calc_lead_score() RETURNS TRIGGER`** (Биз-22) — `lead_score = supplier.quality_score × (time_in_form_seconds / 60)`, clamped в `[0, 99.99]`.

**Новые триггеры (+12):**

- **5 пар** на audit-таблицах (`auth_log`, `activity_log`, `pd_processing_log`, `saas_admin_audit_log`, `balance_transactions`): `BEFORE INSERT` (hash chain) + `BEFORE UPDATE OR DELETE` (block mutation) = 10 триггеров.
- **1 на `report_jobs`:** `AFTER INSERT` → `pd_processing_log`.
- **1 на `deals`:** `BEFORE INSERT OR UPDATE OF time_in_form_seconds, project_id` → расчёт `lead_score`.

**Закомментированный задел Post-MVP:**

- **`call_recordings(tenant_id, deal_id, call_started_at, duration_sec, direction, recording_path, transcript)`** (OPEN-И-26) — задел под Биз-12 (телефония + транскрипция), 10 строк закомментированного DDL в `db/schema.sql` секция 17. Активация — однострочный uncomment + миграция при первом клиентском запросе Post-MVP.

**Метрики schema v8.5:** 54 таблицы + 12 партиций, 91 индекс, 35 RLS-политик (из них 2 с WITH CHECK обновлены), 35 ENABLE RLS, **4 роли** (crm_app_user / crm_admin_user / crm_migrator / crm_audit_writer), **12 триггеров**, **4 функции**.

**Новые межсущностные связи v8.5 (для будущей перерисовки ER):**

- `project_user_assignments → projects (project_id)` + `→ users (user_id)` ON DELETE CASCADE (новая M2M).
- `impersonation_tokens.second_approver_id → saas_admin_users(id)` (новая FK).
- `deals.duplicate_of_id` (self-reference, **без FK** — партиционированная таблица).

> **Полный DDL v8.5** — `db/schema.sql`. **Полный CHANGELOG v8.4 → v8.5** — `db/CHANGELOG_schema.md` запись Y. **Narrative-обоснование v8.5** — `CRM_bp-gr_Инструкция_v8_5.md` блок «Что нового в v8.5» + §10.8/§12.5.5/§14.8/§17.9/§19.10.11–12/§22.13/§23.10.11. **Operational runbook v8.5** — `Runbook_ekspluatatsii_v8_2.md` Часть Г (9 процедур).

---

# Часть Б — ER-диаграмма БД

## ER-диаграмма БД — SaaS-аналог crm.bp-gr.ru (v8.1)

**Источник:** `schema.sql` v8.1 (1523 строки, 47 уникальных таблиц + 12 партиций по 6 на `deals` и `supplier_lead_costs`).
**Назначение:** закрыть пробел 🔴 №3 из конспекта анализа v8.0 — раздел 7.2 v8.0 содержит неполную ASCII-диаграмму, в которой не отражены ~11 таблиц.
**Формат:** Mermaid `erDiagram` (рендерится в GitHub, GitLab, VSCode + Markdown Preview Mermaid Support, Obsidian, Notion). Дублируется текстовым справочником связей — на случай отсутствия рендера.
**Версия:** 1.1 (после P0-блока). Изменения относительно 1.0:

- Добавлен **Блок #6** «Себестоимость и поставщики» (Ю-2): `supplier_lead_costs`, `supplier_invoices`.
- В **Блок #5** добавлена `impersonation_tokens` (Ю-1).
- Обновлены `tariff_subscriptions` (5 состояний enum + новый индекс), `users` (JSONB вместо BOOLEAN), `tenants` (chargeback_unrecovered_rub), `refund_requests` (source).
- Обновлены счётчики FK: 81 (было 73).
- Обновлены счётчики таблиц: 47 (было 44).

> **Замечание о масштабе.** Один `erDiagram` на 47 таблиц превращается в нечитаемый «спутанный шар». Поэтому диаграмма разбита на **6 доменных подграфов** (тенанты и пользователи, сделки, биллинг SaaS, лид-биллинг и логи, админка и 152-ФЗ, **поставщики и себестоимость**). Связи между доменами явно перечислены в финальном текстовом справочнике.
>
> **Замечание о FK без REFERENCES.** В схеме есть три случая «осознанно отсутствующего FK»:
>
> 1. **`*.deal_id`** в `activity_log`, `reminders`, `webhook_log`, `deal_tag_pivot`, **`supplier_lead_costs`** — `deals` партиционирована, FK невозможен.
> 2. **`balance_transactions.related_*` и `tariff_subscriptions.created_by_*`** — полиморфные связи. FK невозможен принципиально.
> 3. **`auth_log.user_id` / `auth_log.saas_admin_user_id`** — взаимоисключающие FK через CHECK constraint. На диаграмме показаны оба, но в каждой строке заполнено ровно одно из двух.

---

### 1. Тенанты, пользователи, безопасность

```mermaid
erDiagram
    tariff_plans {
        bigserial id PK
        varchar code UK
        varchar billing_model
        decimal price_per_lead
        decimal price_monthly
        int included_leads
        jsonb limits
        jsonb features
    }

    tenants {
        bigserial id PK
        varchar subdomain UK
        varchar webhook_token UK
        bigint current_tariff_id FK
        varchar status
        decimal balance_rub
        int balance_leads
        decimal chargeback_unrecovered_rub
        boolean is_trial
        timestamptz last_activity_at
        timestamptz deleted_at
    }

    tenant_custom_domains {
        bigserial id PK
        bigint tenant_id FK
        varchar domain UK
        varchar ssl_status
    }

    users {
        bigserial id PK
        bigint tenant_id FK
        varchar email
        varchar password_hash
        varchar totp_secret
        boolean totp_enabled
        jsonb notification_preferences
        boolean sound_enabled
        timestamptz email_verified_at
        timestamptz deleted_at
    }

    user_recovery_codes {
        bigserial id PK
        bigint user_id FK
        varchar code_hash
        timestamptz used_at
    }

    user_sessions {
        bigserial id PK
        bigint user_id FK
        varchar token_hash UK
        inet ip_address
        timestamptz last_active_at
        timestamptz expires_at
    }

    email_verifications {
        bigserial id PK
        bigint user_id FK
        varchar token UK
        timestamptz expires_at
        timestamptz verified_at
    }

    password_resets {
        varchar email PK
        varchar token
        timestamptz created_at
    }

    api_keys {
        bigserial id PK
        bigint tenant_id FK
        bigint user_id FK
        varchar key_hash UK
        varchar key_prefix
        jsonb scopes
        timestamptz expires_at
    }

    push_subscriptions {
        bigserial id PK
        bigint tenant_id FK
        bigint user_id FK
        text endpoint
        text p256dh
        text auth
    }

    tariff_plans ||--o{ tenants : "current_tariff_id"
    tenants ||--o{ tenant_custom_domains : "tenant_id"
    tenants ||--o{ users : "tenant_id"
    users ||--o{ user_recovery_codes : "user_id"
    users ||--o{ user_sessions : "user_id"
    users ||--o{ email_verifications : "user_id"
    tenants ||--o{ api_keys : "tenant_id"
    users ||--o{ api_keys : "user_id"
    tenants ||--o{ push_subscriptions : "tenant_id"
    users ||--o{ push_subscriptions : "user_id"
```

> Изменения относительно v1.0: `users.notification_preferences` (JSONB) и `sound_enabled` (BOOLEAN) — заменили четыре раздельных BOOLEAN после CTO-4. `tenants.chargeback_unrecovered_rub` — добавлено после Ю-3.

---

### 2. Сделки, проекты, статусы воронки

```mermaid
erDiagram
    tenants {
        bigserial id PK
    }

    users {
        bigserial id PK
        bigint tenant_id FK
    }

    lead_statuses {
        varchar slug PK
        varchar name_ru
        boolean is_system
        int sort_order
        varchar color_hex
    }

    tenant_status_overrides {
        bigint tenant_id PK_FK
        varchar status_slug PK_FK
        varchar custom_name
    }

    projects {
        bigserial id PK
        bigint tenant_id FK
        varchar name
        varchar tag
        varchar type
        boolean is_active
    }

    deals {
        bigserial id PK
        bigint tenant_id
        bigint source_crm_id
        bigint project_id
        varchar phone
        jsonb phones
        varchar status
        varchar contact_name
        text comment
        timestamptz reminder_at
        bigint manager_id
        timestamptz received_at PK
    }

    deal_tags {
        bigserial id PK
        bigint tenant_id FK
        varchar name
        varchar color_hex
        bigint created_by FK
    }

    deal_tag_pivot {
        bigint deal_id PK
        bigint tag_id PK_FK
        bigint tagged_by FK
    }

    comment_templates {
        bigserial id PK
        bigint tenant_id FK
        bigint user_id FK
        varchar name
        text text
        int usage_count
    }

    activity_log {
        bigserial id PK
        bigint tenant_id FK
        bigint user_id FK
        bigint deal_id
        varchar event
        text old_value
        text new_value
        jsonb context
    }

    reminders {
        bigserial id PK
        bigint tenant_id FK
        bigint user_id FK
        bigint deal_id
        timestamptz remind_at
        boolean is_done
        boolean is_sent
    }

    import_log {
        bigserial id PK
        bigint tenant_id FK
        bigint user_id FK
        varchar filename
        int rows_total
        varchar status
    }

    tenants ||--o{ projects : "tenant_id"
    tenants ||--o{ tenant_status_overrides : "tenant_id"
    lead_statuses ||--o{ tenant_status_overrides : "status_slug"
    tenants ||--o{ deals : "tenant_id (no FK, partitioned)"
    projects ||--o{ deals : "project_id (no FK, partitioned)"
    users ||--o{ deals : "manager_id (no FK, partitioned)"
    tenants ||--o{ deal_tags : "tenant_id"
    users ||--o{ deal_tags : "created_by"
    deal_tags ||--o{ deal_tag_pivot : "tag_id"
    deals ||--o{ deal_tag_pivot : "deal_id (no FK)"
    users ||--o{ deal_tag_pivot : "tagged_by"
    tenants ||--o{ comment_templates : "tenant_id"
    users ||--o{ comment_templates : "user_id"
    tenants ||--o{ activity_log : "tenant_id"
    users ||--o{ activity_log : "user_id"
    deals ||--o{ activity_log : "deal_id (no FK)"
    tenants ||--o{ reminders : "tenant_id"
    users ||--o{ reminders : "user_id"
    deals ||--o{ reminders : "deal_id (no FK)"
    tenants ||--o{ import_log : "tenant_id"
    users ||--o{ import_log : "user_id"
```

> Без изменений относительно v1.0.

---

### 3. SaaS-биллинг (платежи, счета, УПД)

```mermaid
erDiagram
    legal_entities {
        bigserial id PK
        varchar code UK
        varchar legal_form
        varchar inn
        varchar kpp
        varchar vat_mode
        boolean is_default
    }

    payment_gateways {
        bigserial id PK
        varchar code UK
        varchar driver
        bigint legal_entity_id FK
        text config
        boolean is_active
        jsonb accepts_methods
    }

    document_sequences {
        bigint legal_entity_id PK_FK
        varchar document_type PK
        int year PK
        int last_number
    }

    tariff_plans {
        bigserial id PK
        varchar code UK
    }

    tariff_subscriptions {
        bigserial id PK
        bigint tenant_id FK
        bigint tariff_plan_id FK
        timestamptz started_at
        timestamptz expires_at
        varchar status
        timestamptz cancelled_at
        timestamptz superseded_at
        jsonb custom_overrides
        varchar created_by_type
        bigint created_by_id
    }

    tenants {
        bigserial id PK
        bigint current_tariff_id FK
    }

    saas_invoices {
        bigserial id PK
        bigint tenant_id FK
        bigint legal_entity_id FK
        varchar invoice_number
        decimal amount_total
        bigint transaction_id FK
        varchar status
        timestamptz expires_at
    }

    saas_invoice_items {
        bigserial id PK
        bigint invoice_id FK
        varchar name
        decimal price
        decimal amount_total
    }

    saas_upd_documents {
        bigserial id PK
        bigint tenant_id FK
        bigint legal_entity_id FK
        varchar upd_number
        varchar upd_function
        bigint correction_for FK
        bigint invoice_id FK
        bigint transaction_id FK
        varchar status
    }

    saas_transactions {
        bigserial id PK
        bigint tenant_id FK
        varchar type
        decimal amount_rub
        bigint gateway_id FK
        varchar gateway_payment_id
        uuid gateway_idempotence_key UK
        bigint legal_entity_id FK
        bigint invoice_id FK
        bigint upd_id FK
        varchar status
        varchar failure_reason
    }

    refund_requests {
        bigserial id PK
        bigint tenant_id FK
        bigint transaction_id FK
        decimal requested_amount_rub
        varchar source
        varchar status
        bigint admin_user_id FK
        bigint correction_upd_id FK
    }

    legal_entities ||--o{ payment_gateways : "legal_entity_id"
    legal_entities ||--o{ document_sequences : "legal_entity_id"
    legal_entities ||--o{ saas_invoices : "legal_entity_id"
    legal_entities ||--o{ saas_upd_documents : "legal_entity_id"
    legal_entities ||--o{ saas_transactions : "legal_entity_id"

    tariff_plans ||--o{ tariff_subscriptions : "tariff_plan_id"
    tariff_plans ||--o{ tenants : "current_tariff_id"
    tenants ||--o{ tariff_subscriptions : "tenant_id"

    tenants ||--o{ saas_invoices : "tenant_id"
    tenants ||--o{ saas_upd_documents : "tenant_id"
    tenants ||--o{ saas_transactions : "tenant_id"
    tenants ||--o{ refund_requests : "tenant_id"

    saas_invoices ||--o{ saas_invoice_items : "invoice_id"
    saas_invoices ||--o{ saas_upd_documents : "invoice_id"
    saas_invoices ||--o{ saas_transactions : "invoice_id"

    saas_transactions ||--o{ saas_invoices : "transaction_id"
    saas_transactions ||--o{ saas_upd_documents : "transaction_id"
    saas_transactions ||--o{ refund_requests : "transaction_id"

    payment_gateways ||--o{ saas_transactions : "gateway_id"

    saas_upd_documents ||--o{ saas_upd_documents : "correction_for (self)"
    saas_upd_documents ||--o{ saas_transactions : "upd_id"
    saas_upd_documents ||--o{ refund_requests : "correction_upd_id"
```

> Изменения относительно v1.0:
>
> - `tariff_subscriptions.status` — теперь 5 значений (`scheduled`, `active`, `superseded`, `expired`, `cancelled`), добавлено поле `superseded_at` (CTO-1).
> - `saas_transactions.failure_reason` — формализованный enum в комментарии DDL (CTO-3).
> - `refund_requests.source` — `admin_initiated` или `external_chargeback` (Ю-3).

---

### 4. Лид-биллинг и логи источника данных

```mermaid
erDiagram
    tenants {
        bigserial id PK
    }

    users {
        bigserial id PK
    }

    deals {
        bigserial id PK
        timestamptz received_at PK
    }

    saas_admin_users {
        bigserial id PK
    }

    balance_transactions {
        bigserial id PK
        bigint tenant_id FK
        varchar type
        decimal amount_rub
        int amount_leads
        decimal balance_rub_after
        int balance_leads_after
        varchar related_type
        bigint related_id
        bigint user_id FK
        bigint admin_user_id FK
    }

    webhook_log {
        bigserial id PK
        bigint tenant_id FK
        jsonb raw_payload
        timestamptz received_at
        timestamptz processed_at
        bigint deal_id
        text error
    }

    failed_webhook_jobs {
        bigserial id PK
        bigint tenant_id FK
        bigint webhook_log_id FK
        jsonb raw_payload
        text exception
        int retry_count
        timestamptz failed_at
        bigint retried_by FK
        timestamptz resolved_at
    }

    rejected_deals_log {
        bigserial id PK
        bigint tenant_id FK
        bigint webhook_log_id FK
        varchar reason
        jsonb payload
    }

    failed_jobs {
        bigserial id PK
        uuid uuid UK
        text connection
        text queue
        text payload
        text exception
    }

    report_jobs {
        bigserial id PK
        bigint tenant_id FK
        bigint user_id FK
        varchar type
        jsonb parameters
        varchar status
        varchar file_path
    }

    tenants ||--o{ balance_transactions : "tenant_id"
    users ||--o{ balance_transactions : "user_id (initiator)"
    saas_admin_users ||--o{ balance_transactions : "admin_user_id (manual_adjustment)"
    deals ||--o{ balance_transactions : "related_id when related_type=Deal (poly, no FK)"

    tenants ||--o{ webhook_log : "tenant_id"
    deals ||--o{ webhook_log : "deal_id (no FK)"

    tenants ||--o{ failed_webhook_jobs : "tenant_id"
    webhook_log ||--o{ failed_webhook_jobs : "webhook_log_id"
    saas_admin_users ||--o{ failed_webhook_jobs : "retried_by"

    tenants ||--o{ rejected_deals_log : "tenant_id"
    webhook_log ||--o{ rejected_deals_log : "webhook_log_id"

    tenants ||--o{ report_jobs : "tenant_id"
    users ||--o{ report_jobs : "user_id"
```

> Изменения: `balance_transactions.admin_user_id` теперь явно в диаграмме (для `manual_adjustment` и новых типов `chargeback_writedown`, `chargeback_repayment` после Ю-3).

---

### 5. Админка SaaS, аудит, 152-ФЗ, impersonation

```mermaid
erDiagram
    saas_admin_users {
        bigserial id PK
        varchar email UK
        varchar role
        text totp_secret_enc
        timestamptz totp_enabled_at
        jsonb allowed_ips
        timestamptz locked_until
        bigint created_by FK
        timestamptz deleted_at
    }

    saas_admin_recovery_codes {
        bigserial id PK
        bigint admin_user_id FK
        varchar code_hash
        timestamptz used_at
    }

    saas_admin_sessions {
        bigserial id PK
        bigint admin_user_id FK
        varchar token_hash UK
        inet ip_address
        timestamptz last_active_at
        timestamptz expires_at
        bigint impersonating_tenant_id FK
        bigint impersonating_token_id FK
    }

    impersonation_tokens {
        bigserial id PK
        bigint tenant_id FK
        bigint requested_by FK
        varchar code_hash
        text reason
        varchar sent_to_email
        timestamptz expires_at
        timestamptz used_at
        bigint session_id FK
        timestamptz session_ended_at
        int failed_attempts
    }

    saas_admin_audit_log {
        bigserial id PK
        bigint admin_user_id FK
        varchar action
        varchar target_type
        bigint target_id
        bigint target_tenant_id FK
        jsonb payload_before
        jsonb payload_after
        text reason
        boolean requires_approval
        bigint approved_by FK
        timestamptz approved_at
    }

    auth_log {
        bigserial id PK
        varchar actor_type
        bigint tenant_id FK
        bigint user_id FK
        bigint saas_admin_user_id FK
        varchar email
        varchar event
        inet ip_address
    }

    system_settings {
        varchar key PK
        text value
        varchar type
        bigint updated_by FK
    }

    tenants {
        bigserial id PK
    }

    users {
        bigserial id PK
    }

    tenant_consents {
        bigserial id PK
        bigint tenant_id FK
        bigint user_id FK
        varchar consent_type
        varchar document_version
        timestamptz given_at
        timestamptz revoked_at
    }

    pd_processing_log {
        bigserial id PK
        bigint tenant_id FK
        varchar subject_type
        bigint subject_id
        varchar action
        varchar purpose
        bigint actor_tenant_user_id FK
        bigint actor_admin_user_id FK
        inet ip_address
    }

    pd_subject_requests {
        bigserial id PK
        timestamptz received_at
        varchar subject_email
        varchar request_type
        varchar status
        bigint tenant_id FK
        bigint assigned_admin_id FK
        timestamptz deadline_at
    }

    saas_admin_users ||--o{ saas_admin_users : "created_by (self)"
    saas_admin_users ||--o{ saas_admin_recovery_codes : "admin_user_id"
    saas_admin_users ||--o{ saas_admin_sessions : "admin_user_id"
    saas_admin_users ||--o{ impersonation_tokens : "requested_by"
    tenants ||--o{ impersonation_tokens : "tenant_id"
    impersonation_tokens ||--o{ saas_admin_sessions : "impersonating_token_id"
    tenants ||--o{ saas_admin_sessions : "impersonating_tenant_id"

    saas_admin_users ||--o{ saas_admin_audit_log : "admin_user_id (actor)"
    saas_admin_users ||--o{ saas_admin_audit_log : "approved_by (4-eyes)"
    tenants ||--o{ saas_admin_audit_log : "target_tenant_id"

    saas_admin_users ||--o{ auth_log : "saas_admin_user_id"
    users ||--o{ auth_log : "user_id"
    tenants ||--o{ auth_log : "tenant_id"

    saas_admin_users ||--o{ system_settings : "updated_by"

    tenants ||--o{ tenant_consents : "tenant_id"
    users ||--o{ tenant_consents : "user_id"

    tenants ||--o{ pd_processing_log : "tenant_id"
    users ||--o{ pd_processing_log : "actor_tenant_user_id"
    saas_admin_users ||--o{ pd_processing_log : "actor_admin_user_id"

    tenants ||--o{ pd_subject_requests : "tenant_id"
    saas_admin_users ||--o{ pd_subject_requests : "assigned_admin_id"
```

> Изменения: добавлена `impersonation_tokens` (Ю-1), расширена `saas_admin_sessions` двумя полями impersonation. Циклическая связь `impersonation_tokens.session_id ↔ saas_admin_sessions.impersonating_token_id` отражает 1:1 связь активной impersonation-сессии с её токеном.

---

### 6. Поставщики и себестоимость (НОВЫЙ домен после Ю-2)

Реселлерская модель: мы покупаем лиды у crm.bp-gr.ru, перепродаём тенантам. Себестоимость записывается на каждый лид в `supplier_lead_costs`, счета от поставщика — в `supplier_invoices`.

```mermaid
erDiagram
    deals {
        bigserial id PK
        timestamptz received_at PK
    }

    saas_admin_users {
        bigserial id PK
    }

    supplier_lead_costs {
        bigserial id PK
        bigint deal_id
        timestamptz received_at PK
        varchar supplier_code
        decimal cost_rub
        bigint supplier_lead_id
        bigint supplier_invoice_id FK
        timestamptz created_at
    }

    supplier_invoices {
        bigserial id PK
        varchar supplier_code
        varchar invoice_number
        date period_from
        date period_to
        int leads_count
        int leads_count_actual
        decimal amount_rub
        decimal amount_rub_actual
        decimal discrepancy_rub
        timestamptz paid_at
        bigint paid_by FK
        bigint reconciled_by FK
        varchar status
    }

    deals ||--o{ supplier_lead_costs : "deal_id (no FK, partitioned)"
    supplier_invoices ||--o{ supplier_lead_costs : "supplier_invoice_id (после сверки)"
    saas_admin_users ||--o{ supplier_invoices : "paid_by"
    saas_admin_users ||--o{ supplier_invoices : "reconciled_by"
```

> **Партиционирование `supplier_lead_costs`** синхронно с `deals` — обе таблицы партиционированы по `received_at`. Связь между ними (`supplier_lead_costs.deal_id → deals.id`) — без FK, как и все связи на партиционированную `deals`. Целостность — на уровне `ProcessWebhookJob` (одна транзакция: `INSERT INTO deals` + `INSERT INTO supplier_lead_costs`).
>
> **Связь с биллингом тенанта** (раздел 3) — через `balance_transactions.related_type='Deal'`. Маржа = `balance_transactions.amount_rub` (выручка) − `supplier_lead_costs.cost_rub` (себестоимость). Эта связь не показана в этой диаграмме, чтобы не дублировать раздел 4.
>
> **Доступ:** обе таблицы доступны **только под ролью БД `crm_admin_user` (BYPASSRLS)** или прямым SQL у DevOps. Тенанты их не видят.

---

### 7. Сводная карта связей между доменами

```mermaid
flowchart LR
    D1["1️⃣ Тенанты &amp; пользователи"]
    D2["2️⃣ Сделки &amp; статусы"]
    D3["3️⃣ SaaS-биллинг"]
    D4["4️⃣ Лид-биллинг &amp; логи"]
    D5["5️⃣ Админка &amp; 152-ФЗ"]
    D6["6️⃣ Поставщики &amp; себестоимость"]

    D1 -->|"users.tenant_id, projects, deals.tenant_id"| D2
    D1 -->|"tenants.current_tariff_id, tariff_subscriptions"| D3
    D1 -->|"balance_transactions, webhook_log, push_subscriptions"| D4
    D1 -->|"tenant_consents, auth_log, pd_processing_log, impersonation_tokens"| D5

    D2 -->|"balance_transactions.related_id (poly)"| D4
    D2 -->|"activity_log, reminders, deal_tag_pivot"| D4
    D2 -->|"supplier_lead_costs.deal_id (no FK)"| D6

    D3 -->|"refund_requests.admin_user_id"| D5
    D3 -->|"saas_transactions ↔ tenants.balance_rub"| D4
    D3 -->|"chargeback_unrecovered_rub (cross-domain biz logic)"| D1

    D5 -->|"saas_admin_users → system_settings.updated_by"| D1
    D5 -->|"saas_admin_users → failed_webhook_jobs.retried_by"| D4
    D5 -->|"saas_admin_users → supplier_invoices.paid_by/reconciled_by"| D6
```

---

### 8. Полный текстовый справочник связей (для сред без Mermaid-рендера)

**81 FK, отсортированных по таблице-источнику.** Формат: `источник.поле → цель`.

```
activity_log.tenant_id                  → tenants
activity_log.user_id                    → users
activity_log.deal_id                    → deals (no FK, partitioned)

api_keys.tenant_id                      → tenants
api_keys.user_id                        → users

auth_log.tenant_id                      → tenants
auth_log.user_id                        → users          (XOR с saas_admin_user_id)
auth_log.saas_admin_user_id             → saas_admin_users

balance_transactions.tenant_id          → tenants
balance_transactions.user_id            → users           (инициатор)
balance_transactions.admin_user_id      → saas_admin_users (manual_adjustment, chargeback_*)
balance_transactions.related_id         → polymorphic (no FK)

comment_templates.tenant_id             → tenants
comment_templates.user_id               → users           (NULL = глобальный)

deal_tag_pivot.tag_id                   → deal_tags
deal_tag_pivot.tagged_by                → users
deal_tag_pivot.deal_id                  → deals (no FK, partitioned)

deal_tags.tenant_id                     → tenants
deal_tags.created_by                    → users

document_sequences.legal_entity_id      → legal_entities

email_verifications.user_id             → users           (FK через ALTER TABLE)

failed_webhook_jobs.tenant_id           → tenants
failed_webhook_jobs.webhook_log_id      → webhook_log
failed_webhook_jobs.retried_by          → saas_admin_users

impersonation_tokens.tenant_id          → tenants
impersonation_tokens.requested_by       → saas_admin_users
impersonation_tokens.session_id         → saas_admin_sessions

import_log.tenant_id                    → tenants
import_log.user_id                      → users

payment_gateways.legal_entity_id        → legal_entities

pd_processing_log.tenant_id             → tenants
pd_processing_log.actor_tenant_user_id  → users           (XOR с actor_admin_user_id)
pd_processing_log.actor_admin_user_id   → saas_admin_users

pd_subject_requests.tenant_id           → tenants
pd_subject_requests.assigned_admin_id   → saas_admin_users

projects.tenant_id                      → tenants

push_subscriptions.tenant_id            → tenants
push_subscriptions.user_id              → users

refund_requests.tenant_id               → tenants
refund_requests.transaction_id          → saas_transactions
refund_requests.admin_user_id           → saas_admin_users (NULL для source='external_chargeback')
refund_requests.correction_upd_id       → saas_upd_documents

rejected_deals_log.tenant_id            → tenants
rejected_deals_log.webhook_log_id       → webhook_log

reminders.tenant_id                     → tenants
reminders.user_id                       → users
reminders.deal_id                       → deals (no FK, partitioned)

report_jobs.tenant_id                   → tenants
report_jobs.user_id                     → users

saas_admin_audit_log.admin_user_id      → saas_admin_users (actor)
saas_admin_audit_log.approved_by        → saas_admin_users (4-eyes)
saas_admin_audit_log.target_tenant_id   → tenants

saas_admin_recovery_codes.admin_user_id → saas_admin_users
saas_admin_sessions.admin_user_id       → saas_admin_users
saas_admin_sessions.impersonating_tenant_id → tenants
saas_admin_sessions.impersonating_token_id  → impersonation_tokens
saas_admin_users.created_by             → saas_admin_users (self)

saas_invoice_items.invoice_id           → saas_invoices

saas_invoices.tenant_id                 → tenants
saas_invoices.legal_entity_id           → legal_entities
saas_invoices.transaction_id            → saas_transactions  (FK через ALTER TABLE)

saas_transactions.tenant_id             → tenants
saas_transactions.gateway_id            → payment_gateways
saas_transactions.legal_entity_id       → legal_entities
saas_transactions.invoice_id            → saas_invoices
saas_transactions.upd_id                → saas_upd_documents

saas_upd_documents.tenant_id            → tenants
saas_upd_documents.legal_entity_id      → legal_entities
saas_upd_documents.invoice_id           → saas_invoices
saas_upd_documents.transaction_id       → saas_transactions  (FK через ALTER TABLE)
saas_upd_documents.correction_for       → saas_upd_documents (self)

supplier_invoices.paid_by               → saas_admin_users
supplier_invoices.reconciled_by         → saas_admin_users

supplier_lead_costs.supplier_invoice_id → supplier_invoices    (FK через ALTER TABLE, заполняется при сверке)
supplier_lead_costs.deal_id             → deals (no FK, partitioned)

system_settings.updated_by              → saas_admin_users

tariff_subscriptions.tenant_id          → tenants
tariff_subscriptions.tariff_plan_id     → tariff_plans
tariff_subscriptions.created_by_id      → polymorphic (no FK)

tenant_consents.tenant_id               → tenants
tenant_consents.user_id                 → users

tenant_custom_domains.tenant_id         → tenants

tenant_status_overrides.tenant_id       → tenants
tenant_status_overrides.status_slug     → lead_statuses

tenants.current_tariff_id               → tariff_plans

user_recovery_codes.user_id             → users
user_sessions.user_id                   → users
users.tenant_id                         → tenants

webhook_log.tenant_id                   → tenants
webhook_log.deal_id                     → deals (no FK, partitioned)

password_resets — без FK (Laravel-стандарт, ключ — email)
failed_jobs    — без FK (стандартная таблица очередей Laravel)
```

---

### 9. Рейтинг таблиц по числу входящих ссылок

«Звёздные» таблицы, на которые ссылаются больше всего:

| Таблица | Входящих FK | Роль |
|---------|-------------|------|
| `tenants` | 27 | Корневая мультитенантная сущность |
| `users` | 16 | Клиентские пользователи |
| `saas_admin_users` | 14 | Админы оператора SaaS |
| `legal_entities` | 5 | Юр.лица — получатели платежей |
| `saas_invoices` | 3 | Счета |
| `saas_upd_documents` | 3 | УПД |
| `tariff_plans` | 2 | Тарифы |
| `webhook_log` | 2 | Лог webhook |
| `supplier_invoices` | 1 | Счета от crm.bp-gr.ru |
| `impersonation_tokens` | 1 | Токены impersonation |
| остальные | 1 | — |

> Изменения относительно v1.0:
>
> - `tenants` 26 → **27** (+`impersonation_tokens.tenant_id`).
> - `saas_admin_users` 12 → **14** (+`impersonation_tokens.requested_by`, +`balance_transactions.admin_user_id` уже было, +`supplier_invoices.paid_by`, +`supplier_invoices.reconciled_by`).
> - Новые целевые таблицы: `supplier_invoices`, `impersonation_tokens`.

---

### 10. Что отличается от ER-диаграммы 7.2 v8.0

В v8.0 раздел 7.2 содержит ASCII-диаграмму на 22 строки с ~14 таблицами. В этой ER-диаграмме v8.1 — **47 уникальных таблиц + 12 партиций**.

**Из самой v8.0 (были в DDL, но не на диаграмме 7.2):**

- `tenant_custom_domains`, `tenant_status_overrides`, `import_log`, `report_jobs`
- `tenant_consents`, `pd_processing_log`
- `email_verifications`, `password_resets`, `user_recovery_codes`, `user_sessions`
- `comment_templates`, `deal_tags`, `deal_tag_pivot`
- `failed_jobs`, `system_settings`, `document_sequences`
- `tariff_subscriptions`, `payment_gateways`
- `saas_invoice_items`, `refund_requests`

**Новые в v8.1 (закрытие пробелов и P0-блок):**

- `saas_admin_users`, `saas_admin_recovery_codes`, `saas_admin_sessions`, `saas_admin_audit_log` (пробел 🟠№5)
- `pd_subject_requests` (пробел 🟠№7 152-ФЗ)
- `failed_webhook_jobs` (пробел в схеме)
- `impersonation_tokens` (Ю-1 — impersonation)
- `supplier_lead_costs`, `supplier_invoices` (Ю-2 — реселлерская модель)

**Обновлены поля существующих таблиц:**

- `tenants.chargeback_unrecovered_rub` (Ю-3)
- `users.notification_preferences JSONB` + `sound_enabled` (CTO-4, заменили 4 BOOLEAN)
- `tariff_subscriptions.status` (5 значений вместо 3 + `superseded_at`) (CTO-1)
- `tariff_subscriptions` — два уникальных частичных индекса (CTO-1)
- `saas_admin_sessions` — два поля impersonation (Ю-1)
- `refund_requests.source` (Ю-3)
- `balance_transactions.type` — два новых значения для chargeback (Ю-3)
- `saas_transactions.failure_reason` — формализованный enum в комментарии (CTO-3)
- `auth_log` — расширен для админов SaaS (`actor_type`, `saas_admin_user_id`)
- `pd_processing_log` — `actor_tenant_user_id` + `actor_admin_user_id` вместо одного «висящего» поля

---

*Версия: 1.1 от 03.05.2026.*

---

# Часть В — State-machine диаграммы

## State-machine диаграммы (v8.1)

**Источник фактов:** v8.0 разделы 13.4, 20.2.3, 20.4.3, 20.5, 20.6, 20.8, 20.9 + DDL `schema.sql` v8.1 + решения P0-блока обсуждения (CTO-1, CTO-2, CTO-3, Биз-2, Ю-3).
**Назначение:** третья часть пробела 🔴 №3 из конспекта анализа v8.0.
**Формат:** Mermaid `stateDiagram-v2` + таблица триггеров.
**Версия:** 1.1 (после P0-блока). Изменения относительно 1.0:

- `saas_transactions`: добавлены два новых триггера `success → refunded` (наш возврат vs chargeback), явно запрещён `failed → success`, добавлен polling-fallback от cron, hard timeout 24ч.
- `tariff_subscriptions`: enum расширен с 3 до 5 состояний (`scheduled`, `active`, `superseded`, `expired`, `cancelled`), 5 закрытых открытых вопросов SM-5 … SM-9.
- `report_jobs`: без изменений.
- Новая мини-машина `refund_requests.status`.

> **Метод.** Переходы выписаны только из явных решений или из v8.0. Где остались открытые вопросы — помечены `[OPEN]` со ссылкой на ID в `Открытые_вопросы_v8_1.md`.

---

### 1. `saas_transactions.status`

Покрывает все сценарии оплаты (карта 20.8, банк.перевод 20.9), плюс наши возвраты и внешние chargeback (Ю-3).

#### 1.1. Допустимые состояния

`pending`, `success`, `failed`, `refunded` — четыре значения, из DDL 20.5.

#### 1.2. Диаграмма

```mermaid
stateDiagram-v2
    direction LR

    [*] --> pending : "POST /api/v1/billing/topup (карта/СБП/ЮMoney)"
    [*] --> success : "Bank transfer: админ подтверждает оплату счёта (20.9)"

    pending --> success : "webhook: payment.succeeded"
    pending --> success : "cron payments:cancel-stale → polling: gateway.getStatus = 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 → processed"
    success --> refunded : "chargeback: webhook refund.succeeded без refund_requests (авто)"

    failed --> [*]
    refunded --> [*]

    note right of pending
        Идемпотентность через
        gateway_idempotence_key (UUID)
        Hard ceiling: 24 часа.
    end note

    note left of success
        Транзакционно:
        balance_rub += amount
        balance_leads += конвертация по тарифу
        balance_transactions: type=topup
    end note

    note right of refunded
        Если наш возврат:
        ◊ balance_rub -= amount
        ◊ balance_leads -= конвертация
        ◊ correction_upd создан
        Если chargeback и balance < amount:
        ◊ balance_rub = 0
        ◊ chargeback_unrecovered_rub += разница
        ◊ tenants.status = suspended
    end note
```

#### 1.3. Таблица триггеров

| Из | В | Триггер | Источник | Побочные эффекты |
|----|---|---------|----------|------------------|
| `[*]` | `pending` | `POST /api/v1/billing/topup` (карта/СБП/ЮMoney/QIWI) | 20.8 шаг 3.a | Создаётся payment в шлюзе, сохраняется `gateway_payment_id` |
| `[*]` | `success` | Админ нажал «Подтвердить» в админке для банк.перевода | 20.9 шаг 7.b | `saas_invoices.status='paid'`, `balance_rub +=`, генерируется УПД |
| `pending` | `success` | Webhook `payment.succeeded` от шлюза | 20.4.3 | Транзакционно: `balance_rub +=`, `balance_leads +=`, `balance_transactions(type='topup')`, email клиенту, чек 54-ФЗ через ЮKassa |
| `pending` | `success` | **CTO-3:** cron `payments:cancel-stale` polling `gateway.getStatus()` = `succeeded` | CTO-3 | То же, что webhook — это просто webhook, который не дошёл напрямую |
| `pending` | `failed` | Webhook `payment.canceled` от шлюза | 20.4.3 | `failure_reason = 'gateway_canceled' / 'gateway_declined'` |
| `pending` | `failed` | **CTO-3:** cron 30 минут, `gateway_payment_id IS NULL` (платёж в шлюзе не создался) | CTO-3 | `failure_reason = 'gateway_create_timeout'` |
| `pending` | `failed` | **CTO-3:** cron 30 минут, polling вернул `canceled` / `failed` | CTO-3 | `failure_reason = 'cron_polling_failed'` |
| `pending` | `failed` | **CTO-3:** hard timeout 24 часа независимо от ответа шлюза | CTO-3 | `failure_reason = 'hard_timeout_24h'` |
| `success` | `refunded` | `refund_requests(source='admin_initiated')` обработана | 20.6 | `balance_rub -=`, `balance_leads -=`, корректировочный УПД |
| `success` | `refunded` | **Ю-3:** webhook `refund.succeeded` без `refund_requests` (chargeback) | Ю-3 | Авто-создание `refund_requests(source='external_chargeback', admin_user_id=NULL)`. Если `balance < amount` → `balance_rub = 0`, `chargeback_unrecovered_rub += разница`, `tenants.status = 'suspended'`. Алерт `finance` |

#### 1.4. Запрещённые переходы

| Запрещено | Почему | Что делать вместо |
|-----------|--------|-------------------|
| `failed → success` | **CTO-3:** транзакция уже отменена, мы не списывали баланс из неё. Поздний webhook `payment.succeeded` означает рассинхронизацию | Webhook **логируется** (`failure_reason='late_webhook_ignored'`), статус не меняется, **алерт `finance`** в админке. `finance` решает: либо инициировать возврат через шлюз, либо ручное начисление через `balance_transactions(type='manual_adjustment')` (с four-eyes если > порога) |
| `refunded → success` | Возврат завершён, обратного пути нет | Если деньги ошибочно вернулись — отдельный новый topup |
| `success → failed` | Если success достигнут — баланс уже зачислен, нельзя «передумать» | Только через возврат → `refunded` |

#### 1.5. Открытые вопросы по этой машине

| ID | Вопрос | Статус |
|----|--------|--------|
| SM-1 | Таймаут на pending транзакции | ✅ закрыт CTO-3: 30 мин soft + 24ч hard |
| SM-2 | Частичный возврат | ✅ закрыт 20.6 v8.0: только полная сумма |
| SM-3 | Кнопка «Отменить платёж» у клиента | `[OPEN]` P2 — до спринта 9 |
| SM-4 | Внешние chargeback от шлюза без нашей инициативы | ✅ закрыт Ю-3: вариант А с защитой от минусового баланса |

---

### 2. `tariff_subscriptions.status` — переписана после CTO-1, CTO-2, Биз-2

После решений CTO-1 (вариант А с `superseded` + 00:00 МСК scheduled) и CTO-2 (жёсткая блокировка при `expired`) и Биз-2 (отмена клиентом отсутствует) машина приобрела **5 состояний** вместо 3.

#### 2.1. Допустимые состояния

`scheduled`, `active`, `superseded`, `expired`, `cancelled` — пять значений (CTO-1).

| Состояние | Семантика |
|-----------|-----------|
| `scheduled` | Будущая активация в 00:00 МСК. Может существовать одновременно с `active` (та — текущий тариф, эта — следующий). Уникальный частичный индекс `WHERE status='scheduled'` (одна на тенанта). |
| `active` | Текущий тариф тенанта. Уникальный частичный индекс `WHERE status='active'` (одна на тенанта). |
| `superseded` | Закрыта новой подпиской. `superseded_at = NOW()`, чаще всего совпадает с `started_at` новой active. |
| `expired` | Истекла по `expires_at`. Каскадно `tenants.status='suspended'` (CTO-2). |
| `cancelled` | Принудительно отменена админом SaaS при блокировке тенанта. `cancelled_at = NOW()`. (Биз-2: клиент сам не отменяет.) |

#### 2.2. Диаграмма

```mermaid
stateDiagram-v2
    direction LR

    [*] --> active : "Регистрация: первая подписка тенанта на дефолт-тариф"
    [*] --> scheduled : "Смена тарифа: новая запись, started_at = ближайшие 00:00 МСК"

    scheduled --> active : "cron tariffs:apply-scheduled в 00:00 МСК: started_at <= NOW()"
    scheduled --> [*] : "Отмена scheduled до активации (новая смена тарифа поверх)"

    active --> superseded : "cron в 00:00 МСК: новая active заменила эту"
    active --> expired : "cron: expires_at <= NOW() (только для срочных тарифов)"
    active --> cancelled : "Админ SaaS блокирует тенанта"

    superseded --> [*]
    expired --> [*]
    cancelled --> [*]

    note right of scheduled
        Уникальный индекс:
        одна scheduled на тенанта.
        До 00:00 МСК тарификация
        идёт по active (CTO-1: вариант Б).
    end note

    note left of active
        Уникальный индекс:
        одна active на тенанта.
        tenants.current_tariff_id ↔ эта запись.
    end note

    note right of expired
        КАСКАДНО (CTO-2):
        ◊ tenants.status = suspended
        ◊ webhook → 402, лиды теряются
        ◊ экраны блокируются
            (кроме /billing)
        Восстановление: новая
        подписка через /billing.
    end note
```

#### 2.3. Таблица триггеров

| Из | В | Триггер | Источник | Побочные эффекты |
|----|---|---------|----------|------------------|
| `[*]` | `active` | Регистрация тенанта (онбординг) | v8.0 раздел 4.1 | `started_at = NOW()`, `tenants.current_tariff_id` устанавливается |
| `[*]` | `scheduled` | Клиент в `/billing` или админ выбирает «Сменить тариф» | CTO-1 | `started_at = ближайшие 00:00 МСК`. Существующая `active` остаётся `active` до 00:00 МСК |
| `scheduled` | `active` | Cron `tariffs:apply-scheduled` обнаружил `started_at <= NOW()` | CTO-1 | Каскадно: текущая `active` → `superseded` (см. ниже); `tenants.current_tariff_id` обновляется |
| `scheduled` | `[*]` (удалена) | Клиент сменил тариф второй раз до 00:00 МСК | CTO-1 | Старая `scheduled` физически удаляется, новая создаётся вместо неё. (Альтернативно — статус `cancelled_scheduled`, но избыточно) |
| `active` | `superseded` | Cron в 00:00 МСК нашёл новую `active` для того же тенанта | CTO-1 | `superseded_at = NOW()`. Запись остаётся в БД бессрочно (история тарифов) |
| `active` | `expired` | Cron `tariffs:apply-scheduled` обнаружил `expires_at <= NOW()` AND нет `scheduled` | CTO-1 + CTO-2 | **Каскадно**: `tenants.status = 'suspended'`. Email клиенту «Подписка истекла» |
| `active` | `cancelled` | Админ SaaS принудительно блокирует тенанта (например, по заявлению, нарушение правил) | АДМ §4.2 | `cancelled_at = NOW()`, `tenants.status = 'suspended'`. Запись в `saas_admin_audit_log` |

#### 2.4. Email-уведомления о приближении `expired`

Cron `subscription:expiry-notify` (ежедневно):

- **за 7 дней** до `expires_at`: «Подписка истекает 1 июня. Продлите, чтобы продолжить приём лидов.»
- **за 1 день**: «Подписка истекает завтра. Без продления приём лидов будет приостановлен.»
- **в момент истечения** (вместе с переводом в `expired`): «Подписка истекла. Поступающие лиды теряются. Продлите подписку.»

#### 2.5. Открытые вопросы по этой машине

| ID | Вопрос | Статус |
|----|--------|--------|
| SM-5 | Закрытие старой `active` при смене тарифа | ✅ закрыт CTO-1: новая `scheduled` → в 00:00 МСК старая → `superseded` |
| SM-6 | Поведение тенанта при `expired` | ✅ закрыт CTO-2: вариант Б, жёсткая блокировка, лиды теряются |
| SM-7 | Может ли клиент сам отменить подписку | ✅ закрыт Биз-2: кнопки нет, уход через переход на `start` или удаление аккаунта |
| SM-8 | Возврат `cancelled / expired → active` | ✅ автоматически: восстановление = новая подписка (как обычная смена тарифа) |
| SM-9 | Grace period после `expired` | ✅ закрыт CTO-2: grace period отсутствует, блокировка в 00:00 МСК |

> **Все 5 открытых вопросов по этой машине — закрыты.** Машина полностью спроектирована.

---

### 3. `report_jobs.status`

Без изменений после P0-блока. Workflow явно описан в 13.4 v8.0.

#### 3.1. Допустимые состояния

`pending`, `processing`, `done`, `failed` — четыре значения, из DDL 13.5.

#### 3.2. Диаграмма

```mermaid
stateDiagram-v2
    direction LR

    [*] --> pending : "POST /api/v1/reports"

    pending --> processing : "Воркер очереди 'reports' взял Job"
    processing --> done : "Файл сгенерирован → S3 → file_path"
    processing --> failed : "Exception в воркере (SQL/PhpSpreadsheet/S3)"

    done --> done_expired : "Cron 'reports:cleanup-expired': expires_at < NOW()"

    state done_expired <<choice>>
    done_expired --> [*] : "Файл удалён из S3, status остаётся done"

    failed --> [*]

    note right of pending
        В очереди reports
        ожидает воркера.
    end note

    note left of done
        finished_at = NOW()
        generation_seconds = ...
        file_path = s3://...
        expires_at = NOW() + 7 days
    end note

    note right of failed
        error_message = exception
        finished_at = NOW()
    end note
```

#### 3.3. Таблица триггеров

| Из | В | Триггер | Источник | Побочные эффекты |
|----|---|---------|----------|------------------|
| `[*]` | `pending` | `POST /api/v1/reports` | 13.4 | Создаётся `report_jobs`, отправляется в очередь `reports` |
| `pending` | `processing` | Воркер очереди `reports` взял Job | 13.4 | (нет timestamp в DDL — только `created_at`) |
| `processing` | `done` | Файл сгенерирован успешно, в S3 | 13.4 | `file_path`, `file_size`, `generation_seconds`, `finished_at`, `expires_at = NOW() + 7d`, email пользователю |
| `processing` | `failed` | Exception в воркере | стандарт Laravel queue | `error_message`, `finished_at` |
| `done` | `done` (file deleted) | Cron `reports:cleanup-expired` (дневной) | 23.5 | Файл удаляется из S3, `file_path` остаётся для аудита, статус `done` остаётся |

#### 3.4. Открытые вопросы

| ID | Вопрос | Статус |
|----|--------|--------|
| SM-10 | Retry для failed из UI пользователя | `[OPEN]` P2 — до спринта 7 (CTO-6) |
| SM-11 | Признак `file_deleted` после expires_at | `[OPEN]` P2 — до спринта 7 (CTO-10) |
| SM-12 | Лимит concurrent отчётов на тенанта | `[OPEN]` P1 — до спринта 7 (CTO-7) |

---

### 4. Параллельная мини-машина: `refund_requests.status`

Вспомогательная машина, тесно связана с `saas_transactions` (раздел 1).

#### 4.1. Допустимые состояния

`pending`, `approved`, `rejected`, `processed` — из DDL 20.6.

#### 4.2. Диаграмма

```mermaid
stateDiagram-v2
    direction LR

    [*] --> pending : "Создание (admin_initiated): админ создаёт запрос"
    [*] --> processed : "Создание (external_chargeback): авто из webhook refund.succeeded"

    pending --> approved : "Админ роли finance/super_admin одобрил"
    pending --> rejected : "Админ отклонил с причиной"

    approved --> processed : "gateway.refund() успешно → корректировочный УПД создан"
    approved --> rejected : "gateway.refund() вернул ошибку, ручной разбор"

    rejected --> [*]
    processed --> [*]

    note left of pending
        source=admin_initiated.
        Лимит на сумму > порога
        ДЕФ-4 → four-eyes.
    end note

    note right of processed
        Каскадно:
        saas_transactions.status = refunded
        balance_rub, balance_leads уменьшаются
        (с защитой от минуса для chargeback).
    end note
```

#### 4.3. Таблица триггеров

| Из | В | Триггер | Источник | Побочные эффекты |
|----|---|---------|----------|------------------|
| `[*]` | `pending` | Админ в админке создал запрос на возврат | 20.6 + АДМ §4.3 | `source='admin_initiated'`, `admin_user_id` заполнен. Если сумма > ДЕФ-4 → `requires_approval=true` в `saas_admin_audit_log` |
| `[*]` | `processed` | **Ю-3:** webhook `refund.succeeded` от шлюза без существующего `refund_requests` | Ю-3 | `source='external_chargeback'`, `admin_user_id=NULL`. Сразу `processed_at = NOW()` (мы не одобряем, это уже свершившийся факт). Каскадно `saas_transactions.status='refunded'` |
| `pending` | `approved` | Админ нажал «Одобрить» (с four-eyes если нужно) | АДМ §4.3 | `decided_at`, `admin_decision`, запись в `saas_admin_audit_log` |
| `pending` | `rejected` | Админ нажал «Отклонить» | АДМ §4.3 | `decided_at`, `admin_decision` (причина обязательна) |
| `approved` | `processed` | `gateway.refund(payment_id, amount)` вернул успех | 20.6 | `gateway_refund_id` сохраняется. `saas_transactions.status='refunded'`. Создаётся корректировочный УПД (`correction_upd_id`) |
| `approved` | `rejected` | Шлюз отказал в возврате (ошибка API, истёк срок отмены, etc.) | Edge case 20.6 | `admin_decision` дополняется ошибкой шлюза. Возврат не выполнен — клиенту делать через банк chargeback или ручное начисление |

---

### 5. Сводный статус открытых вопросов

После P0-блока обсуждения: **8 из 12 закрыто**.

| ID | Машина | Вопрос | Приоритет | Статус |
|----|--------|--------|-----------|--------|
| SM-1 | saas_transactions | Таймаут pending | P1 | ✅ CTO-3 |
| SM-2 | saas_transactions | Частичный возврат | P0 | ✅ 20.6 v8.0 |
| SM-3 | saas_transactions | Кнопка «отменить» у клиента | P2 | `[OPEN]` |
| SM-4 | saas_transactions | Внешние chargeback | P0 | ✅ Ю-3 |
| SM-5 | tariff_subscriptions | Закрытие active при смене | P0 | ✅ CTO-1 |
| SM-6 | tariff_subscriptions | Поведение при expired | P0 | ✅ CTO-2 |
| SM-7 | tariff_subscriptions | Отмена клиентом | P1 | ✅ Биз-2 |
| SM-8 | tariff_subscriptions | Возврат cancelled→active | P2 | ✅ автоматически |
| SM-9 | tariff_subscriptions | Grace period | P1 | ✅ CTO-2 |
| SM-10 | report_jobs | Retry failed из UI | P2 | `[OPEN]` |
| SM-11 | report_jobs | Признак file_deleted | P2 | `[OPEN]` |
| SM-12 | report_jobs | Лимит concurrent | P1 | `[OPEN]` |

**Все P0 закрыты.** Остались 4 вопроса P1/P2, не блокирующие разработку.

---

### 6. Что делать с этим документом

1. Как **спецификацию для разработчиков** (sprint 8 биллинга, sprint 7 отчётов).
2. Как **ввод для тестов** — каждый переход = тест-кейс с контрактом «что входит, что меняется, побочные эффекты».
3. Как **базу для UI** — фронтенд показывает текущий статус и доступные действия.
4. Как **источник для алертов** — Sentry/Prometheus метрики «количество транзакций в pending старше 30 минут», «количество подписок в expired», «количество late webhook ignored».

---

*Версия: 1.1 от 03.05.2026.*

---

*Версия документа: 1.0 от 05.05.2026 (объединение Прил. Б v8.1 + Прил. В v8.1).*
*При следующем обновлении ER до v8.3 переименовать в `Приложение_Б_В_БД_диаграммы_v8_4.md` или повысить версию документа.*
