# Спек C — Биллинг v2: preflight баланса + VTB-эквайринг

**Дата:** 2026-05-24
**Статус:** Design (awaiting user review)
**Автор:** Claude Opus 4.7 (под руководством заказчика)
**Брейнсторм:** сессия 24.05.2026
**Триггер:** «preflight баланса перед заказом у поставщика, чтобы не заказать лишнего; VTB-эквайринг для пополнения баланса» (заказчик 23.05.2026, расширено в брейнсторме 24.05).

**Часть серии из 3 спеков:**

- Спек A — единый ₽-баланс (DONE на проде, [спек](2026-05-23-billing-v2-spec-a-balance-rub-design.md)).
- Спек B — политика дублей (DONE на проде, [спек](2026-05-23-billing-v2-spec-b-duplicates-design.md)).
- **Спек C (этот)** — preflight баланса + VTB-эквайринг.

---

## §1. Контекст и проблема

### §1.1 Текущее поведение портала

**Расчёт заказа у поставщика** ([app/app/Services/Supplier/SupplierQuotaAllocator.php:88-98](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php#L88-L98)):

```
order = max(самый_большой_лимит, ceil(сумма_лимитов ÷ 3))
```

где входной массив — `daily_limit` всех eligible-на-сегодня проектов клиентов на источнике (источник = тег × субъект). Затем `order` делится между площадками B1/B2/B3 (`distributeForPlatform`, largest-remainder).

**Allocator не смотрит баланс.** На вход принимает только лимиты проектов. Если у клиента нулевой баланс — он всё равно учитывается в формуле, значит портал заказывает у поставщика лиды, которые этот клиент оплатить не сможет.

**Списание с клиента** ([app/app/Services/Billing/LedgerService.php](../../../app/app/Services/Billing/LedgerService.php)) происходит при доставке (`RouteSupplierLeadJob` создаёт `Deal` → `LedgerService::chargeForDelivery`). Если баланса не хватает — после Спека A не было защиты «на входе»; шёл лид, списывалось, баланс уходил в ноль.

**Пополнение баланса** ([app/app/Services/Billing/BillingTopupService.php](../../../app/app/Services/Billing/BillingTopupService.php)) — MVP-stub: мгновенно кредитует `balance_rub` + пишет `balance_transactions(type='topup')`. Реальной оплаты нет. UI — `AdminTenantsController::adjustBalance` (admin-only).

### §1.2 Проблемы

**Преfflight:**

1. **Портал переплачивает поставщику** за лиды клиентов, у которых нет денег. Это прямой убыток — закупка оплачена, а продажа не состоится.
2. **Нет проактивной защиты при создании/изменении проектов.** Клиент может выставить лимит, который сам по себе не оплачиваем. Сейчас проблема всплывает только в момент списания.
3. **Нет ясной коммуникации с клиентом** «у тебя баланса хватит на X лидов, ты заказал Y, нужно пополнить или сократить» — клиент узнаёт по факту остановки списания.

**VTB-эквайринг:**

4. **Реального пополнения нет** — `BillingTopupService` это заглушка. Деньги попадают на баланс только через ручное действие админа (`AdminTenantsController::adjustBalance`).
5. **Нет 54-ФЗ фискализации** при ритейл-платежах (после подключения карт/СБП — обязательно).

### §1.3 Триггер

Заказчик 23.05.2026: «preflight баланса перед заказом у поставщика; VTB-эквайринг; аудит раздела Биллинг» → в брейнсторме 24.05 уточнено как один спек с детальной механикой preflight + полный охват трёх методов оплаты (безнал, СБП, карты).

**Главный принцип** (заказчик 24.05): «**не заказать лишнего у поставщика — это убыток**».

---

## §2. Scope

### §2.1 Что делаем

**Преfflight баланса:**

- Расширение `SupplierQuotaAllocator` для учёта баланса клиента (фильтрация eligible-проектов до `computeOrder`).
- Активная проверка при создании/правке проекта в личном кабинете (диалог выбора).
- Активная проверка перед cut-off (18:00 MSK ежедневно) — для пассивного износа баланса.
- UI-баннер «приём приостановлен» в личном кабинете клиента.
- Уведомления по email с правильной частотой (1 + 1д + 3д + «возобновлено»).
- Журналирование событий заморозки/разморозки в `balance_transactions` или новой таблице.

**VTB-эквайринг:**

- Архитектура (интерфейс `TopupGateway` + три реализации: `BankTransfer`, `SBP`, `Card`).
- **Полная реализация Безнала** (генерация PDF-счёта, журнал «ожидающих платежей» в админке, авто-поиск через VTB Бизнес API с подтверждением человеком, режимный переключатель «автомат / с подтверждением», часовые email-алерты).
- **Dev-заглушки для СБП и Карт** (мгновенное подтверждение в dev/test, реальные эндпоинты VTB — после Б-1).
- **Архитектура 54-ФЗ** через ОФД-Атол (заглушка в dev, реальная интеграция после Б-1 параллельно с СБП).

### §2.2 Что НЕ делаем (явно out of scope)

- **Реальное подключение VTB Acquiring и СБП** — требует реквизитов ООО (P0-блокер Б-1). Отдельные задачи после Б-1.
- **Реальная интеграция ОФД-Атол** — параллельно с СБП после Б-1.
- **Авто-сверка с банковской выпиской VTB** (`/api/vtb-business`) — отдельная задача после Б-1; в этом спеке только архитектурный интерфейс и ручной режим.
- **«Отдать разморозившемуся клиенту лиды, уже купленные сегодня, через шеринг»** — отложено в Спек D (см. §8).
- **Возвраты пополнений** (refund) — не реализуем (Спек A: «возвраты не делаем»).
- **Recurring-платежи** (автосписание) — не реализуем.
- **Изменение формулы `computeOrder`** — формула остаётся прежней, преfflight только фильтрует входной список.

---

## §3. Решение — часть 1: Преfflight баланса

### §3.1 Главный инвариант

**Баланс клиента никогда не уходит в минус.** Гарант — преfflight, который проверяет «хватит ли на полный дневной заказ» **до** того, как заказ уйдёт поставщику. Если хватает — клиент в заказе; если поставщик пришлёт меньше планируемого (норма), остаток баланса уходит в следующий день.

### §3.2 Когда срабатывает преfflight

**Одна основная точка:** ежедневный cut-off в **18:00 MSK** (включая выходные).

Между cut-off и cut-off (всё внутри текущего дня) никаких внутридневных стопов нет. Лиды, заказанные у поставщика на сегодня, идут клиенту полностью, списываются с его баланса как поступают. Защита от ухода в минус — на стороне cut-off предыдущего вечера.

**Дополнительные триггерные точки** (для UX в личном кабинете, не для блокировки заказа):

- Создание нового проекта в UI клиента.
- Изменение лимита существующего проекта в UI клиента.
- Активация ранее приостановленного проекта.
- Пополнение баланса (для авто-разморозки при следующем cut-off).
- Снижение/удаление проекта (может вернуть в зелёную зону).

### §3.3 Что значит «баланса хватает»

Сравнение делается **в лидах**, не в рублях, потому что в existing-сервисе `BalanceToLeadsConverter` (от Спека A) есть прямой расчёт «сколько лидов даст баланс с учётом 7 ступеней и уже отгруженного объёма за месяц»:

```php
$capacity = $converter->convert(
    balanceRub: $tenant->balance_rub,
    deliveredInMonth: $tenant->delivered_in_month,
    tiers: $activePricingTiers
)['leads'];

$requiredLeads = $tenant->projects()
    ->where('status', 'active')
    ->where('eligible_tomorrow', true)
    ->sum('daily_limit');

$passes = $capacity >= $requiredLeads;
```

Если `passes=true` — клиент проходит преfflight. Если `false` — не проходит.

7-ступенчатый расчёт уже реализован в `BalanceToLeadsConverter::convert` (Спек A) — он сам пройдёт по ступеням, учтёт «текущую» (где сейчас клиент в накопленном объёме) и переход на следующие при росте.

**NB:** проверяется на **полный лимит** проектов, не на «уже отгруженное + остаток сегодняшнего дня». Это потому, что преfflight работает один раз перед формированием заказа на завтра, а не во время выдачи.

### §3.4 Что делает портал при создании/правке «перегруженного» проекта

В UI клиента при попытке сохранить проект, после которого сумма `daily_limit` всех eligible-проектов превысит «потолок баланса»:

**Модальный диалог** (не блокирующая ошибка):

```
Этот лимит превышает твой баланс.
У тебя на счёте 1000₽ = 30 лидов по текущему тарифу.
После сохранения этого проекта сумма лимитов будет 40 лидов.
Не хватает: 10 лидов.

Чтобы он начал работать, нужно одно из:
  • Пополнить счёт (примерно 350₽ покроют 10 лидов недостачи)
  • Поставить лимит этого проекта 0
  • Уменьшить лимиты других проектов

[Сохранить и приостановить только этот] [Поставить лимит 0] [Отмена]
```

- **«Сохранить и приостановить только этот»** — проект сохраняется с исходным лимитом, но при следующем cut-off исключается из расчёта заказа на завтра (`active_today = false`). Остальные проекты клиента работают как обычно.
- **«Поставить лимит 0»** — проект сохраняется с лимитом 0 (фактически выключен). Не идёт в заказ.
- **«Отмена»** — изменения отбрасываются.

**Ключевое:** заморозка точечная, **только перегружающий проект**. Не «весь тенант». Это позволяет клиенту работать над созданием 20-30 проектов поэтапно, постепенно понимая «нужно столько-то ₽ для запуска всех».

### §3.5 Что делает портал при пассивном износе баланса

Клиент не правил проекты, просто баланс таял по дням. На очередном cut-off (18:00 MSK) выясняется, что баланс уже не покрывает все активные проекты.

**Действие:**

1. **Все проекты клиента** исключаются из расчёта заказа на завтра.
2. На `tenants` устанавливается флаг `frozen_by_balance_at = now()` (новая колонка).
3. На email клиента — письмо «Приём лидов приостановлен» (детали §3.7).
4. В личном кабинете — красный баннер на всех страницах (детали §3.6).

Клиент сам выбирает что делать в личном кабинете:

- Пополнить баланс (откроет UI «Пополнение», см. часть 2).
- Снизить лимиты на проектах (UI «Проекты»).
- Выключить часть проектов (paused).
- Любое сочетание.

Как только сумма лимитов снова влезает в баланс (после пополнения, снижения лимита, или выключения проектов) — `frozen_by_balance_at = NULL`, баннер исчезает, отправляется письмо «Возобновлено» (если успели до 18:00 — клиент в завтрашнем заказе; если позже — в послезавтрашнем).

### §3.6 UI личного кабинета клиента

**Красный баннер на всех страницах** (компонент `BalanceFrozenBanner.vue`) когда `tenant.frozen_by_balance_at IS NOT NULL`:

```
🔴 Приём лидов приостановлен
Не хватает баланса на дневной заказ. Нужно ещё 380₽ (или сократи лимиты на 10 лидов).
[Пополнить счёт] [Перейти к проектам]
```

**Постоянная подсказка под балансом** (даже когда не в заморозке) — компонент `BalanceCapacityIndicator.vue`:

```
Баланс: 1000₽ = до 30 лидов по тарифу
Проекты заказывают: 25 лидов в день
✅ Хватит на ~1.2 дня
```

В состоянии «хватает на меньше 3 дней» — жёлтый цвет с подсказкой «скоро потребуется пополнение». В состоянии «не хватает» — красный (баннер выше).

### §3.7 Email-уведомления

**При входе в заморозку:**

- T+0 (сразу) — `BalanceFrozenMail` («Приём лидов приостановлен»).
- T+24ч (если ещё в заморозке) — `BalanceFrozenReminderMail` («Всё ещё приостановлено»).
- T+72ч (если ещё в заморозке) — `BalanceFrozenFinalMail` («Приостановлено 3 дня»).
- Дальше — тишина до следующего цикла (если разморозится и снова попадёт — счёт идёт заново).

**При выходе из заморозки:**

- T+0 — `BalanceUnfrozenMail` («Приём возобновлён»).

Все письма throttled по `tenant_id` через `mail_log` (паттерн `ZeroBalancePausedMail` из Plan 4) — повторов не будет.

### §3.8 Cut-off режимы синхронизации с поставщиком

Сохраняем оба существующих режима (admin-переключатель уже есть):

- **Онлайн-режим** (сейчас, для малого числа клиентов): любое изменение в проектах Лидерры → немедленный апдейт на сервере поставщика (`SyncSupplierProjectJob` per-project). Поставщик сохраняет, использует в своём 21:00 слепке.
- **Batch до 18:00** (на будущее при росте): накопленные изменения уезжают одним пакетом перед 18:00 (`SyncSupplierProjectsJob` daily cron).

**Преfflight работает одинаково в обоих режимах** — он только меняет, какие проекты идут в `SyncSupplierProjectJob` (исключает frozen-проекты из `active_today`). Дальше — стандартный механизм синхронизации.

### §3.9 Граничные случаи

| Случай | Поведение |
|---|---|
| Ретро-операция (CSV-импорт исторических лидов) между 18:00 и началом следующего дня списывает баланс ниже плана | Допускается, но админ предупреждается в UI «эта операция может вывести клиента в заморозку, продолжить?». Если согласился — выполняется; на следующем cut-off клиент будет в заморозке. Минусовых балансов не возникает (CSV-импорт делает обычные `lead_charges` через `LedgerService`, который остаётся защищён от минуса) |
| Ручная правка баланса админом (`adjustBalance`) уменьшает баланс ниже плана | Аналогично — админ предупреждается, ответственность на нём. Преfflight отработает на следующем cut-off |
| Клиент уже в минусовом балансе на момент запуска преfflight (legacy состояние) | Одноразовая artisan-команда `billing:preflight-initial-sweep` — проходит по всем тенантам, помечает `frozen_by_balance_at` где нужно, отправляет письма с пояснением «у вас активирована новая защита баланса». Запускается один раз при выкатке миграции |
| Тарифная ступень меняется в течение дня (накопился объём) | Преfflight на 18:00 MSK использует **текущую** ступень. На завтра ступень может быть другой — но это уже зона следующего cut-off |
| Поставщик прислал меньше планируемого (норма) | Остаток баланса клиента — экономия для следующего дня. Никаких корректировок |
| Клиент пополнил после 18:00 | В сегодня-в-21:00-слепок поставщика не успевает, но в личном кабинете тут же «Возобновлено». В следующий вечерний cut-off — в заказ на послезавтра |

### §3.10 Шеринг с другими клиентами на том же источнике

**Формула** (живёт в `SupplierQuotaAllocator::computeOrder`, [код](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php#L88-L98)):

```
order = max(самый_большой_лимит, ceil(сумма_лимитов ÷ 3))
```

Преfflight **не меняет формулу**, а **фильтрует входной массив `daily_limits`** — выкидывает клиентов, не прошедших проверку.

**Эффект зависит от того, кого выкинули:**

| Кто выкинут | `max(...)` | Заказ у поставщика | Маржа портала |
|---|---|---|---|
| Крупнейший клиент группы | Падает (новый крупнейший меньше) | **Уменьшается** — реальная экономия закупки | Падает |
| Любой некрупный | Не меняется | **Не меняется** | Падает на лимит выкинутого |

**Гарантии:**

1. **Никогда не вредит** другим клиентам в группе — их лимиты неприкосновенны.
2. **Никогда не заказывает у поставщика на «бедного»** — он исключён из формулы.
3. **Реальная экономия закупки** только при выкидывании крупнейшего. В остальных случаях защищаемся от логической ошибки «заказали лиды, оплатить которые некому» без эффекта на закупку.

### §3.11 Журналирование

При каждом срабатывании преfflight (заморозка / разморозка) — строка в новой таблице `balance_freeze_log`:

```sql
CREATE TABLE balance_freeze_log (
    id BIGSERIAL PRIMARY KEY,
    tenant_id BIGINT NOT NULL REFERENCES tenants(id),
    event_type VARCHAR(20) NOT NULL,  -- 'frozen' | 'unfrozen' | 'project_overload_dialog'
    triggered_by VARCHAR(30) NOT NULL,  -- 'cutoff_18msk' | 'project_update' | 'topup'
    balance_rub_at_event DECIMAL(12,2) NOT NULL,
    required_rub_at_event DECIMAL(12,2) NOT NULL,
    leads_capacity INTEGER NOT NULL,
    total_daily_limit INTEGER NOT NULL,
    details JSONB,  -- какие проекты, какая причина, и т.д.
    created_at TIMESTAMP DEFAULT now()
);
```

RLS — `tenant_isolation` стандартный (как для большинства tenant-таблиц). Append-only через `audit_block_mutation` триггер. Цель — видеть в админке историю «когда и почему клиент попадал в заморозку».

---

## §4. Решение — часть 2: VTB-эквайринг (3 метода оплаты)

### §4.1 Архитектурный подход

**Интерфейс `TopupGatewayInterface`** + **три реализации**:

| Реализация | Метод оплаты | Статус в этом спеке |
|---|---|---|
| `BankTransferGateway` | Безнал (счёт PDF) | **Полная реализация** |
| `SbpGateway` | СБП | Архитектура + dev-заглушка; реальный код после Б-1 |
| `CardGateway` | Карта | Архитектура + dev-заглушка; реальный код после Б-1 |

`BillingTopupService` рефакторится — становится оркестратором:

```php
public function initiateTopup(
    int $tenantId,
    string $amountRub,
    int $userId,
    string $method  // 'bank_transfer' | 'sbp' | 'card'
): TopupSession {
    $gateway = $this->resolveGateway($method);
    return $gateway->createSession($tenantId, $amountRub, $userId);
}
```

И отдельно обработка callback'а:

```php
public function confirmPayment(string $providerRef, ?int $adminUserId): BalanceTransaction {
    // gateway-agnostic кредит баланса + запись audit
}
```

### §4.2 Состояния платежа

Новая таблица `topup_sessions`:

```sql
CREATE TABLE topup_sessions (
    id BIGSERIAL PRIMARY KEY,
    tenant_id BIGINT NOT NULL REFERENCES tenants(id),
    user_id BIGINT REFERENCES users(id),
    method VARCHAR(20) NOT NULL,  -- 'bank_transfer' | 'sbp' | 'card'
    amount_rub DECIMAL(12,2) NOT NULL,
    provider_ref VARCHAR(100),  -- номер счёта / VTB transaction ID / SBP QR ID
    status VARCHAR(20) NOT NULL DEFAULT 'pending',  -- 'pending' | 'matched' | 'confirmed' | 'failed' | 'expired'
    matched_at TIMESTAMP,  -- когда нашли соответствие (только bank_transfer)
    confirmed_at TIMESTAMP,  -- когда человек подтвердил (или авто)
    confirmed_by INTEGER REFERENCES users(id),  -- кто подтвердил (NULL = автомат)
    failed_reason VARCHAR(500),
    metadata JSONB,
    created_at TIMESTAMP DEFAULT now(),
    updated_at TIMESTAMP DEFAULT now()
);
```

RLS — `tenant_isolation` для tenant_id; админ видит все через `crm_app_admin` роль.

После `status='confirmed'` — кредит баланса (`BalanceTransaction` с `type='topup'` и ссылкой на `topup_session_id`).

### §4.3 Безнал — полная реализация

**Шаг 1: Клиент инициирует пополнение**

В личном кабинете → Биллинг → «Пополнить счёт» → выбор метода «Безнал (счёт)» → ввод суммы → кнопка «Сформировать счёт».

Backend: `BankTransferGateway::createSession` — создаёт `topup_sessions(method='bank_transfer', status='pending')`, генерирует уникальный номер счёта (`SCH-{YYYY}-{tenant_id}-{seq}`).

**Шаг 2: Генерация PDF-счёта**

Шаблон `resources/views/pdf/invoice.blade.php`:

- Шапка: реквизиты Лидерры (название ООО, ИНН, КПП, юр. адрес, расчётный счёт VTB).
- Реквизиты плательщика (тенант): название, ИНН, КПП, юр. адрес — берутся из новых полей `tenants.legal_entity_*` (см. §4.7).
- Назначение платежа: «Оплата лидов по договору публичной оферты, счёт №SCH-2026-15-001 от 24.05.2026».
- Сумма: NNNN,NN ₽ (БЕЗ НДС или С НДС в зависимости от системы налогообложения Лидерры — `tax_regime` в admin-settings; для УСН 6% — без НДС).
- Срок оплаты: 5 рабочих дней.

Возвращается клиенту как файл скачивания. URL `/api/billing/topup-sessions/{id}/invoice.pdf`.

**Шаг 3: Авто-поиск платежа в VTB Бизнес API**

Артизан-команда `billing:vtb-statement-sync --since=N` (cron: каждые 15 минут после Б-1; в dev — manual только):

- Запрашивает выписку у VTB Бизнес API за период N часов.
- Для каждой входящей транзакции ищет в `назначение_платежа` номер счёта (`SCH-YYYY-...`).
- При совпадении — `topup_sessions(provider_ref=...).status = 'matched'`, `matched_at = now()`.

В dev/test (когда нет реальных VTB-реквизитов) — режим симуляции: команда `billing:vtb-statement-simulate {session_id} --amount=N` для ручного тестирования флоу.

**Шаг 4: Подтверждение человеком или автомат (admin setting)**

В админке `Биллинг → Настройки` — переключатель:

- **«С подтверждением» (по умолчанию):** платёж в статусе `matched` ждёт ручного клика «Подтвердить зачисление». До этого баланс не растёт.
- **«Автомат»:** платёж в статусе `matched` сразу переводится в `confirmed`, баланс растёт.

В админке `Биллинг → Ожидающие платежи` — список платежей в статусах `matched` и `pending`:

```
[SCH-2026-15-001] ООО Альфа, 100000₽, найден 24.05 15:42 [Подтвердить] [Отклонить]
[SCH-2026-22-003] ИП Иванов, 50000₽, не найден (3 дня)   [Найти вручную] [Отменить]
```

Кнопка «Подтвердить» → `confirmPayment(...)` → кредит баланса + `BalanceTransaction(type='topup', topup_session_id=...)`.

**Шаг 5: Часовые email-алерты админу (только в режиме «С подтверждением»)**

Cron `billing:notify-pending-confirmations` каждый час:

- Если есть платежи в статусе `matched`, не подтверждённые — отправить email админу (`admin_emails` из settings, по умолчанию `eclips9363@gmail.com`).
- Throttle: один email в час суммарно.

### §4.4 СБП — архитектура + dev-заглушка

**Реализация в этом спеке:**

- `SbpGateway::createSession` — в dev/test возвращает фейковый QR-код PNG (`data:image/png;base64,...`) и через 5 секунд (фоновый job) переводит сессию в `confirmed`. Это позволяет полностью отлаживать UI и пост-обработку.
- В prod (после Б-1): зовёт реальный VTB SBP API, получает QR-код / платёжную ссылку, регистрирует callback на `/api/billing/vtb-sbp/callback`.

**Реализация после Б-1 (отдельная задача):**

- Боевая интеграция VTB SBP API (REST + signed callbacks).
- ОФД-Атол для 54-ФЗ чеков (см. §4.6).
- Боевые секреты — в YC Lockbox (SEC-5).

### §4.5 Карты — архитектура + dev-заглушка

**Реализация в этом спеке:**

- `CardGateway::createSession` — в dev/test возвращает редирект-URL на локальную страницу `/dev-mock-vtb-acquiring/{session_id}` с двумя кнопками «Симулировать успех» / «Симулировать ошибку». Клик → переводит сессию в `confirmed` или `failed`.
- В prod (после Б-1): редирект на боевую страницу VTB internet-эквайринга с 3DS Secure.

**Реализация после Б-1 (отдельная задача):**

- Боевая интеграция VTB Acquiring API.
- 3DS Secure (обязательно для всех карт).
- Обработка chargeback'ов.
- ОФД-Атол для 54-ФЗ чеков.

### §4.6 54-ФЗ фискализация

**Текущее состояние:** не реализовано. Раздел подсветить в архитектуре, реальный код — после Б-1 параллельно с СБП.

**Когда нужен чек:**

| Метод оплаты | Чек 54-ФЗ |
|---|---|
| Безнал (юр→юр) | **Не нужен** (статья 1.2 п.9 закона) |
| СБП | **Обязателен** (B2C ритейл-платёж) |
| Карта | **Обязателен** (B2C ритейл-платёж) |

**Архитектура** (заглушка в этом спеке):

```php
interface FiscalReceiptProvider {
    public function issueReceipt(TopupSession $session): FiscalReceipt;
}
```

Реализации:

- `AtolOnlineFiscalProvider` — реальная интеграция (после Б-1).
- `NoOpFiscalProvider` — для безнала (возвращает «чек не требуется по 54-ФЗ»).
- `DevMockFiscalProvider` — для dev/test (фейковый чек ID).

### §4.7 Реквизиты тенанта

Новые поля в `tenants` (для счёта-фактуры):

```sql
ALTER TABLE tenants ADD COLUMN legal_entity_name VARCHAR(255);    -- "ООО Альфа" или "ИП Иванов Иван"
ALTER TABLE tenants ADD COLUMN legal_entity_inn VARCHAR(12);
ALTER TABLE tenants ADD COLUMN legal_entity_kpp VARCHAR(9);       -- NULL для ИП
ALTER TABLE tenants ADD COLUMN legal_entity_address TEXT;
ALTER TABLE tenants ADD COLUMN legal_entity_form VARCHAR(20);     -- 'OOO' | 'IP' | 'OAO' | 'ZAO' | 'OTHER'
```

В личном кабинете клиента → Настройки → «Реквизиты юр. лица» — форма для заполнения. **Обязательны** для безнала (без них счёт PDF не выписывается); для СБП/карт — опционально (но желательно).

Валидация ИНН (контрольное число), КПП (формат), форма юрлица — стандартными правилами laravel.

### §4.8 Минимум/максимум суммы пополнения

| Метод | Минимум | Максимум |
|---|---|---|
| Безнал | 1000 ₽ | 1 000 000 ₽ |
| СБП | 100 ₽ | 600 000 ₽ (лимит СБП) |
| Карта | 100 ₽ | 100 000 ₽ за одну транзакцию |

Конфигурация в `config/billing.php` (settable).

---

## §5. Архитектура изменений

### §5.1 Карта изменений по слоям

| Слой | Что |
|---|---|
| **БД** | `tenants` (+`frozen_by_balance_at`, +5 `legal_entity_*` полей); новые таблицы `balance_freeze_log`, `topup_sessions` |
| **Бэк-сервисы** | `SupplierQuotaAllocator` (новый pre-filter pipeline); `BalancePreflightService` (новый, проверка платёжеспособности); `BillingTopupService` (рефакторинг под gateway pattern); `TopupGatewayInterface` + 3 реализации; `FiscalReceiptProvider` + 3 реализации |
| **Бэк-джобы** | `BalancePreflightSweepJob` (cron @18:00 MSK ежедневно); `BalanceFrozenNotificationJob` (event-driven при заморозке); `VtbStatementSyncJob` (cron @ каждые 15 мин); `NotifyPendingConfirmationsJob` (cron hourly) |
| **Бэк-команды** | `billing:preflight-sweep`, `billing:vtb-statement-sync`, `billing:notify-pending-confirmations`, `billing:preflight-initial-sweep` (one-time migration), `billing:vtb-statement-simulate` (dev only) |
| **Бэк-контроллеры** | `BillingController` (+endpoints: `/api/billing/topup/initiate`, `/api/billing/topup-sessions/{id}`, `/api/billing/topup-sessions/{id}/invoice.pdf`); `ProjectController` (preflight check + 409 response с диалогом); `Admin/PendingTopupsController` (новый); `Admin/BillingSettingsController` (новый, для переключателя auto/manual) |
| **Бэк-Mail** | `BalanceFrozenMail`, `BalanceFrozenReminderMail`, `BalanceFrozenFinalMail`, `BalanceUnfrozenMail`, `PendingConfirmationsAdminMail` |
| **Фронт-компоненты** | `BalanceFrozenBanner.vue`, `BalanceCapacityIndicator.vue`, `ProjectLimitOverloadDialog.vue`, `TopupMethodPicker.vue`, `BankTransferInvoiceView.vue`, `SbpQrCodeView.vue`, `CardRedirectView.vue`, `PendingPaymentsAdminView.vue`, `BillingSettingsAdminView.vue`, `LegalEntityForm.vue` (в Настройки) |
| **Фронт-views** | `TopupView.vue` (новый, обёртка с переключателем methods); `BillingFrozenInfoView.vue` |
| **Pinia** | `billingStore` (расширение под topup-сессии); `tenantStore` (frozen-флаг) |

### §5.2 Sequence-диаграмма преfflight на cut-off

```
18:00 MSK Cron
   │
   ├── BalancePreflightSweepJob::handle()
   │     │
   │     ├── для каждого tenant:
   │     │     │
   │     │     ├── BalancePreflightService::evaluate(tenant)
   │     │     │     │
   │     │     │     ├── читает projects (active=true, eligible_tomorrow=true)
   │     │     │     ├── требуемые лиды = Σ daily_limit
   │     │     │     ├── ёмкость лидов = BalanceToLeadsConverter::convert(balance, delivered, tiers)['leads']
   │     │     │     ├── сравнивает: passes = capacity >= required
   │     │     │     └── возвращает PreflightResult { passes: bool, required_leads, capacity_leads, deficit_leads }
   │     │     │
   │     │     ├── если passes изменился:
   │     │     │     │
   │     │     │     ├── tenant.frozen_by_balance_at = NULL | now()
   │     │     │     ├── balance_freeze_log.insert(event_type='frozen' | 'unfrozen')
   │     │     │     ├── dispatch(BalanceFrozenMail | BalanceUnfrozenMail)
   │     │     │     │
   │     │     │     └── (если frozen) для каждого projects:
   │     │     │           projects.preflight_blocked_at = now()  (новая колонка)
   │     │     │
   │     │     └── (если unfrozen) projects.preflight_blocked_at = NULL для всех
   │     │
   │     └── для следующего tenant...
   │
18:05 MSK (после преfflight) — обычный SyncSupplierProjectsJob запускается
   │
   ├── SupplierQuotaAllocator::allocate(eligible_projects)
   │     │
   │     ├── фильтр eligible_projects: only WHERE preflight_blocked_at IS NULL
   │     ├── computeOrder([daily_limit, ...]) (формула не меняется)
   │     └── distributeForPlatform(order, [B1, B2, B3])
   │
   └── SyncSupplierProjectJob (per project) → отправка лимитов поставщику
```

### §5.3 Sequence-диаграмма пополнения (Безнал)

```
Клиент в личном кабинете
   │
   ├── Кнопка "Пополнить счёт"
   │
   └── TopupMethodPicker → выбор "Безнал (счёт)"
         │
         └── Ввод суммы → "Сформировать счёт"
               │
               └── POST /api/billing/topup/initiate { method: 'bank_transfer', amount_rub: '100000.00' }
                     │
                     ├── BillingTopupService::initiateTopup(...)
                     │     │
                     │     └── BankTransferGateway::createSession(...)
                     │           │
                     │           ├── создание topup_sessions(status='pending', provider_ref='SCH-2026-15-001')
                     │           └── возвращает { session_id, invoice_url: '/api/billing/topup-sessions/15001/invoice.pdf' }
                     │
                     └── Редирект клиента на invoice_url → скачивание PDF

Клиент оплачивает в своём банк-клиенте
   │
   └── Деньги поступают на расчётный счёт VTB Лидерры (за ~часы)

VtbStatementSyncJob (каждые 15 минут)
   │
   ├── VTB Business API → выписка
   │
   └── для каждой входящей транзакции:
         │
         ├── парсинг назначения платежа → ищем SCH-YYYY-tenant-seq
         │
         └── если совпадение найдено + сумма совпадает:
               │
               ├── topup_sessions.status = 'matched'
               ├── matched_at = now()
               │
               └── (если admin-setting auto_confirm=true):
                     │
                     ├── BillingTopupService::confirmPayment(provider_ref, NULL)
                     │     │
                     │     ├── topup_sessions.status = 'confirmed'
                     │     ├── tenant.balance_rub += amount_rub  (bcadd)
                     │     └── BalanceTransaction.create(type='topup', topup_session_id=...)
                     │
                     └── (если auto_confirm=false): ждёт ручного подтверждения

NotifyPendingConfirmationsJob (каждый час)
   │
   └── если есть topup_sessions(status='matched') не подтверждённые:
         │
         └── PendingConfirmationsAdminMail → admin email

Админ в кабинете
   │
   └── Биллинг → Ожидающие платежи → [Подтвердить SCH-2026-15-001]
         │
         └── POST /api/admin/topup-sessions/15001/confirm
               │
               └── BillingTopupService::confirmPayment(provider_ref, admin_user_id)
                     │
                     ├── topup_sessions.status = 'confirmed'
                     ├── tenant.balance_rub += amount_rub
                     ├── BalanceTransaction.create(type='topup', confirmed_by=admin_user_id)
                     │
                     └── BalancePreflightService::evaluate(tenant)  ← может разморозить!
                           │
                           └── если frozen_by_balance_at был NOT NULL и теперь passes:
                                 │
                                 ├── tenant.frozen_by_balance_at = NULL
                                 ├── balance_freeze_log.insert(event_type='unfrozen', triggered_by='topup')
                                 └── BalanceUnfrozenMail отправляется
```

### §5.4 Изменения в `SupplierQuotaAllocator`

Минимальное и неинвазивное — добавление одного шага в caller (`SyncSupplierProjectsJob`):

**До:**

```php
$eligibleProjects = $this->projectsQuery->getEligibleForToday($targetDate);
$dto = SupplierQuotaAllocator::allocate(..., $eligibleProjects, $targetDate);
```

**После:**

```php
$eligibleProjects = $this->projectsQuery->getEligibleForToday($targetDate);

// NEW: фильтр по frozen-флагу tenant
$eligibleProjects = $eligibleProjects->reject(
    fn($p) => $p->tenant->frozen_by_balance_at !== null
);

$dto = SupplierQuotaAllocator::allocate(..., $eligibleProjects, $targetDate);
```

`SupplierQuotaAllocator` сам не меняется — он pure-функция, принимает то что дали. Это важно для тестируемости (формула не сломалась).

---

## §6. Сценарии (end-to-end)

### §6.1 Преfflight — пассивный износ

**Воскресенье 00:00.** Клиент с балансом 1000₽ = 30 лидов (tier 3, цена ~33₽/лид). Проекты заказывают 25/день. Запас 5.

**Воскресенье 18:00.** Cron `BalancePreflightSweepJob`:
- `BalancePreflightService::evaluate(client)` → `passes=true` (хватает на 25).
- `frozen_by_balance_at` остаётся `NULL`.

**Воскресенье 18:05.** `SyncSupplierProjectsJob` — все 25 идут в заказ поставщику. Поставщик в 21:00 берёт слепок.

**Понедельник.** В течение дня партии лидов приходят, клиент получает все 25, баланс падает до 0. Никаких внутридневных стопов.

**Понедельник 18:00.** Cron snova:
- `evaluate(client)` → `passes=false` (0₽ ≠ 25 лидов).
- `frozen_by_balance_at = now()`.
- `balance_freeze_log.insert(event='frozen', triggered_by='cutoff_18msk')`.
- `BalanceFrozenMail` отправляется.

**Понедельник 18:05.** `SyncSupplierProjectsJob` — этот клиент исключён, формула пересчитывается для остальных в группе на источнике.

**Понедельник 19:00.** Клиент видит письмо, пополняет на 1000₽ через безнал PDF → 1-2 дня ждёт зачисления через сверку. Или через СБП/карту (после Б-1) — мгновенно.

**Вторник 14:00.** Безнал подтверждён админом (или авто) → баланс 1000₽. `confirmPayment` зовёт `evaluate` → `passes=true`. `frozen_by_balance_at = NULL`. `BalanceUnfrozenMail`. В личном кабинете баннер исчезает.

**Вторник 18:00.** Cron snova — клиент в заказе на среду. Со среды получает лиды.

### §6.2 Преfflight — активная нехватка при создании проекта

**Клиент с балансом 1000₽ = 30 лидов.** Имеет 3 проекта по 10 = 30 лимит. Всё впритык.

**Клиент создаёт 4-й проект с лимитом 20.** Бэк (`ProjectController::store`) делает превью-преfflight:

- `Σ daily_limit (после сохранения) = 50` лидов
- `capacity = BalanceToLeadsConverter::convert(1000₽, delivered, tiers)['leads'] = 30` лидов
- `30 < 50` → недостаток 20 лидов

Возвращает HTTP 409 с body:

```json
{
  "error": "balance_insufficient",
  "current_balance_rub": "1000.00",
  "current_capacity_leads": 30,
  "would_be_required_leads": 50,
  "deficit_leads": 20
}
```

Фронт показывает диалог `ProjectLimitOverloadDialog.vue`:

```
Этот лимит превышает баланс.
У тебя 1000₽ = 30 лидов по текущему тарифу.
После сохранения нужно 50 лидов.
Не хватает: 20 лидов.

Чтобы он начал работать, нужно одно из:
  • Пополнить счёт (примерно 700₽ покроют 20 лидов недостачи)
  • Поставить лимит этого проекта 0
  • Уменьшить лимиты других проектов

[Сохранить и приостановить этот] [Поставить лимит 0] [Отмена]
```

«Примерно 700₽» — оценка через обратное преобразование: добавляем баланс шагами по 100₽ и смотрим, при какой сумме `capacity` вырастет до 50 (фронт делает это локально, не дёргая бэк).

- Если «Сохранить и приостановить» → POST `/api/projects` с флагом `force_save_blocked=true`. Создаётся проект с `preflight_blocked_at = now()`. Остальные 3 проекта работают.
- Если «Поставить лимит 0» → POST `/api/projects` с `daily_limit = 0`. Создаётся, не идёт в заказ.
- Если «Отмена» → форма закрывается, ничего не сохраняется.

### §6.3 VTB — Безнал end-to-end (после Б-1)

1. Клиент → Биллинг → «Пополнить 100000₽» → выбор «Безнал».
2. Backend создаёт `topup_sessions(status='pending', provider_ref='SCH-2026-15-001')`.
3. Скачивает PDF-счёт с реквизитами Лидерры (ИНН, КПП, р/с) и своими реквизитами.
4. Идёт в свой банк-клиент, оплачивает.
5. Деньги в течение нескольких часов приходят на р/с Лидерры в VTB.
6. `VtbStatementSyncJob` за 15 минут после прихода находит транзакцию с «SCH-2026-15-001» в назначении, ставит `status='matched'`.
7. Часовой `NotifyPendingConfirmationsJob` шлёт админу email «есть 1 непровер. платёж».
8. Админ в админке → Биллинг → Ожидающие платежи → видит запись → «Подтвердить» → 100000₽ кредитуется, `BalanceTransaction` пишется.
9. Если клиент был в `frozen_by_balance_at` — `evaluate` пересчитывает → разморозка → `BalanceUnfrozenMail`.

В dev-режиме шаг 6 заменяется на `billing:vtb-statement-simulate 15001 --amount=100000` (manual).

---

## §7. Известные открытые вопросы

1. **Учёт перехода ступени за день.** `BalanceToLeadsConverter::convert` уже учитывает «текущую ступень и переход на следующие при росте объёма». Граничный случай «дневной заказ переваливает за порог ступени, после чего цена меняется» автоматически обрабатывается — `convert` итерирует по ступеням и считает разные цены для разных кусков. **Не нужна дополнительная логика.**
2. **Что если клиент имеет несколько менеджеров с email?** Письмо `BalanceFrozenMail` — на главный email тенанта (`tenant.email`) или на всех users этого тенанта? **Решение по умолчанию:** на главный email тенанта (как сейчас в `ZeroBalancePausedMail`).
3. **Формат номера счёта** `SCH-YYYY-{tenant_id}-{seq}` — гарантирована уникальность через `(tenant_id, seq)` сериал. Если переходим на ОФД-Атол, может потребоваться другой формат. **Решение:** сейчас наш формат, при подключении ОФД — модернизируем (отдельная задача).
4. **Локализация PDF-счёта** — пока только русский. После Б-1 при необходимости — английский / казахский (если расширим географию).
5. **Reconcilation между `topup_sessions` и `balance_transactions`** — есть ли инвариант «каждая completed-сессия = одна balance_transaction»? Да, должен быть — добавить foreign key + unique constraint.

---

## §8. Future enhancements (Спек D и далее)

1. **«Отдать разморозившемуся клиенту лиды, уже купленные сегодня, через шеринг»** (см. §3.10) — бизнес: разморозившийся в 15:00 клиент мог бы получить лиды, поступающие после 15:00 по его источнику, если есть свободные слоты шеринга. Технически — модификация `RouteSupplierLeadJob` + расширение eligible-кандидатов после mid-day events. Реальная частота сценария = низкая; делать после месяца сбора статистики.
2. **«Приоритет шеринга 4+ клиентов — те, у кого хватало баланса на момент cut-off»** — снимок «утренней платёжеспособности» сохраняется, при шеринге сверяется. Полезно при переполненном шеринге (>3 покупателей конкурируют за лид).
3. **Авто-сверка с VTB Бизнес API** — реальная интеграция (не заглушка), боевой `VtbStatementSyncJob` с retry и подписью.
4. **Recurring-платежи** — автосписание с карты раз в месяц (отдельная фича, требует UX-проработки).
5. **Возвраты** (refund) — если бизнес-нужда подтвердится. Сейчас Спек A явно говорит «не делаем».
6. **Мульти-валютность** — если выходим на казахский / белорусский рынок (KZT/BYN).

---

## §9. Связи

- [Спек A](2026-05-23-billing-v2-spec-a-balance-rub-design.md) — единый ₽-баланс, `BalanceToLeadsConverter` (используется в этом спеке).
- [Спек B](2026-05-23-billing-v2-spec-b-duplicates-design.md) — `supplier_lead_deliveries` lock-таблица, шеринг до 3 клиентов на лид (контекст для §3.10).
- [App allocator](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php) — формула заказа, остаётся без изменений.
- [App ledger](../../../app/app/Services/Billing/LedgerService.php) — списания с баланса, остаётся без изменений.
- [App topup](../../../app/app/Services/Billing/BillingTopupService.php) — рефакторится в этом спеке.
- [App webhook routing](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php) — добавляется фильтр frozen-проектов.
- [App project controller](../../../app/app/Http/Controllers/Api/ProjectController.php) — добавляется preflight check.
- [Pravila §13.2](../../../docs/Pravila_raboty_Claude_v1_1.md) — финансовая нормативка.
- [CLAUDE.md §6](../../../CLAUDE.md) — текущая фаза.
- [project_billing_v2 memory](../../../../C:/Users/Administrator/.claude/projects/c---------------------crm-------------/memory/project_billing_v2.md) — серия из 3 спеков.
- [SEC-3, SEC-5 (memory)](../../../../C:/Users/Administrator/.claude/projects/c---------------------crm-------------/memory/project_server_hardening.md) — блокеры YC Lockbox для секретов VTB.

---

## §10. План реализации (overview)

Детальный план — отдельным документом через `superpowers:writing-plans` после утверждения этого спека.

Предварительная разбивка на фазы (для оценки масштаба):

**Phase 1 — Преfflight баланса** (~5-7 задач):

- Миграция БД (`frozen_by_balance_at`, `preflight_blocked_at`, `balance_freeze_log`).
- `BalancePreflightService` (pure) + тесты.
- `BalancePreflightSweepJob` + cron.
- 4 Mailable + throttle.
- `BalanceFrozenBanner.vue` + `BalanceCapacityIndicator.vue`.
- `ProjectController` preflight check + 409 response.
- `ProjectLimitOverloadDialog.vue`.
- Фильтр в `SyncSupplierProjectsJob`.
- One-time `billing:preflight-initial-sweep`.

**Phase 2 — Безнал PDF + админка** (~6-8 задач):

- Миграция БД (`legal_entity_*` в `tenants`, `topup_sessions`).
- `TopupGatewayInterface` + `BankTransferGateway`.
- PDF-генерация (`resources/views/pdf/invoice.blade.php`).
- `LegalEntityForm.vue` + валидация.
- `TopupView.vue` + `TopupMethodPicker.vue` + `BankTransferInvoiceView.vue`.
- `Admin/PendingTopupsController` + `PendingPaymentsAdminView.vue`.
- `Admin/BillingSettingsController` (auto/manual переключатель).
- `VtbStatementSyncJob` (с dev-симулятором).
- `NotifyPendingConfirmationsJob`.

**Phase 3 — СБП и Карты dev-заглушки** (~4-5 задач):

- `SbpGateway` (dev режим) + `SbpQrCodeView.vue`.
- `CardGateway` (dev режим) + `/dev-mock-vtb-acquiring/...` страница.
- `FiscalReceiptProvider` interface + `NoOpFiscalProvider` + `DevMockFiscalProvider`.

**Phase 4 — Тесты + smoke** (~3-4 задач):

- Pest end-to-end сценарии преfflight (frozen/unfrozen flow).
- Pest end-to-end сценарии безнала (создание счёта → симуляция выписки → подтверждение).
- Vitest на новые Vue-компоненты.
- Регрессия (Pest --parallel + Vitest + lychee + gitleaks).

**Phase 5 — Документация + memory + ПИЛОТ.md** (~2 задач):

- Обновление `project_billing_v2.md` (Спек C статус, известные хвосты).
- ADR (если требуется новый — `docs/adr/016-preflight-vtb-architecture.md`).
- Обновление `ПИЛОТ.md` после выкатки на прод.

**Out of scope этого плана (post-Б-1, отдельные планы):**

- Боевая интеграция VTB Acquiring (карты).
- Боевая интеграция VTB SBP API.
- Боевая интеграция VTB Бизнес API (авто-сверка).
- Боевая интеграция ОФД-Атол (фискализация).
- Боевые секреты в YC Lockbox.

---

**Конец Спека C.**
