# R3b — Прогон: онбординг · письмо-дайджест · убранные напоминания · отчёты · импорт

**Owner:** исполнитель приёмочного теста (wall-free) | **Частота:** разово, перед передачей продажникам (повтор — после нового деплоя G1/G2/G3).
**Обновлён:** 19.06.2026 (выверен скилом `operations:runbook`, сверен с боевым `01a9029c`). **Последний прогон:** — (не запускался; история — внизу).
**Зависит от:** R0 (старт/доступ), R1 (тест-клиенты), R2 (инъекция), R3 (движок). База: `prod-logic-map.md` §1, §5, §6, §8; хартия пп. 5/6/7/8 + блок «Онбординг».
**Формат карточек — уровень C** (образец см. R0 §6 / R3). Каждая: §ref · Что · Предусловие · Действие · Ожидаем · Сверка · Если не так · Покрывает.
**Это «UI/G-зависимая» часть**, отделённая от движка (R3). Гонится ПОСЛЕ деплоя G1/G2/G3.
**Проверка ранбука (методика скила):** карточки самодостаточны для исполнителя, незнакомого с кодом — все факты/команды явны; где исполнитель «застрял» — дополнять troubleshooting-таблицу (внизу).

> **Сверено с боевым 19.06.2026** (деплой gitea `01a9029c`, schema v8.49). На проде реально живут: G1 самозапись, G2 дайджест (вкл. по умолчанию), G3 напоминания удалены целиком. Перед прогоном — перепроверить (карта §0 ниже), снимок волатилен.
> **Тарифы НЕ зашиты:** живые ступени читать `SELECT tier_no, leads_in_tier, price_per_lead_kopecks FROM pricing_tiers WHERE is_active ORDER BY tier_no`. На 19.06 = T1 500₽(100)…T7 250₽(∞); смена на ≤65₽ **не применена** (отдельная работа по биллингу) — **всегда сверяй с БД**.

---

## §0 — Пред-прогон: убедиться, что фиксы реально на боевом (read-only, 1 минута)

| Проверка | Команда (ssh liderra-prod) | Ожидаем |
|---|---|---|
| G1 файлы | `ls .../app/Http/Controllers/Api/RegistrationController.php .../Services/Auth/RegistrationService.php` | оба есть |
| G2 запланирован | `grep -n SendNewLeadsDigestJob .../routes/console.php` | `->everyThirtyMinutes()` |
| G2 дефолт письма | `psql … "SELECT column_default FROM information_schema.columns WHERE table_name='users' AND column_name='notification_preferences'"` | `new_lead … "email": true` |
| G3 убран | `ls .../Console/Commands/RemindersDispatchDue.php .../Http/Controllers/Api/ReminderController.php` | **нет файлов** |
| G3 таблица убрана | `psql … "\dt reminders"` | `Did not find any relation` |

Если что-то не так — **СТОП**, деплой не приехал, не гадать (как было 19.06 до 19:10).

---

## Часть A — Онбординг клиента (G1, §1)

> На проде самозапись — **единственный** клиент-видимый путь. **Админ-создания тенанта НЕТ** (`AdminTenantsController` = index/show/updateBalance) — заведение админом по-прежнему через SQL/seed (см. R1, провижининг). Поэтому «оба пути» из решения владельца на деле = «самозапись (продукт) + SQL (служебно)».
> **ВАЖНО про код на проде:** 6-значный код в ответе API (`_dev_plain_code`) отдаётся **только в local/testing**, на проде — **нет**. На боевом код приходит **только письмом** → онбординг-карточки с подтверждением гонять на **живой ящик, которым владеет проверяющий** (прежние `kdv1@bk.ru`/`stels_info@bk.ru` УДАЛЕНЫ 21.06 — бери свежий).
>
> 🔑 **КАПЧА НА ПРОДЕ РЕАЛЬНАЯ (Yandex SmartCaptcha, M-2)** — `CAPTCHA_DRIVER=yandex`, виджет с боевым site-key, `test=false`. Headless-автомат её НЕ решит. Два рабочих способа провести онбординг через капчу:
> - **(а) владелец сам регистрируется** в своём браузере (решает капчу руками), затем тестировщик продолжает под этим аккаунтом — надёжно, всегда работает;
> - **(б) тестировщик ведёт Playwright и на шаге капчи ставит паузу**, владелец решает капчу в видимом окне, тестировщик жмёт «отправить» — работает, если браузер Playwright видим/интерактивен.
>
> 📧 **КОД подтверждения — тоже от владельца.** 6-значный код приходит **письмом на ящик владельца** (исполнитель в чужую почту не залезет, в ответе API на проде кода нет). → На шаге **ONB-CODE владелец читает код из письма и передаёт его исполнителю**, тот вводит в форму подтверждения. То есть на живом онбординге владелец даёт исполнителю ДВЕ вещи: **решение капчи** и **код из письма**.
>
> Для **массового** провижининга тест-клиентов (не через онбординг) — по-прежнему **SQL (R1)**, капчу минует. То есть: ONB-REG/ONB-CODE/ONB-CAPTCHA проверяем **живым онбордингом с ручным решением капчи** (хотя бы 1 раз), остальную популяцию заводим SQL.

### ONB-REG — Самозапись: шаг регистрации
**§ref:** `RegistrationController@register`, `RegistrationService::register`. **Что:** новый email+пароль+капча → создаётся **новый тенант** в статусе `pending_email_confirm` (не активен) и письмо с кодом.
**Предусловие:** email на живом ящике, которого ещё нет в системе; капча проходима.
**Действие:** браузером на `https://liderra.ru/register` заполни email (живой ящик)+пароль, пройди капчу, отправь.
**Ожидаем:** ответ `201` `status=pending_email_confirm` + `expires_at` (~15 мин вперёд); экран просит ввести код из письма; войти ещё нельзя.
**Сверка (БД):** `SELECT t.status, t.balance_rub, u.is_active FROM tenants t JOIN users u ON u.tenant_id=t.id WHERE u.email='<email>'` → `pending_email_confirm | 0 | f`; одна строка в `email_verifications` (`verified_at IS NULL`, `expires_at` ~+15 мин).
**Если не так:** тенант создан `active` сразу / баланс ≠ 0 до подтверждения / два тенанта на один email → **СТОП**, дефект онбординга (был баг «tenant создавался active»).
**Покрывает:** создание нового тенанта, отложенная активация.

### ONB-CODE — Подтверждение почты 6-значным кодом → активация + 300 ₽
**§ref:** `@confirmEmail`, `RegistrationService::confirm`. **Что:** верный код → юзер активен, тенант `active`, баланс **300 ₽**, вход.
**Предусловие:** ONB-REG прошёл; код пришёл письмом на живой ящик.
**Действие:** введи код из письма, подтверди.
**Ожидаем:** вход в кабинет; на дашборде баланс **300 ₽**.
**Сверка (БД):** та же строка → `active | 300.00 | t`; `email_verifications.verified_at` проставлен.
**Если не так:** активировался без кода / баланс ≠ 300 / частичная активация (юзер активен, тенант нет) → **СТОП**, критдефект.
**Покрывает:** код-подтверждение, старт-баланс 300 ₽, атомарная активация.

### ONB-ATTEMPTS — Неверный код: лимит 5 попыток, TTL 15 мин
**§ref:** `confirm` (MAX_FAILED_ATTEMPTS=5, CODE_TTL_MINUTES=15). **Действие:** введи **неверный** код несколько раз; отдельно — подожди >15 мин и введи верный.
**Ожидаем:** на неверный — `422` с `attempts_remaining` (5→0); после 5 неверных код негоден (`too_many_attempts`); просроченный код негоден (`expired`).
**Сверка:** `SELECT failed_attempts, expires_at, verified_at FROM email_verifications WHERE email='<email>' ORDER BY id DESC LIMIT 1` — счётчик растёт, переживает 422 (не сбрасывается откатом).
**Если не так:** код принят после 5 ошибок / после истечения TTL → дефект (анти-перебор сломан).
**Покрывает:** лимит попыток, TTL кода.

### ONB-RESEND — Повторная отправка кода + анти-енумерация
**§ref:** `@resendCode`, `RegistrationService::resend`. **Действие:** «отправить код повторно» для (а) ожидающего подтверждения email, (б) уже **активного** email, (в) несуществующего email.
**Ожидаем:** во всех трёх — одинаковый нейтральный ответ «если аккаунт ожидает подтверждения, мы отправили код»; письмо реально приходит **только** в случае (а); старый код гасится (новый — единственный годный).
**Сверка:** для (а) — новая строка `email_verifications`, прежние с `failed_attempts=5` (погашены); для (б)/(в) — новых строк нет.
**Если не так:** разный ответ для существующего/несуществующего (утечка наличия аккаунта) → дефект 152-ФЗ/безопасности.
**Покрывает:** resend, анти-енумерация, гашение старого кода.

### ONB-CAPTCHA / ONB-DUP — Капча и дубль-защита
**§ref:** `register` (captcha, advisory-lock по email). **Действие:** (а) отправь регистрацию с непройденной/пустой капчей; (б) попробуй зарегистрировать **уже активный** email.
**Ожидаем:** (а) `422` `captcha_failed` (поле `captcha_token`); (б) `422` `email_taken`. Параллельные register на один новый email не создают два тенанта (advisory-lock).
**Сверка:** в БД нет лишних тенантов/юзеров после отказов.
**Если не так:** капча обходится / два тенанта на email → дефект.
**Покрывает:** капча-гейт, TOCTOU-дубль.

### ONB-REQ-GATE — Гейт реквизитов: первый проект только после реквизитов
**§ref:** `ProjectController@store` (`requisites_required`), `RequisitesService::isLightComplete`. **Что:** у тенанта **0 проектов** и нет минимальных реквизитов → создать проект нельзя.
**Предусловие:** свежий подтверждённый тенант (ONB-CODE), реквизиты не заполнены.
**Действие:** попробуй создать первый проект.
**Ожидаем:** `422 {"error":"requisites_required"}`; UI ведёт на вкладку «Реквизиты» (deep-link). Осмотр кабинета при этом доступен.
**Сверка:** в `projects` для тенанта по-прежнему 0 строк; в `tenant_requisites` записи нет/неполна.
**Если не так:** проект создался без реквизитов → дыра гейта.
**Покрывает:** гейт первого проекта.

### ONB-INN — Подтяжка реквизитов по ИНН (ЮЛ/ИП) + физлицо без ИНН
**§ref:** `@lookupInn` (DaData `PartyLookup`), `UpdateRequisitesRequest`, `isLightComplete`. **Что:** по ИНН подтягиваются название/КПП/ОГРН/адрес; для ИП — тоже; физлицу ИНН не нужен.
**Действие:** на вкладке «Реквизиты»: (а) тип «юр.лицо», введи реальный ИНН → «подтянуть»; (б) тип «физлицо» → только ФИО+телефон.
**Ожидаем:** (а) `found=true` + `legal_name/kpp/ogrn/legal_address` + `subject_type_hint` (`legal_entity`/`sole_proprietor`); сохранение проходит. (б) сохранение без ИНН проходит. После заполнения минимума (тип+ФИО+телефон, +ИНН для ЮЛ/ИП) гейт ONB-REQ-GATE снимается — первый проект создаётся.
**Сверка:** `SELECT subject_type, contact_name, contact_phone, inn FROM tenant_requisites WHERE tenant_id=<t>`; `isLightComplete` истинно (тип+ФИО+телефон, и `inn` непуст для legal_entity/sole_proprietor).
**Если не так:** ИНН не тянется (DaData выкл/бюджет) — записать как находку (не критдефект); физлицо требует ИНН → дефект логики.
**Покрывает:** ИНН/DaData-подтяжка, тип субъекта, снятие гейта.

### ONB-REQ-FULL — Полные платёжные реквизиты → «Готово к оплате» (опц., ⭐)
**§ref:** `RequisitesService::upsert` (`requisites_completed_at` при заполнении `bank_account`). **Действие:** заполни банковские реквизиты (счёт и т.д.).
**Ожидаем:** появляется отметка/чип «Готово к оплате»; `requisites_completed_at` проставлен.
**Сверка:** `SELECT requisites_completed_at FROM tenant_requisites WHERE tenant_id=<t>` непуст.
**Покрывает:** полные реквизиты, маркер готовности к оплате.

---

## Часть B — Колокольчик + письмо-дайджест нового лида (G2, §5; пункт 5 ПЕРЕПИСАН)

> **Что изменилось деплоем:** письмо на **каждый** лид заменено **дайджестом** раз в 30 мин (`SendNewLeadsDigestJob`). `notifyNewLead` теперь шлёт **только колокольчик** на каждую сделку. **Узкое место H1 (синхронное письмо в транзакции доставки) — УШЛО** (email вынесен в отдельный 30-мин джоб; в транзакции доставки писем нет). Письмо `new_lead.email` теперь **включено по умолчанию**.

### BELL-NEW — Колокольчик на каждый лид
**§ref:** `NotificationService::notifyNewLead` (in-app). **Действие:** влей лид клиенту (R2) → открой кабинет.
**Ожидаем:** у всех активных юзеров тенанта с `new_lead.inapp=true` — уведомление «Новый лид — {проект}» (контакт/телефон, ссылка на сделку); счётчик непрочитанных растёт; отметить прочитанным/все/удалить работает.
**Сверка:** `SELECT count(*) FROM in_app_notifications WHERE tenant_id=<t> AND event='new_lead'` = числу влитых лидов; deep-link ведёт на карточку именно этой сделки.
**Если не так:** колокольчик не пришёл / ведёт не туда → находка.
**Покрывает:** in-app new_lead, счётчик, deep-link.

### DIGEST-ON — Дайджест приходит по умолчанию (вкл.)
**§ref:** `SendNewLeadsDigestJob` (every 30 min), default `new_lead.email=true`. **Что:** одно письмо-сводка «N новых сделок» за окно — каждому активному юзеру с `new_lead.email=true`.
**Предусловие:** клиент на **живом ящике** (`kdv1@bk.ru`), несколько лидов влиты за последние 30 мин; письмо в настройках НЕ выключал.
**Действие:** влей 2–3 лида → дождись 30-мин крона **или** запусти джоб вручную (`SendNewLeadsDigestJob` через tinker/временную команду — окно «последние 30 мин по `received_at`»).
**Ожидаем:** **одно** письмо-дайджест на живой ящик со сводкой о N сделках (бренд «Лидерра», ссылка на liderra.ru, не спам); по-лид-письмо НЕ приходит.
> **Дедуп (N-4, исправлено в коде):** джоб теперь идемпотентен **по сделке** — каждая сделка помечается в Redis (SETNX `digest_sent:<id>`, TTL сутки) при включении в дайджест. **Повторный/ручной прогон в том же окне НЕ дублирует** письмо (уже отправленные сделки исключаются). Поэтому «запусти вручную» безопасно. Проверка: два прогона подряд → второй ничего не шлёт.
**Сверка:** число лидов в письме = `SELECT count(*) FROM deals WHERE tenant_id=<t> AND received_at > now()-interval '30 min' AND is_test=false AND deleted_at IS NULL` (минус уже отправленные в прошлый дайджест).
**Если не так:** письмо на каждый лид (старое поведение) / дайджест не пришёл при включённой подписке → находка.
**Покрывает:** дайджест 30-мин, дефолт-вкл, окно по received_at.

### DIGEST-WINDOW / DIGEST-OFF — Идемпотентность окна и отключение
**§ref:** §5, окно 30 мин непересекающееся; `@updateNotificationPreferences`. **Действие:** (а) запусти джоб дважды подряд без новых лидов; (б) выключи `new_lead.email` в настройках → влей лид → запусти джоб.
**Ожидаем:** (а) второй запуск писем не шлёт (окно пустое); (б) после выключения подписки дайджест НЕ приходит (колокольчик — приходит).
**Сверка:** `SELECT notification_preferences->'new_lead'->>'email' FROM users WHERE id=<u>` = `false`; писем нет.
**Если не так:** письмо при выключенной подписке / дубль за то же окно → находка.
**Покрывает:** окно дайджеста, opt-out.

### PUSH-DEAD — Push не приходит (G4)
**§ref:** §13 G4. **Ожидаем:** push-уведомления не приходят (канал убран) — клиенту не обещаем. Просто подтвердить.
**Покрывает:** G4.

---

## Часть C — Напоминания УБРАНЫ целиком (G3, §8; пункт 6 ПЕРЕВЁРНУТ)

> **Решение владельца исполнено полностью:** напоминания вырезаны из портала совсем — нет контроллера, команды, крона, **таблицы `reminders`** и раздела в UI. Пункт 6 теперь = «убедиться, что нет», а НЕ «проверить рассылку».

### REM-GONE — Раздела «Напоминания» в портале нет
**§ref:** §8 (G3). **Действие:** под клиентом пройди по кабинету; попробуй открыть `https://liderra.ru/reminders` (если знал старый путь).
**Ожидаем:** пункта «Напоминания» в меню нет; прямой URL не открывает функционал (Vue-роутер → не туда/404); API напоминаний нет.
**Сверка (read-only):** на проде нет `ReminderController.php`, нет команды `RemindersDispatchDue.php`, нет строки `reminders:dispatch-due` в `console.php`, таблица `reminders` отсутствует (`\dt reminders` → пусто).
**Если не так:** раздел/таблица/эндпоинт ещё на месте → деплой G3 неполный, находка.
**Покрывает:** полное удаление напоминаний.

---

## Часть D — Отчёты (§8, пункт 7)

База — `ReportJobController` + `GenerateReportJob`. Гоняем на данных инъекции R2–R3. Метод: отчёт ↔ БД.

### RPT-TYPES — 4 типа × 3 формата, числа = БД
**§ref:** §8. **Действие:** в кабинете построй отчёты: сделки (deals_export), биллинг (billing_summary), источники (sources_summary), менеджеры (managers_summary) — каждый в CSV / XLSX / JSON.
**Ожидаем:** все 4 строятся; числа совпадают между форматами и с БД; **PDF — заглушка → штатный `failed`** (не виснет), клиенту не предлагаем.
**Сверка:** агрегаты отчёта = `SELECT … GROUP BY …` по `deals`/`lead_charges` за период; `report_jobs.status` = `completed` (pdf → `failed`).
**Если не так:** числа расходятся / PDF виснет → находка.
**Покрывает:** 4 типа, 3 формата, PDF-stub.

### RPT-QUEUE / RPT-DOWNLOAD / RPT-ISO — Квота, скачивание, изоляция
**§ref:** §8 (квота 3, signed-URL 24ч). **Ожидаем:** 4-й одновременный отчёт ждёт/отклоняется; повтор/отмена/удаление работают; signed-URL качает, по истечении 24ч — нет, чужой не скачает; **отчёт содержит только данные своего тенанта**.
**Сверка:** отчёт другого тенанта по прямой ссылке → 403/нет; содержимое = только `tenant_id=<свой>`.
**Если не так:** чужие данные в отчёте / чужой скачал → **СТОП**, критдефект изоляции.
**Покрывает:** квота, signed-URL, изоляция отчётов.

---

## Часть E — Импорт сделок из CSV (§8/§14, пункт 8)

База — `ImportController` + `ImportLeadsJob` + `HistoricalImportService` + `CsvLeadsParser` (9 колонок, дата `Y/m/d H:i:s`). Тест-файлами.

### IMP-OK — Импорт работает, БАЛАНС НЕ СПИСЫВАЕТСЯ
**§ref:** §8/§14. **Действие:** загрузи корректный CSV (`/api/imports`, экран «Импорт») → проверь ленту сделок и баланс.
**Ожидаем:** сделки появились в ленте, поля верные; **баланс НЕ изменился** (импорт истории денег не трогает — ключевая защита).
**Сверка:** баланс до==после; нет новых строк `lead_charges` по импортированным сделкам; импортированные сделки в `deals` с признаком импорта.
**Если не так:** импорт списал деньги → **СТОП**, критдефект.
**Покрывает:** импорт, неприкосновенность баланса.

### IMP-IDEMP / IMP-STATUS / IMP-VALID / IMP-ISO — Дубли, статусы, валидация, изоляция
**§ref:** §14 (`webhook_dedup_keys`), unknown-statuses. **Ожидаем:** повторная загрузка того же файла → без дублей (upsert); статус из файла вне воронки → мастер сопоставления на new/viewed/in_progress/won/lost (`/api/imports/unknown-statuses` → resolve); битый формат/колонки/дата/телефон → понятная ошибка, не падение; импортированные сделки только в своём тенанте.
**Сверка:** после повторной загрузки число сделок не выросло; `import_logs` показывает строки-ошибки; чужой тенант импортированных сделок не видит.
**Если не так:** дубли / падение на битом файле / утечка в чужой тенант → находка/критдефект (изоляция).
**Покрывает:** идемпотентность, мастер статусов, валидация, изоляция.

---

## Часть F — Расширенный охват (Раздел B) + НОВЫЕ фичи G6/G7

> Добавлено по ревью 21.06 ([acceptance-coverage-expansion-NEW.md](../specs/2026-06-21-acceptance-coverage-expansion-NEW.md) Раздел B). Части A–E были happy-path; здесь — edge-кейсы отчётов/импорта/онбординга **и полностью отсутствовавшие карточки G6 (публичный API) и G7 (поддержка/impersonation)** (планы №24/25/26). Каждая — по факту кода (file:line).

### RPT-LIFECYCLE — Отчёты: retry / cancel / destroy + квота-гонка
**§ref:** §8 (ReportJobController). **Действие/Ожидаем:**
- **retry:** провали отчёт → повтор → `retry_count`=1; повтор ещё → =2; 3-й повтор → 422 «лимит попыток» ([:217-223](../../../app/app/Http/Controllers/Api/ReportJobController.php#L217)); отчёт старше 7д → 422 «окно истекло» ([:225-231](../../../app/app/Http/Controllers/Api/ReportJobController.php#L225)); retry DONE-отчёта → 422; чужой → 403/404 ([:206-215](../../../app/app/Http/Controllers/Api/ReportJobController.php#L206));
- **cancel:** только PENDING → status FAILED «Отменено»; cancel PROCESSING → отказ ([:289-294](../../../app/app/Http/Controllers/Api/ReportJobController.php#L289));
- **destroy:** только terminal → файл удалён из storage + запись `PdAuditLogger`; destroy PENDING → отказ ([:330-350](../../../app/app/Http/Controllers/Api/ReportJobController.php#L330));
- **квота TOCTOU:** 2 параллельных store на границе 3-х активных → возможно 4 (count без лока, `:143-160`) — зафиксировать как ограничение, не критдефект.
**Покрывает:** §8 жизненный цикл отчёта.

### RPT-URL — Signed-URL: подмена / истечение / ретеншн
**§ref:** §8 (signed mw, 24ч). **Действие/Ожидаем:** подмени подпись/tenant в URL скачивания → 403 (`signed` HMAC, `:360`); валидный URL после 24ч → 403 (`:413`); скачать отчёт с истёкшим `expires_at` → 410 «недоступен/истёк» ([:380-384](../../../app/app/Http/Controllers/Api/ReportJobController.php#L380)); файл живёт 30д, URL 24ч — после истечения job всё ещё DONE, но `downloadUrl=null`.
**Покрывает:** §8 защита и ретеншн ссылки.

### IMP-PARSER — Импорт: правила парсера + дубль в файле
**§ref:** §14 (CsvLeadsParser). **Действие/Ожидаем (по строкам в одном файле):** <9 колонок / телефон не `^7\d{10}$` / дата не `Y/m/d H:i:s` / пустой статус / пустой проект → **каждая** даёт конкретную ошибку в `import_logs`, валидные строки всё равно импортируются ([CsvLeadsParser.php:47-104](../../../app/app/Services/Import/CsvLeadsParser.php#L47)); две строки с одним `source_crm_id` → **1 сделка** (advisory-lock upsert, `HistoricalImportService.php:148-169`), не 2.
**Покрывает:** §14 валидация по-правилам, in-file дедуп.

### IMP-PROVENANCE — Нулевая историческая транзакция + dry_run + загрузка
**§ref:** §14. **Действие/Ожидаем:**
- импорт N>0 строк → **одна** строка `balance_transactions` `type='historical_import', amount_rub=0` ([HistoricalImportService.php:255](../../../app/app/Services/Import/HistoricalImportService.php#L255)); импорт 0 валидных → строки НЕТ. ⚠️ **Поправка к плану №23/IMP-OK:** шаг «balance_transactions не выросло» неверен — растёт на 1 нулевую строку; деньги (`lead_charges`/баланс) НЕ трогаются;
- `dry_run=true` → счётчики посчитаны, 0 записей в `deals`/`webhook_dedup_keys`/`balance_transactions`;
- файл `.xlsx` или >10 МБ → 422 (`StoreImportRequest`).
**Покрывает:** §14 провенанс без списания, dry_run, лимиты загрузки.

### ONB-EDGE — Онбординг: старый код / not_found / dev-код на проде
**§ref:** §1 (RegistrationService). **Действие/Ожидаем:** после `resend` прежний код → 422 `too_many_attempts` (погашен, `:210-214`), годен только новый; `confirm` для email без pending-записи (уже активен) → 422 `not_found` (`:83-85`, отлично от `invalid_code`); ответ API на проде НЕ содержит `_dev_plain_code` (только local/testing, `:231` + `RegistrationController.php:45-46`).
**Покрывает:** §1 гашение кода, not_found, не-утечка dev-кода.

### REQ-EDGE — Реквизиты: сброс чипа / ИНН не найден / нормализация телефона
**§ref:** §1 (RequisitesService). **Действие/Ожидаем:** заполнить `bank_account` → чип «Готово»; очистить → `requisites_completed_at` снова NULL (пересчёт на каждый upsert, `:25`); `lookup-inn` несуществующего ИНН → `{found:false}`, ничего не сохранено (`TenantRequisitesController.php:44-45`); телефон `8 (912)…` сохраняется нормализованным, `isLightComplete` видит норму (`:18-20`).
**Покрывает:** §1 сброс готовности, found:false, нормализация.

### BELL-EDGE — Колокольчик: лимит/идемпотентность/чужой/фильтры дайджеста
**§ref:** §5 (InAppNotificationController). **Действие/Ожидаем:** `limit=0`/`101` → 422 (`:33-36`); mark-read дважды → `read_at` не меняется (идемпотентно, `:106-108`); mark-read/delete чужого id → 404 (`:102,161`); дайджест исключает `is_test` и soft-deleted сделки (`SendNewLeadsDigestJob.php:46-47`) — влей 1 норм + 1 is_test + 1 удалённую → в письме только 1.
**Покрывает:** §5 валидация/идемпотентность/изоляция колокольчика, фильтры дайджеста.

### API-KEY — G6: API-ключ (создание/ротация)
**§ref:** §10 G6 (ApiKeyController). **Действие:** Настройки → «API и Webhook» → создать/перегенерировать ключ.
**Ожидаем:** ключ `lpkapi_…` показан **один раз** (далее только префикс/маска, `:37,82`); один активный — перегенерация гасит прежний (`:47-51`); TTL 365д, scope `['read']` (`:61-62`); хранится хэшем.
**Сверка:** `SELECT key_prefix, scopes, expires_at, is_active FROM api_keys WHERE tenant_id=<t>` — один активный.
**Покрывает:** §10 жизненный цикл ключа.

### API-DEALS — G6: GET /api/v1/deals (пагинация/скоуп/изоляция)
**§ref:** §10 G6 (V1/DealsController, ApiKeyAuth). **Действие/Ожидаем:**
- `GET /api/v1/deals` с `Bearer lpkapi_…` → JSON `{data:[…], next_cursor}`; `limit=0→1`, `1000→500` (clamp `:25`); `since=мусор`→422, `cursor=мусор`→422 (`:27-46`);
- без ключа/битый/просроченный/отозванный → **401** (`ApiKeyAuth.php:26,34-35`); ключ без scope `read` → **403** (`:50-52`);
- `last_used_at`/`last_used_ip` пишутся после успешного запроса (`:54-57`);
- **только свои сделки** (RLS + явный where) — сверь число с лентой кабинета;
- rate-limit ПОКА НЕТ (на 19.06) — зафиксировать как известную поверхность brute/DoS.
**Если не так:** отдало чужие сделки → **СТОП**, критдефект изоляции.
**Покрывает:** §10 публичный API, изоляция, 401/403.

### SUPPORT — G7-A: заявка в поддержку
**§ref:** §10 G7-A (SupportRequestController). **Действие:** кабинет → «Помощь» → форма (Имя/Контакт/Сообщение, предзаполнены из профиля) → отправить.
**Ожидаем:** `201`, строка `support_requests` (`tenant_id`=свой) **создаётся всегда**, письмо на `support.email` — best-effort (сбой почты заявку не валит, `:47-51`); пусто → 422; message >5000 → 422 (`:26-28`); JivoSite-виджет виден при заданном `JIVO_WIDGET_ID`; RLS — клиент видит только свои.
**Сверка:** `SELECT tenant_id FROM support_requests WHERE id=<r>` = свой.
**Покрывает:** §10 поддержка, best-effort письмо, валидация, RLS.

### IMPERS — G7-B: impersonation (клиентская сторона + границы машинного ключа)
**§ref:** §10 G7-B (ImpersonationController/Context). **Действие/Ожидаем:**
- админ/ИИ: запрос (`reason≥30`→иначе 422, `:120-125`) → клиенту письмо с 6-зн. кодом (TTL 15м); ввод кода → сессия; у клиента в кабинете **красная плашка** «режим поддержки [Выйти]» (из `/api/auth/me .impersonation`, `AuthController.php:134-150`);
- **5 неверных кодов** → инвалидация (`:192-205`); **авто-истечение 60 мин** → следующий запрос 401 «сессия истекла» + endSession + письмо `ImpersonationEndedMail` (`ImpersonationContext.php:35-48`); кнопка «Выйти» завершает (идемпотентно: повтор end → 422);
- **машинный ключ `lpimp_`:** `/api/admin/*` → **403** (`EnsureSaasAdmin.php:39-43`); `/api/api-keys`, `/api/billing` (topup) → **401** (нет гарда impersonation на роутах); **`/api/billing/charges` ЧИТАЕТСЯ** ключом (гард есть, read-only ленты) — осознать; чужой/битый Bearer → 401;
- 2 письма клиенту (код-согласие + завершение).
**Если не так:** `lpimp_` прошёл в админку/topup / плашки нет / сессия не истекает → дефект безопасности.
**Покрывает:** §10 impersonation клиент-сайд + границы ключа.

## Матрица покрытия R3b
| Пункт хартии / находка | Карточки |
|---|---|
| G1 самозапись (§1) | ONB-REG, ONB-CODE, ONB-ATTEMPTS, ONB-RESEND, ONB-CAPTCHA/DUP |
| G1 реквизиты-гейт + ИНН/DaData | ONB-REQ-GATE, ONB-INN, ONB-REQ-FULL |
| G2 колокольчик + дайджест (пункт 5) | BELL-NEW, DIGEST-ON, DIGEST-WINDOW/OFF, PUSH-DEAD |
| G3 напоминания убраны (пункт 6) | REM-GONE |
| Отчёты (пункт 7) | RPT-TYPES, RPT-QUEUE/DOWNLOAD/ISO, **RPT-LIFECYCLE, RPT-URL** |
| Импорт CSV (пункт 8) | IMP-OK, IMP-IDEMP/STATUS/VALID/ISO, **IMP-PARSER, IMP-PROVENANCE** |
| Онбординг/реквизиты edges (Раздел B) | ONB-EDGE, REQ-EDGE |
| Колокольчик/дайджест edges (Раздел B) | BELL-EDGE |
| G6 публичный API + ключи (план №26) | API-KEY, API-DEALS |
| G7-A поддержка (план №24) | SUPPORT |
| G7-B impersonation клиент-сайд (план №25) | IMPERS |

**Чек-лист «не пропущено»:** ✅ онбординг (+edges) · ✅ колокольчик+дайджест (+edges) · ✅ напоминания убраны · ✅ отчёты+изоляция (+жизненный цикл/URL) · ✅ импорт (+парсер/провенанс) · ✅ **G6 API · G7 поддержка · G7 impersonation** (Часть F). **Деньги/распределение/сделки/изоляция движка — в R3. Слой D (нагрузка/железо) — в R4.**

---

## Заметки для слоя D (передать в R4)
- **H1 закрыт:** синхронного письма в транзакции доставки больше нет (email → 30-мин дайджест-джоб). Замер слоя D пересмотреть — главное узкое место устранено; осталась капасити-связь (дайджест-джоб и freeze/unfreeze-синки делят единственный воркер с раздачей, но **вне** транзакции доставки).
- Дайджест-джоб сам шлёт письма синхронно (`Mail::send`) внутри своего запуска раз в 30 мин — при большом числе тенантов с подпиской это пакет на единственном воркере; не блокирует доставку, но занимает воркер. Учесть в D3/D4.

---

## Troubleshooting — типовые операционные затыки прогона
| Симптом | Вероятная причина | Что делать |
|---|---|---|
| Код подтверждения не пришёл на почту | На проде кода в ответе API нет; письмо в очереди / медленный SMTP | Подожди; проверь живой ящик (`kdv1@bk.ru`/`stels_info@bk.ru`); «отправить повторно» (ONB-RESEND). Письмо ставится в очередь — нужен живой воркер `liderra-queue` |
| Регистрация падает на капче в браузере | На проде реальный капча-провайдер (в dev — `NullCaptchaVerifier`) | Пройти капчу руками; для автотеста — провижининг тест-клиента через SQL (R1), не через самозапись |
| `lookup-inn` вернул `found:false` | DaData выключена/исчерпан бюджет (`services.dadata.enabled`, budget-guard) | Не критдефект; заполнить реквизиты вручную; пометить как находку «ИНН-подтяжка недоступна» |
| Дайджест не пришёл, хотя лиды влиты | Окно «последние 30 мин» ещё не отработано кроном; или `new_lead.email=false` у юзера | Дождаться 30-мин крона ИЛИ диспатчить `SendNewLeadsDigestJob` вручную; проверить `notification_preferences->'new_lead'->>'email'` |
| Прямой URL `/reminders` что-то открывает | Деплой G3 неполный | СТОП — карточка REM-GONE провалена, доложить владельцу |
| `psql` под `ubuntu` = «role does not exist» | У ssh-пользователя нет роли в БД | Читать через `sudo -u postgres psql liderra -c "SELECT…"` (под снятой стеной доступно) |
| Тест-лид не доставился вовсе | Нет строки слепка за активную дату / лимит/баланс/пауза | См. R2 (инъекция) и R3 §19.4 — проверить слепок и 7 условий кандидата |

## Rollback / teardown (после прогона R3b)
- **Полный teardown — в [R5](2026-06-18-acceptance-r5-finalize-teardown.md)** (строгий порядок: поставщик → слепки → тест-тенанты → Redis → проверка). Здесь — что родил именно R3b:
  - **Самозапись** создаёт реальные тенанты+юзеры → попадают под DELETE по маркеру `TEST-` (тест-email завести с распознаваемым доменом/именем, чтобы зацепить teardown-скриптом). Связанные `email_verifications`, `tenant_requisites` — каскадом.
  - **Реквизиты** (`tenant_requisites`) и **импорт** (`deals` + `import_logs` + `webhook_dedup_keys`) — чистятся вместе с тенантом.
  - **Отчёты** (`report_jobs` + файлы в storage) — удалить за тест-тенанты; signed-URL станут недействительны.
  - **Дайджест-письма** уже ушли на живой ящик — необратимо (это ок, тест-ящики наши).
- **Критдефект по ходу** (деньги/изоляция): НЕ чинить на боевом — зафиксировать «ожидали/получили/факт из БД», остановить пункт, доложить (см. Escalation).

## Escalation (safety-stop хартии)
| Ситуация | Кому | Как |
|---|---|---|
| Деньги расходятся / списание при импорте / двойное списание | Владелец | Стоп пункта, зафиксировать факт из БД, доложить — не чинить на боевом |
| Изоляция течёт (чужие данные/отчёты видны, чужой ID отдаёт объект) | Владелец | Немедленный стоп, доложить |
| Любое действие может задеть `lkomega`/tenant 2 или живую поставку | Владелец | Немедленный стоп, не продолжать |
| Деплой G1/G2/G3 неполон (§0 / REM-GONE провалены) | Владелец | Стоп прогона R3b, вернуть на деплой |

## История прогонов
| Дата | Кто | Заметки |
|---|---|---|
| 19.06.2026 | — | Ранбук написан по реальному прод-коду (`01a9029c`) и выверен скилом `operations:runbook`. Прогон ещё не выполнялся. |
