# Единое поведение двух балансовых замков (F/J) — дизайн

**Дата:** 2026-06-23 · **Где работаем:** только докалка (локальный dev), боевой не трогаем.

## Цель

Свести два балансовых замка Лидерры — клиентскую заморозку (`tenants.frozen_by_balance_at`) и проектный блок (`projects.preflight_blocked_at`) — к единому согласованному поведению:

1. один расчёт «хватает ли денег на весь заказ» во всех точках (полный дневной лимит, прогрессивная цена от текущего месячного объёма клиента);
2. пополнение баланса снимает **оба** замка вместе, сразу (политика «всё-или-ничего»);
3. вечерний пересчёт в 18:00 МСК тоже снимает блоки проектов, если клиент по итогу не заморожен;
4. явная иерархия: клиентская заморозка — главный замок, проектный блок имеет смысл только когда клиент активен.

Закрывает пункты F и J из `docs/superpowers/findings/2026-06-23-balance-block/HANDOFF.md`.

## Текущее состояние (как есть) {#D1}

**Замок 1 — клиентская заморозка** `tenants.frozen_by_balance_at`. Накрывает тенанта целиком. Ставит/снимает только вечерний пересчёт `BalancePreflightSweepJob` (cron 18:00 МСК, `routes/console.php`). Пока стоит — `LeadRouter` не маршрутизирует лиды тенанту, все его проекты получают `paused_at` (откатывается при разморозке фильтром `paused_at >= frozen_at_was`, чтобы сохранить ручные паузы). Расчёт «сколько нужно» — `Tenant::requiredLeadsForTomorrow()`, **share-aware** (R-19, Stage 4): доля группового заказа поставщика, а не полный лимит.

**Замок 2 — проектный блок** `projects.preflight_blocked_at`. Накрывает один проект. Ставится только в `ProjectController` (store/update), когда клиент при создании/правке продавил «сохранить приостановленным» (лимит больше доступного баланса). Снимается только пополнением — `ProjectBlockReleaseService::releaseForTenant`, вызывается из `BillingTopupService::topup`, политика «всё-или-ничего», расчёт **по полному лимиту** (`sum(daily_limit_target)`).

**Фильтр snapshot/sync** — оба флага консистентно исключают проект из заказа поставщику в 5 местах: `SnapshotProjectRoutingJob`, `SnapshotRebuildCommand`, `SnapshotBackfillCommand`, `SyncSupplierProjectsJob`, `BalancePreflightSweepJob::dispatchSupplierSyncIfOnline`.

**Контракт раздела:** это инвентарь существующего поведения; правки разделов D2–D6 не должны нарушить ни один из 5 фильтров snapshot/sync и не должны менять момент простановки `paused_at` при заморозке.

**Edge-case:** вечерний пересчёт идёт в системном контексте без RLS-middleware → `app.current_tenant_id` выставляется через `SET LOCAL` внутри транзакции (`evaluateTenant`). Любой новый код, читающий `projects` под тенантом в пересчёте, обязан исполняться внутри этой же транзакции.

## Единый расчёт «хватает ли денег» {#D2}

**Контракт.** Во всех точках, где решается «хватает ли баланса на весь заказ тенанта», используется одинаковый расчёт:

- **required (нужно лидов)** = полный дневной лимит всех активных проектов тенанта = `sum(daily_limit_target where is_active=true)`. Без share-aware доли.
- **capacity (потяну лидов)** = `BalanceToLeadsConverter::convert(balance_rub, deliveredInMonth, tiers)` — прогрессивно по ступеням, **от текущего месячного объёма** клиента (Чтение А, существующее поведение конвертера; не меняем).
- **passes** = `capacity >= required`.

**Точки применения (должны давать одинаковый вердикт при одних данных):** снятие проектного блока при пополнении (B/D3), снятие проектного блока в пересчёте 18:00 (F/D4), клиентская заморозка/разморозка в пересчёте 18:00, джоб писем-напоминаний `BalanceFrozenReminderJob`.

**Конвенция (выбран один способ).** Единый источник расчёта required — метод `Tenant::requiredLeadsForTomorrow()`: его тело переводится с share-aware (R-19) на полный лимит `sum(daily_limit_target where is_active=true)`. Все существующие вызовы (`BalancePreflightSweepJob`, `BalanceFrozenReminderJob`) автоматически получают единый расчёт без правок на местах; сервис разблокировки (D6) тоже считает required через этот же метод. Новый хелпер не вводим — это лишняя сущность (YAGNI).

**Последствие (осознанный откат R-19).** Сейчас заморозка считает «по доле»: клиент, делящий номер-источник с другими, требует меньше баланса. Переход на полный лимит это убирает. Пример: 3 клиента делят один номер, лимит каждого 30 → сейчас required=10 на каждого, после правки required=30 (≈×3). Такие клиенты замораживаются строже. Решение владельца зафиксировано: «везде, единый расчёт по полному лимиту».

**Критерий приёмки D2:** тест показывает, что при одинаковых `balance/delivered/tiers/limits` заморозка-пересчёт и сервис разблокировки дают один и тот же `passes`; обновлённый `TenantPreflightTest` отражает полный лимит (старые share-aware ожидания заменены).

## Пополнение снимает оба замка вместе (B) {#D3}

**Контракт.** После зачисления баланса (`BillingTopupService::topup`, единая точка ручного и онлайн-пополнения) выполняется единая разблокировка по тенанту:

- если `passes` (D2) — **сразу** снять клиентскую заморозку (если стояла) И снять `preflight_blocked_at` со всех заблокированных активных проектов; диспатчить sync к поставщику для затронутых проектов так же, как делает разморозка в пересчёте (только в online-режиме; в batch — заказ уедет в 18:05);
- если не `passes` — не трогать ни один замок; вернуть дефицит для сигнала «не хватает X лидов».

**Edge-cases:**
- разморозка тенанта при пополнении повторяет существующую механику пересчёта: очистка `frozen_by_balance_at`, откат `paused_at >= frozen_at_was` через `pgsql_supplier`, письмо `BalanceUnfrozenMail`;
- нет заморозки, но есть блоки проектов и `passes` → снять только блоки (как сейчас E);
- нет ни заморозки, ни блоков → no-op;
- топап не вводит баланс в минус и работает внутри транзакции под `lockForUpdate` (существующий контракт `topup` сохранить).

**Критерий приёмки D3:** тест «замороженный тенант + заблокированный проект → пополнение достаточной суммой снимает оба замка в одном вызове topup»; тест «недостаточная сумма не снимает ничего».

## Пересчёт 18:00 снимает блоки проектов (F) {#D4}

**Контракт.** В `BalancePreflightSweepJob::evaluateTenant`, после решения по клиентской заморозке: если тенант по итогу **не заморожен** (`passes`, заморозка снята или не ставилась) — снять `preflight_blocked_at` со всех заблокированных активных проектов тенанта (та же единая разблокировка, что и при пополнении) и диспатчить sync. Если тенант заморожен — блоки проектов не трогать.

**Edge-cases:**
- блоков нет → no-op;
- тенант только что разморожен в этом же прогоне → разблокировка проектов идёт следом, в той же транзакции/контексте тенанта;
- идемпотентность: повторный прогон без изменений баланса ничего не делает.

**Критерий приёмки D4:** тест «активный тенант с заблокированным проектом и достаточным балансом → прогон пересчёта снимает блок проекта»; тест «замороженный тенант → прогон не снимает блок проекта».

## Иерархия двух замков (J) {#D5}

**Контракт (инвариант).** Клиентская заморозка — главный замок; проектный блок подчинён ей:

- тенант заморожен ⟹ блоки проектов не снимаются (всё и так на паузе, снятие бессмысленно и небезопасно);
- тенант активен ⟹ ни один проект не должен оставаться заблокированным по балансу (D3 закрывает путь пополнения, D4 — путь пересчёта);
- поскольку расчёт единый (D2), условие одно: `passes ⟺ тенант активен ⟺ блоков проектов быть не должно`.

**Конвенция.** Простановку `preflight_blocked_at` (create/update в `ProjectController`) этот дизайн не меняет — блок по-прежнему ставит только осознанное «сохранить приостановленным». Меняется только согласованное **снятие**.

**Критерий приёмки D5:** инвариант покрыт тестами D3/D4; отдельный тест не нужен, но в обзоре проверить, что нет пути, снимающего блок проекта при заморожённом тенанте.

## Общий сервис разблокировки {#D6}

**Конвенция (архитектура).** Чтобы пополнение (D3) и пересчёт (D4) не дублировали логику пауз/писем/пересинка, путь «разморозить тенанта + снять блоки проектов» живёт в одном месте — расширенный `ProjectBlockReleaseService` (или переименованный `BalanceUnlockService`) с методом, который:

1. считает `passes` единым расчётом D2;
2. при `passes` — снимает клиентскую заморозку (если стояла, с откатом `paused_at` и письмом) и снимает блоки всех заблокированных проектов;
3. диспатчит sync затронутым проектам по правилам режима поставщика (online сразу, batch в 18:05);
4. возвращает структуру результата (снято замков/проектов, passes, deficit).

**Что остаётся в `BalancePreflightSweepJob`:** только направление заморозки (active→frozen) с простановкой `paused_at` и письмом `BalanceFrozenMail`; направление разморозки и снятие блоков проектов делегируются общему сервису.

**Edge-case совместимости:** сервис обязан корректно работать в двух контекстах — HTTP-запрос топапа (RLS-контекст от middleware `tenant`) и системный пересчёт (`SET LOCAL app.current_tenant_id` в транзакции `evaluateTenant`). `paused_at`-апдейт идёт через `pgsql_supplier` (BYPASSRLS worker), как в текущем пересчёте.

**⚠️ Межсессионный дедлок (найден на живом прогоне 23.06, исправлено).** `BillingTopupService::topup` держит `FOR UPDATE` на строке tenant (`lockForUpdate`) на протяжении всей HTTP-транзакции. Таблица `balance_freeze_log` FK-ссылается на `tenants` → её `INSERT` берёт `FOR KEY SHARE` на ту же строку tenant. Если этот `INSERT` идёт через **отдельное** соединение `pgsql_supplier`, он ждёт освобождения `FOR UPDATE` от главной транзакции, а та не завершится, пока не вернётся запрос → вечное самоблокирование (подтверждено `pg_blocking_pids` на докалке; в тестах замаскировано — `SharesSupplierPdo` схлопывает оба соединения в одно). **Решение:** `balance_freeze_log` INSERT в сервисе пишется через **главное** соединение (та же транзакция, что держит лок → FK-лок совместим). RLS соблюдена (`app.current_tenant_id` выставлен в обоих контекстах), INSERT-грант есть у app-ролей. `paused_at` — UPDATE не-FK-колонки, родителя не лочит, поэтому остаётся на `pgsql_supplier`. **Деплой:** при накате убедиться, что роль планировщика (`DB_USERNAME` на проде) имеет INSERT на `balance_freeze_log` (есть у `crm_app_user`/`crm_migrator`/`crm_app_admin`/`crm_supplier_worker`).

**Критерий приёмки D6:** один путь кода покрывает оба сценария (топап и пересчёт) — отсутствие дублирующей разморозки/снятия в двух местах подтверждается обзором; тесты D3 и D4 зелёные через общий сервис.

## Тестирование, критерии и затронутая совместимость {#D7}

**Метод (по каждому пункту):** TDD — сначала падающий тест, затем минимальная правка, затем GREEN; затем визуальная проверка в браузере на докалке (пополнил → заморозка и блоки ушли; прогон пересчёта → блок снят) со скриншотами с нашей стороны и со стороны поставщика, если затронут заказ.

**Затронутые существующие тесты/код (ожидаемые правки):**
- `tests/Feature/Billing/TenantPreflightTest.php` — share-aware ожидания (R-19) заменяются на полный лимит;
- `BalanceFrozenReminderJob` — переходит на единый расчёт required (дефицит письма должен совпадать с расчётом заморозки);
- `ProjectBlockReleaseService` / `BillingTopupService` / `BalancePreflightSweepJob` — правятся по D2–D6;
- проверить, что 5 фильтров snapshot/sync (D1) не задеты.

**Критерий приёмки D7 (общий):** регрессия биллинга зелёная; новые тесты D2–D6 зелёные; визуальные сценарии подтверждены скриншотами; ни один из 5 фильтров snapshot/sync не сломан.

```verified-context-json
[
  {"id":"D1","kind":"EXTRACTED","ref":"app/app/Jobs/Billing/BalancePreflightSweepJob.php","anchor":"private function evaluateTenant"},
  {"id":"D2","kind":"EXTRACTED","ref":"app/app/Models/Tenant.php","anchor":"public function requiredLeadsForTomorrow"},
  {"id":"D3","kind":"EXTRACTED","ref":"app/app/Services/Billing/BillingTopupService.php","anchor":"$this->blockRelease->releaseForTenant"},
  {"id":"D6","kind":"EXTRACTED","ref":"app/app/Services/Billing/ProjectBlockReleaseService.php","anchor":"public function releaseForTenant"}
]
```
