# Plan 4 (Billing + CSV Reconcile + Admin) Implementation Design

**Дата:** 2026-05-11
**Статус:** черновик дизайна (brainstorming output)
**Заказчик:** Дмитрий (владелец Лидерры)
**Parent spec:** [docs/superpowers/specs/2026-05-10-supplier-integration-design.md](./2026-05-10-supplier-integration-design.md) §5.2, §7
**Inherits from:** Plan 1 (Foundation `001d781`) + Plan 2 (Webhook+Routing `d5aa972`) + Plan 2.5 (concurrency hotfix `c1ae195`+`1ba1df8`) + Plan 2.6 (cleanup `7899071`) + Plan 3 (Supplier Sync `734b0ab`).

## 1. Контекст, инварианты, что уже есть в коде

### 1.1. Цель Plan 4

Активировать ступенчатый биллинг клиентов Лидерры (`pricing_tiers` / `lead_charges`), закрыть TODO «Биллинг per Plan 4» в [RouteSupplierLeadJob.php:48](../../../app/app/Jobs/RouteSupplierLeadJob.php#L48), реализовать резервный CSV-канал приёма лидов и Admin UI для конфигурации.

### 1.2. Что уже есть в коде (после Plans 1+2+3)

| Сущность | Где | Состояние |
|---|---|---|
| `pricing_tiers` (7 ступеней, копейки, `effective_from`) | [db/schema.sql:962](../../../db/schema.sql#L962) | Таблица создана (v8.14), **никем не читается** |
| `lead_charges` (append-only ledger, RLS, FK на partitioned deals) | [db/schema.sql:1001](../../../db/schema.sql#L1001) | Таблица создана (v8.15), **никто не пишет** |
| `tenants.balance_rub` (DECIMAL 12,2 без CHECK), `tenants.balance_leads` (INT) | [db/schema.sql:629](../../../db/schema.sql#L629) | Существуют; `balance_leads` декрементится в RouteSupplierLeadJob |
| `projects.delivered_in_month` (INTEGER) | [db/schema.sql:786](../../../db/schema.sql#L786) | Инкрементится в RouteJob, **нет cron'а сброса** |
| `BalanceTransaction` (type=`lead_charge`, amount_leads=-1) | [RouteSupplierLeadJob:271](../../../app/app/Jobs/RouteSupplierLeadJob.php#L271) | Пишется при доставке |
| `SupplierLeadCost` (per-deal закупочный snapshot, partitioned) | [db/schema.sql:2201](../../../db/schema.sql#L2201), [ProcessWebhookJob:216](../../../app/app/Jobs/ProcessWebhookJob.php#L216) | Пишется только в старом `ProcessWebhookJob`, в новом `RouteSupplierLeadJob` НЕ пишется (gap) |
| `suppliers.cost_rub` (per-platform B1/B2/B3) | [db/schema.sql:352](../../../db/schema.sql#L352) | Seed B1/B2/B3 в [schema.sql:2505](../../../db/schema.sql#L2505) |
| `SupplierPortalClient` + `RefreshSupplierSessionJob` + `PlaywrightBridge` | `app/app/Services/Supplier/*`, Plan 3 | Готово, переиспользуется для CSV |
| `SupplierCriticalAlertMail` (email через Unisender Go) | `app/app/Mail/*`, Plan 3 | Готово, расширим алертами биллинга и drift'а |

### 1.3. Бизнес-инварианты Plan 4

1. **Dual balance:** при доставке лида сначала пытаемся списать 1 единицу `balance_leads` (prepaid pool); если 0 — списываем из `balance_rub` по текущей tier-цене.
2. **Tier-lookup per-tenant:** ступень определяется накопительной суммой `tenants.delivered_in_month` за месяц (новая колонка — см. §2).
3. **Атомарность:** charge + deal-INSERT + counter increment — одна транзакция. `lead_charges` пишется **всегда** при успешной доставке: `price_per_lead_kopecks=0` для prepaid, фактическая tier-цена для рублёвого списания. `charge_source ENUM('prepaid','rub')` для прозрачности.
4. **Pause-on-insufficient:** если оба источника пусты — лид НЕ доставляется этому проекту, статус Лидерра-проекта → `is_active=false` + email уведомление с rate-limit 1/час/tenant.
5. **Pricing tier change → effective 1-го числа след. месяца:** UI принимает изменения в любой день, но `effective_from` всегда auto-set = `DATE_TRUNC('month', NOW() + INTERVAL '1 month')`. Текущая активная ступень = `MAX(effective_from) WHERE effective_from <= CURRENT_DATE AND is_active=true`.
6. **`SupplierLeadCost` для sharing-flow:** при каждой созданной deal-копии пишем `supplier_lead_costs(deal_id, supplier_id, cost_rub=suppliers.cost_rub)` — закрывает существующий gap (Plan 2/3 не писали).
7. **CSV reconcile:** hourly. Сравнение по `supplier_leads.vid` за окно 25h. Drift > 5% → email админу. Recovered лиды идут через тот же `RouteSupplierLeadJob` с `recovered_from_csv_at` маркером.
8. **Admin UI:** SaaS-level `/admin/pricing-tiers` (CRUD 7 ступеней) + `/admin/supplier-prices` (B1/B2/B3 cost_rub editor) + tenant-level «Списания» tab в существующем `BillingView`.

### 1.4. Out of scope Plan 4

1. Balance top-up UI для admin/tenant — Plan 4.5 или Plan 5.
2. Auto-resume project при пополнении баланса — Plan 4.5+.
3. Подписочный биллинг / payments / счета-фактуры — Plan 5+.
4. Per-tenant pricing override — out of MVP (spec §1.2).
5. Replay сделок при изменении tier'а задним числом — нет (`effective_from` + append-only `lead_charges`).
6. `Schedule::onOneServer()` для новых cron'ов — требует `cache_locks` таблицу, gated на Б-1 / Managed PG в Yandex Cloud.
7. SaaS-admin auth middleware — gated на Б-1 + SSO заказчика; на MVP все `/api/admin/*` без middleware (паритет с Plans 1/2/3).
8. Discovery CSV-схемы через credentials поставщика (паритет с Plan 3 Tasks 1+2 BLOCKED).
9. Polling балансов из платёжного шлюза.

## 2. Schema delta v8.18 → v8.19

### 2.1. Изменения в существующих таблицах

#### `tenants` — +1 колонка `delivered_in_month`

```sql
ALTER TABLE tenants ADD COLUMN delivered_in_month INTEGER NOT NULL DEFAULT 0
    CHECK (delivered_in_month >= 0);

COMMENT ON COLUMN tenants.delivered_in_month IS
  'Накопительный счётчик доставленных лидов в текущем календарном месяце (Europe/Moscow). '
  'Используется PricingTierResolver для определения текущей ступени pricing_tiers '
  'на горячем пути RouteSupplierLeadJob. Сбрасывается в 0 cron-командой '
  'ResetMonthlyCountersCommand 1-го числа в 00:00 МСК (Plan 4).';
```

Обоснование: SUM-by-projects на каждом charge — N+1 + `lockForUpdate`-race; денормализованный счётчик читается O(1) и обновляется в той же транзакции, что `lead_charges` INSERT.

#### `tenants.balance_rub` — CHECK НЕ добавляем

Намеренно: проверка `< price` происходит в `LedgerService::canCharge()`, в БД оставляем свободу для chargeback writedown (Ю-3, может уйти в плюс) и админских корректировок.

#### `lead_charges` — +1 колонка `charge_source` + 1 CHECK

```sql
ALTER TABLE lead_charges
    ADD COLUMN charge_source VARCHAR(8) NOT NULL DEFAULT 'rub'
        CHECK (charge_source IN ('prepaid','rub'));

ALTER TABLE lead_charges
    ADD CONSTRAINT chk_lead_charges_prepaid_zero_price
    CHECK (charge_source = 'rub' OR price_per_lead_kopecks = 0);

COMMENT ON COLUMN lead_charges.charge_source IS
  'Источник списания: prepaid (balance_leads -1, price_per_lead_kopecks=0) '
  'или rub (balance_rub минус price). Plan 4.';
```

`DEFAULT 'rub'` безопасен: до Plan 4 строк в таблице нет.

#### `supplier_leads` — +1 колонка `recovered_from_csv_at`

```sql
ALTER TABLE supplier_leads
    ADD COLUMN recovered_from_csv_at TIMESTAMPTZ;

CREATE INDEX supplier_leads_recovered_from_csv_partial
    ON supplier_leads(recovered_from_csv_at)
    WHERE recovered_from_csv_at IS NOT NULL;

COMMENT ON COLUMN supplier_leads.recovered_from_csv_at IS
  'NULL для лидов, пришедших через webhook (основной канал). Заполняется CsvReconcileJob '
  'при восстановлении лида, который webhook пропустил. Plan 4.';
```

### 2.2. Новая таблица: `supplier_csv_reconcile_log`

```sql
CREATE TABLE supplier_csv_reconcile_log (
    id                   BIGSERIAL PRIMARY KEY,
    started_at           TIMESTAMPTZ NOT NULL,
    finished_at          TIMESTAMPTZ,
    window_start         TIMESTAMPTZ NOT NULL,
    window_end           TIMESTAMPTZ NOT NULL,
    total_csv_rows       INTEGER,
    matched_count        INTEGER,
    recovered_count      INTEGER,
    drift_ratio          NUMERIC(5,4),
    status               VARCHAR(16) NOT NULL DEFAULT 'running'
                         CHECK (status IN ('running','ok','drift_alert','failed')),
    error_message        TEXT,
    alert_email_sent_at  TIMESTAMPTZ,
    created_at           TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX supplier_csv_reconcile_log_started_at_index
    ON supplier_csv_reconcile_log(started_at DESC);
CREATE INDEX supplier_csv_reconcile_log_status_index
    ON supplier_csv_reconcile_log(status)
    WHERE status IN ('drift_alert','failed');
```

SaaS-level (не tenant-scoped), без RLS. Аналог `supplier_sync_log`. **GRANT-policy:** REVOKE/GRANT для `crm_supplier_worker` (BYPASSRLS) **не идут в `schema.sql`** — `schema.sql` остаётся DDL-only под dev (postgres superuser). GRANT SELECT,INSERT,UPDATE на `supplier_csv_reconcile_log` для `crm_supplier_worker` добавляется в [db/02_grants.sql](../../../db/02_grants.sql) (паттерн Plan 2.6 #iv `7899071`). На dev таблица доступна суперпользователю по умолчанию.

### 2.3. Seed-данные (отдельно от schema.sql)

7 ступеней `pricing_tiers` + системные настройки. **НЕ идут в schema.sql** (тот — DDL only), а в:

- `database/seeders/PricingTierSeeder.php` — 7 строк с `effective_from='1970-01-01'`.
- `system_settings` row seed в schema.sql §INSERTS — TBD ключ `supplier_lead_price_default_kopecks` (если потребуется fallback).

**Дефолтные tier-цены** (`[?]` для заказчика — открытый вопрос #1):

| tier_no | leads_in_tier | price_per_lead (руб) | price_per_lead_kopecks |
|---|---|---|---|
| 1 | 100 | 500.00 | 50000 |
| 2 | 200 | 450.00 | 45000 |
| 3 | 400 | 400.00 | 40000 |
| 4 | 800 | 350.00 | 35000 |
| 5 | 1500 | 300.00 | 30000 |
| 6 | 3000 | 270.00 | 27000 |
| 7 | NULL | 250.00 | 25000 |

### 2.4. Метрики после Plan 4

| Метрика | До (v8.18) | После (v8.19) | Δ |
|---|---|---|---|
| Базовые таблицы | 61 | 62 (+`supplier_csv_reconcile_log`) | +1 |
| Партиции | 12 | 12 | 0 |
| Индексы | 114 | 117 | +3 |
| RLS-политики | 39 | 39 | 0 |
| CHECK constraints | (без явного подсчёта) | +2 (`tenants.delivered_in_month >= 0`, `chk_lead_charges_prepaid_zero_price`) | +2 |
| Добавлено колонок | — | `tenants.delivered_in_month`, `lead_charges.charge_source`, `supplier_leads.recovered_from_csv_at` | +3 |

### 2.5. Patch order в schema.sql

1. `tenants.delivered_in_month` — после строки 644 (`desired_daily_numbers`), в секции «Биллинг».
2. `lead_charges.charge_source` + CHECK — внутри `CREATE TABLE lead_charges` после строки 1008.
3. `supplier_csv_reconcile_log` — новая секция после `supplier_sync_log`.
4. `supplier_leads.recovered_from_csv_at` — внутри `CREATE TABLE supplier_leads` (v8.18).
5. Bump version header v8.18 → v8.19 + entry в `db/CHANGELOG_schema.md`.

## 3. Billing flow в `RouteSupplierLeadJob`

### 3.1. Новые сервисы

**`App\Services\Billing\PricingTierResolver`** — pure resolver:

```php
final class PricingTierResolver
{
    /**
     * @param  Collection<int, PricingTier>  $tiers  активные ступени, отсортированные по tier_no
     * @return PricingTier  ступень, в которую попадает N-й лид (1-based)
     */
    public function resolveForCount(Collection $tiers, int $deliveredInMonth): PricingTier;
}
```

Логика: tier 1 покрывает 1..`leads_in_tier`; tier 2 — следующие `leads_in_tier`; ... tier 7 с `leads_in_tier=NULL` ловит всё свыше. Текущая активная сетка = `MAX(effective_from) WHERE effective_from <= CURRENT_DATE AND is_active=true` через `PricingTierRepository::activeAt(Carbon $date)`.

**`App\Services\Billing\LedgerService`** — командный сервис, инжектится в job:

```php
final class LedgerService
{
    public function __construct(
        private PricingTierResolver $resolver,
        private PricingTierRepository $tiers,
    ) {}

    /**
     * Выполняется ВНУТРИ открытой DB-транзакции под lockForUpdate(Tenant).
     * Возвращает ChargeResult с source ('prepaid'|'rub'); throws InsufficientBalanceException.
     */
    public function chargeForDelivery(Tenant $lockedTenant, Deal $deal): ChargeResult;
}
```

**`App\Exceptions\Billing\InsufficientBalanceException`** — несёт `priceKopecks`, `balanceRub`, `balanceLeads` для логирования.

### 3.2. Точка интеграции — diff в `createDealCopyForProject`

Замена строк [RouteSupplierLeadJob.php:265-279](../../../app/app/Jobs/RouteSupplierLeadJob.php#L265-L279):

**Было:**

```php
$tenant->decrement('balance_leads');
$tenant->refresh();
$project->increment('delivered_today');
$project->increment('delivered_in_month');

BalanceTransaction::create([
    'tenant_id' => $tenant->id,
    'type' => BalanceTransaction::TYPE_LEAD_CHARGE,
    'amount_leads' => -1,
    'balance_leads_after' => (int) $tenant->balance_leads,
    'related_type' => Deal::class,
    'related_id' => $deal->id,
    'created_at' => now(),
]);
```

**Станет:**

```php
try {
    $chargeResult = $ledger->chargeForDelivery($tenant, $deal);
} catch (InsufficientBalanceException $e) {
    Log::warning('billing.insufficient_balance.deal_rolled_back', [
        'tenant_id' => $tenant->id,
        'project_id' => $project->id,
        'supplier_lead_id' => $lead->id,
        'price_kopecks' => $e->priceKopecks,
        'balance_rub' => $e->balanceRub,
        'balance_leads' => $e->balanceLeads,
    ]);
    throw $e;  // вылетает из DB::transaction → rollback Deal/Tenant lock/ActivityLog
}

$project->increment('delivered_today');
$project->increment('delivered_in_month');
```

### 3.3. Поток внутри `LedgerService::chargeForDelivery`

```
1. Считать активные tiers через PricingTierRepository::activeAt(today).
2. Resolve currentTier = PricingTierResolver::resolveForCount(tiers,
                              $lockedTenant->delivered_in_month + 1).
3. priceKopecks = currentTier->price_per_lead_kopecks.
4. Decide chargeSource (все денежные сравнения через bcmath — НЕ через PHP float):
   - if $lockedTenant->balance_leads >= 1:                                          source='prepaid'
   - else if bccomp(bcmul((string) $lockedTenant->balance_rub, '100', 0),
                    (string) $priceKopecks, 0) >= 0:                                source='rub'
   - else: throw InsufficientBalanceException(...)
5. Apply:
   if source='prepaid':
     - $lockedTenant->decrement('balance_leads', 1)
     - $lockedTenant->increment('delivered_in_month', 1)
     - $lockedTenant->refresh()
     - INSERT lead_charges (charge_source='prepaid', tier_no=current,
                            price_per_lead_kopecks=0, charged_at=now)
     - INSERT balance_transactions (type='lead_charge', amount_leads=-1,
                                    balance_leads_after, related=Deal)
   if source='rub':
     - amount_rub = bcdiv($priceKopecks, '100', 2)  (string-math, не float)
     - $lockedTenant->decrement('balance_rub', $amount_rub)
     - $lockedTenant->increment('delivered_in_month', 1)
     - $lockedTenant->refresh()
     - INSERT lead_charges (charge_source='rub', tier_no=current,
                            price_per_lead_kopecks=$priceKopecks, charged_at=now)
     - INSERT balance_transactions (type='lead_charge', amount_rub=-$amount_rub,
                                    balance_rub_after, related=Deal)
6. INSERT supplier_lead_costs (deal_id, received_at, supplier_id, cost_rub)
   — закрывает gap (Plan 2/3 не писали в sharing-flow). supplier_id резолвится через
   $lead->supplierProject->supplier_id (FK supplier_projects → suppliers); если null
   (stub-проект без resolved supplier) — fallback в suppliers WHERE code = strtolower(platform).
   cost_rub = $supplier->cost_rub (snapshot на момент INSERT'а).
7. return new ChargeResult(source, currentTier, priceKopecks)
```

**Атомарность:** все INSERT'ы — внутри одного `DB::transaction` родительского closure. Композитный DEFERRABLE FK `lead_charges → deals(id, received_at)` проверяется на commit.

**Денежная арифметика:** `price_per_lead_kopecks` (INTEGER) → `balance_rub` (DECIMAL 12,2) через `bcdiv($priceKopecks, '100', 2)`, не PHP float. `Tenant::decrement('balance_rub', $amount)` использует SQL UPDATE (Eloquent), PG корректно держит в DECIMAL.

### 3.4. Concurrency и порядок locks

Текущий код держит lock'и в порядке (после Plan 2.5 fix #2):

1. `Tenant::lockForUpdate` ([RouteSupplierLeadJob.php:188-191](../../../app/app/Jobs/RouteSupplierLeadJob.php#L188-L191))
2. `Project::lockForUpdate` ([RouteSupplierLeadJob.php:199-202](../../../app/app/Jobs/RouteSupplierLeadJob.php#L199-L202))

Plan 4 не меняет порядок. `LedgerService::chargeForDelivery` принимает **уже locked** `$tenant` — не делает повторный SELECT.

## 4. Monthly reset + balance-insufficient → auto-pause

### 4.1. `ResetMonthlyCountersCommand` (cron 1-го числа в 00:00 МСК)

Расширенный аналог `ResetDeliveredTodayCommand`:

```php
namespace App\Console\Commands;

final class ResetMonthlyCountersCommand extends Command
{
    protected $signature = 'projects:reset-monthly';
    protected $description = 'Сброс tenants.delivered_in_month + projects.delivered_in_month = 0 (1-го числа в 00:00 МСК)';

    public function handle(): int
    {
        DB::connection('pgsql_supplier')->transaction(function () {
            $tenants = DB::connection('pgsql_supplier')
                ->update('UPDATE tenants  SET delivered_in_month = 0 WHERE delivered_in_month <> 0');
            $projects = DB::connection('pgsql_supplier')
                ->update('UPDATE projects SET delivered_in_month = 0 WHERE delivered_in_month <> 0');
            $this->info("Monthly reset: {$tenants} tenants, {$projects} projects.");
        });
        return self::SUCCESS;
    }
}
```

Schedule entry в [routes/console.php](../../../app/routes/console.php) — после `reset-delivered-today`:

```php
Schedule::command('projects:reset-monthly')
    ->monthlyOn(1, '00:00')
    ->timezone('Europe/Moscow');
```

Идемпотентность: `WHERE delivered_in_month <> 0` → повторный запуск даёт 0 affected.

### 4.2. Auto-pause при insufficient balance

`InsufficientBalanceException` ловится в `createDealCopyForProject` **после** rollback'а транзакции:

```php
try {
    return DB::transaction(function () use (...) { /* существующий код + LedgerService */ });
} catch (InsufficientBalanceException $e) {
    $this->handleInsufficientBalance($lead, $project, $e);
    return false;
}
```

```php
private function handleInsufficientBalance(
    SupplierLead $lead,
    Project $project,
    InsufficientBalanceException $e,
): void {
    // UPDATE через pgsql_supplier (BYPASSRLS-роль crm_supplier_worker) — паттерн
    // ResetDeliveredTodayCommand. Не используем SET LOCAL app.current_tenant_id,
    // т.к. queue worker может работать под non-tenant context'ом.
    DB::connection('pgsql_supplier')
        ->update('UPDATE projects SET is_active = false WHERE id = ?', [$project->id]);

    $cacheKey = "billing:zero_balance_alert:{$project->tenant_id}";
    if (Cache::add($cacheKey, true, now()->addHour())) {
        app(NotificationService::class)->notifyZeroBalance($project->tenant, $project);
    }

    Log::warning('billing.project_paused_insufficient_balance', [
        'tenant_id' => $project->tenant_id,
        'project_id' => $project->id,
        'supplier_lead_id' => $lead->id,
        'price_kopecks' => $e->priceKopecks,
        'balance_rub' => $e->balanceRub,
        'balance_leads' => $e->balanceLeads,
    ]);
}
```

### 4.3. Семантика паузы

- **Что пауза делает:** `projects.is_active = false`. `LeadRouter::matchEligibleProjects` уже фильтрует `WHERE is_active = true` (Plan 1/2 паттерн — подтверждается тестом).
- **Что НЕ делает:** не отменяет связь с supplier_projects, не сбрасывает счётчики, не трогает `SyncSupplierProjectsJob` (но союз active-tenant'ов пересчитается на след. SyncJob run).
- **Re-activation:** через UI пополнения баланса (Plan 4.5+) или manual toggle. Auto-resume — out of Plan 4.

### 4.4. Notification — `notifyZeroBalance`

`NotificationService::notifyZeroBalance(Tenant $tenant, Project $project)`. Матрица `notification_preferences` ([schema.sql:716](../../../db/schema.sql#L716)) `zero_balance` дефолтно через email. Email через Unisender Go.

Mailable `App\Mail\ZeroBalancePausedMail` + blade `resources/views/emails/zero_balance_paused.blade.php`:

- Subject: «Проект "{name}" приостановлен — недостаточно средств»
- Body (русский): имя проекта, текущий баланс (rub и leads), требуемая ступень + цена, ссылка `/billing/topup`.

### 4.5. Rate-limit алертов 1/час/tenant

Через `Cache::add($key, true, now()->addHour())` — atomic Redis SETNX. Открытый вопрос #2: возможна корректировка.

## 5. CSV reconcile архитектура

### 5.1. Расширение `SupplierPortalClient`

Один новый публичный метод в [SupplierPortalClient.php:65](../../../app/app/Services/Supplier/SupplierPortalClient.php#L65):

```php
public function downloadLeadsCsv(CarbonInterface $from, CarbonInterface $to): string
{
    $response = $this->request('GET', '/admin/report/index', [
        'type' => 49,
        'from' => $from->format('Y-m-d H:i:s'),
        'to'   => $to->format('Y-m-d H:i:s'),
    ]);

    return $response->body();
}
```

`request()` ([line 70](../../../app/app/Services/Supplier/SupplierPortalClient.php#L70)) уже поддерживает GET + query-string + 401 retry через `RefreshSupplierSessionJob` + 5xx → `SupplierTransientException` + 4xx → `SupplierClientException`.

### 5.2. `SupplierCsvParser`

Pure парсер, streaming через generator:

```php
namespace App\Services\Supplier;

final class SupplierCsvParser
{
    /**
     * Ожидаемые столбцы (placeholder — открытый вопрос #4):
     *   vid;project;tag;phone;phones;time
     *
     * @return iterable<int, array{vid: string, project: string, phone: string, time: int}>
     */
    public function parse(string $rawCsv): iterable;
}
```

### 5.3. `CsvReconcileJob`

```php
namespace App\Jobs\Supplier;

final class CsvReconcileJob implements ShouldQueue
{
    public int $tries = 1;
    public int $timeout = 300;

    public function handle(
        SupplierPortalClient $portal,
        SupplierCsvParser $parser,
        Mailer $mailer,
    ): void;
}
```

**Алгоритм:**

```
1. Cache::lock('supplier:csv_reconcile', 600) — защита от overlap'а.
2. INSERT supplier_csv_reconcile_log (status='running', started_at=now,
     window_start=now-25h, window_end=now).
3. csv = $portal->downloadLeadsCsv(window_start, window_end).
4. rows = $parser->parse(csv) → собрать ['vid' => row, ...].
5. existing = SELECT vid FROM supplier_leads WHERE received_at BETWEEN window_start AND window_end
              (через pgsql_supplier BYPASSRLS).
6. missing = array_diff_key(csvRows, existing).
7. drift_ratio = count(missing) / max(count(rows), 1).
8. Для каждой missing row:
   - INSERT supplier_leads (vid, raw_payload=json_encode(row), received_at=row.time,
                            recovered_from_csv_at=now, supplier_project_id=null).
   - dispatch(new RouteSupplierLeadJob($newLead->id)).
   - recovered_count++.
9. UPDATE supplier_csv_reconcile_log:
   total_csv_rows, matched_count, recovered_count, drift_ratio, finished_at, status.
10. if drift_ratio > 0.05:
    - status='drift_alert', $mailer->send(CsvDriftAlertMail), alert_email_sent_at=now.
11. На исключение: status='failed', error_message, throw.
```

Окно — 25h (запас 1h над hourly cron'ом). Дубли через `supplier_leads.vid UNIQUE` — INSERT упадёт `unique_violation`, ловим, считаем как matched.

### 5.4. Schedule entry

После Plan 3 entries в [routes/console.php](../../../app/routes/console.php):

```php
Schedule::job(new CsvReconcileJob)->hourly();
```

Без `withoutOverlapping()` (нет `cache_locks`). Защита от overlap — внутренний `Cache::lock` (шаг 1).

### 5.5. Recovery flow

`RouteSupplierLeadJob` уже идемпотентен (Plan 2.5 fix #3 `processed_at` guard). `recovered_from_csv_at` маркер нужен для:

1. Отчётности «webhook vs CSV» (spec §5.2).
2. ActivityLog tag: `'source' => $lead->recovered_from_csv_at !== null ? 'supplier_csv_recovery' : 'supplier_webhook'`.

### 5.6. `CsvDriftAlertMail`

Mailable + `resources/views/emails/csv_drift_alert.blade.php`:

- Subject: «Лидерра ↔ Поставщик: расхождение CSV > 5% за {window}»
- Body (русский): drift %, total_csv_rows, missing_count, recovered_count, window, ссылка на `supplier_csv_reconcile_log.id`.
- Адресат: `config('services.supplier.alert_email')` — **не верифицировал** существование ключа в config из Plan 3; при impl грепнем и при необходимости добавим.

## 6. Admin UI экраны

### 6.1. `/admin/pricing-tiers` (SaaS-admin)

**Frontend:** `resources/js/views/admin/AdminPricingTiersView.vue`. Маршрут в `resources/js/router/index.ts`:

```ts
{ path: '/admin/pricing-tiers',
  name: 'admin-pricing-tiers',
  component: () => import('@/views/admin/AdminPricingTiersView.vue'),
  meta: { layout: 'app', requiresAdmin: true } },
```

**UI:** Vue 3 + Vuetify 3, Forest-палитра, Inter + JetBrains Mono для цифр (`font-feature-settings:'tnum'`).

- `<v-card>` «Текущая активная сетка (с {effective_from})»: `<v-data-table>` 7 строк (tier_no, leads_in_tier — `NULL → «всё свыше»`, price отображается через computed «{руб},{коп}»).
- `<v-card>` «Запланированные изменения»: будущие pricing_tiers (`effective_from > today`) + «отменить запланированное».
- `<v-btn>` «Редактировать сетку» → `<v-dialog>` с 7-строчным редактором: `<v-text-field type="number">` для `leads_in_tier` (последний disabled = «NULL»), `<v-text-field>` для `price_rub` с 2 десятичными.
- `effective_from`: read-only, auto = «1-е след. месяца».
- POST `/api/admin/pricing-tiers` (создаёт 7 новых строк с одинаковым `effective_from`).

**Backend:** `App\Http\Controllers\Api\AdminPricingTiersController`:

- `GET /api/admin/pricing-tiers` — active + scheduled.
- `POST /api/admin/pricing-tiers` — 7 новых rows с `effective_from = DATE_TRUNC('month', NOW() + INTERVAL '1 month')`. Validation: ровно 7 tiers, `tier_no` 1..7 unique, `leads_in_tier > 0` для 1..6, `leads_in_tier IS NULL` для 7, `price_per_lead_kopecks >= 0`. Single transaction.
- `DELETE /api/admin/pricing-tiers/scheduled/{effective_from}` — отмена будущей сетки.

Маршрут в [routes/web.php:99](../../../app/routes/web.php#L99):

```php
Route::prefix('/api/admin/pricing-tiers')->group(function () {
    Route::get('/', 'App\Http\Controllers\Api\AdminPricingTiersController@index');
    Route::post('/', 'App\Http\Controllers\Api\AdminPricingTiersController@store');
    Route::delete('/scheduled/{effective_from}',
        'App\Http\Controllers\Api\AdminPricingTiersController@deleteScheduled')
        ->where('effective_from', '\d{4}-\d{2}-\d{2}');
});
```

Без auth middleware (паритет с другими `/api/admin/*`). Audit trail — `saas_admin_audit_log`.

### 6.2. `/admin/supplier-prices` (SaaS-admin)

**Frontend:** `resources/js/views/admin/AdminSupplierPricesView.vue` + маршрут `/admin/supplier-prices`.

**UI:** `<v-card>` + `<v-data-table>` 3 строки (B1/B2/B3): code, name, cost_rub (editable inline `<v-text-field type="number" step="0.01">`), quality_score (editable inline), is_active toggle. Кнопка «Сохранить».

**Backend:** `App\Http\Controllers\Api\AdminSuppliersController`:

- `GET /api/admin/suppliers` — список 3 строк.
- `PATCH /api/admin/suppliers/{id}` — обновление `cost_rub` / `quality_score` / `is_active`. Audit log.

```php
Route::get('/api/admin/suppliers', 'App\Http\Controllers\Api\AdminSuppliersController@index');
Route::patch('/api/admin/suppliers/{id}', 'App\Http\Controllers\Api\AdminSuppliersController@update')
    ->where('id', '[0-9]+');
```

### 6.3. Tenant-side: «Списания» tab в `BillingView`

Не отдельный route — **новый tab** в существующем `BillingView`. Tabs: «Баланс» + «Списания» (новый) + «Тариф» (read-only показ pricing_tiers — открытый вопрос #3: всё или только текущая ступень).

**UI** `resources/js/views/billing/ChargesTab.vue`:

- Фильтры: `<v-select>` период (текущий месяц / прошлый / 90 дней / диапазон), `<v-select>` charge_source.
- `<v-data-table>`: charged_at, deal_id (clickable → `/deals/{id}`), tier_no, charge_source (chip), price_rub, balance_rub_after.
- Footer: «Всего за период: {N} лидов, {sum} ₽» + кнопка «Скачать CSV».
- Pagination server-side.

**Backend:** `App\Http\Controllers\Api\TenantChargesController` под `auth:sanctum + tenant`:

```php
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing/charges')->group(function () {
    Route::get('/', 'App\Http\Controllers\Api\TenantChargesController@index');
    Route::post('/export', 'App\Http\Controllers\Api\TenantChargesController@export');
});
```

- `GET /api/billing/charges` — paginated lead_charges WHERE tenant_id = current (RLS защищает через `SetTenantContext`). JOIN `pricing_tiers` для leads_in_tier.
- `POST /api/billing/charges/export` — CSV через OpenSpout + `StreamedResponse` (паттерн из DealExport).

### 6.4. Nav-tree

В [AppLayout.vue](../../../app/resources/js/layouts/AppLayout.vue) — добавить пункты под admin-секцию (если `user.is_saas_admin`): «Тарифы» (`/admin/pricing-tiers`), «Поставщики» (`/admin/supplier-prices`).

**Не верифицировал** различение saas-admin vs tenant-user в nav-tree (связано с Б-1 / SSO). Грепнем при impl.

### 6.5. Stories для Histoire

- `AdminPricingTiersView.story.vue` — 4 variants: пустая сетка / активная / scheduled / dialog open.
- `AdminSupplierPricesView.story.vue` — 2 variants: default / editing row.
- `billing/ChargesTab.story.vue` — 3 variants: пустой / mixed prepaid+rub / только rub.

### 6.6. A11y и стиль

- Vuetify-компоненты покрывают keyboard nav + ARIA.
- Числа — JetBrains Mono с `font-feature-settings:'tnum'`.
- Pa11y проверка после impl — 0 violations.

## 7. Тестовая стратегия и Acceptance Criteria

### 7.1. Сводка тестов

| Категория | Слой | Кол-во |
|---|---|---|
| Pure unit на `PricingTierResolver` | tests/Unit/Billing | 7 |
| Integration `LedgerService::chargeForDelivery` | tests/Feature/Billing | 6 |
| E2E `RouteSupplierLeadJob` с биллингом | tests/Feature/Supplier | 4 |
| `ResetMonthlyCountersCommand` | tests/Feature/Console | 4 |
| Auto-pause flow | tests/Feature/Supplier | 5 |
| `SupplierCsvParser` | tests/Unit/Supplier | 5 |
| `SupplierPortalClient::downloadLeadsCsv` | tests/Unit/Supplier | 3 |
| `CsvReconcileJob` integration | tests/Feature/Supplier | 6 |
| E2E CSV happy-path mock-server | tests/Browser (Linux CI only) | 1 |
| `AdminPricingTiersController` | tests/Feature/Admin | 8 |
| `AdminSuppliersController` | tests/Feature/Admin | 4 |
| `TenantChargesController` | tests/Feature/Billing | 6 |
| Frontend Vitest на 3 Vue компонента | resources/js/.../spec.ts | 12 |
| **Итого новых тестов Plan 4** | | **71** |

После Plan 4 baseline: ориентировочно **688 Pest + 405 Vitest** (фактическое число подтвердится при impl).

### 7.2. Pre-commit (lefthook) gates

Без изменений в `lefthook.yml`: Pint / Larastan / squawk / pgFormatter / cspell / gitleaks / markdownlint — встраивается в существующие jobs. Larastan baseline вероятно расширится на ~5-10 entries.

### 7.3. Acceptance criteria

| AC | Описание | Verification |
|---|---|---|
| **AC-1** | Schema v8.18 → v8.19: +1 таблица, +3 колонки, +3 индекса, +2 CHECK. | `migrate:fresh` зелёный; `db/CHANGELOG_schema.md`. |
| **AC-2** | `RouteSupplierLeadJob` пишет `lead_charges` + `supplier_lead_costs` в одной транзакции с Deal. | 4 E2E теста (см. §7.1). |
| **AC-3** | Dual-balance: balance_leads-first, balance_rub-second; tier через `delivered_in_month + 1`; bcmath. | 6 LedgerService тестов (см. §7.1). |
| **AC-4** | `ResetMonthlyCountersCommand` + `monthlyOn(1, '00:00')` + Europe/Moscow. | 4 Console-теста + `schedule:list`. |
| **AC-5** | Insufficient balance → exception → rollback → `is_active=false` + email (1/час/tenant). | 5 auto-pause тестов. |
| **AC-6** | `CsvReconcileJob` hourly, окно 25h, drift > 5% → email, лог в `supplier_csv_reconcile_log`. | 6 integration + 1 E2E. |
| **AC-7** | Admin UI: 7-tier editor, 3-row supplier prices, ChargesTab + CSV export. | 18 backend + 12 frontend + Histoire +9 variants + Pa11y 0. |
| **AC-8** | Retry-idempotency: повторный run job'а не пишет дубль `lead_charges`. | 1 retry-idempotency тест. |
| **AC-9** | Атомарные коммиты: 1 commit = 1 logical change. | git log review. |
| **AC-10** | 0 schema-orphan FK / 0 duplicate CREATE TABLE / RLS-метрики 39. | self-review per Pravila §4.6. |

### 7.4. Verification gate перед merge (CV-pattern)

1. `composer pint` — clean diff.
2. `composer stan` — 0 errors above baseline.
3. `composer test` — все Pest зелёные.
4. `npm run lint:vue` — clean.
5. `npm run type-check` — 0 errors.
6. `npm run test:vue` — все Vitest зелёные.
7. `npm run story` build — все variants собираются.
8. `php artisan migrate:fresh --database=liderra_testing` + RLS smoke + model smoke.
9. `npm run a11y` — 0 violations.
10. `gitleaks detect --no-banner` full history — 0 leaks.
11. `npm run links` (lychee) — 0 broken.
12. code-reviewer subagent → no BLOCKER.
13. Manual smoke tinker `RouteSupplierLeadJob`.
14. Manual UI smoke: pricing-tiers editor.

### 7.5. Risks / mitigations

| Risk | Mitigation |
|---|---|
| `delivered_in_month` race 2 concurrent deliveries | `lockForUpdate(Tenant)` уже держится; tier-lookup внутри lock → safe. |
| PHP float drift при price/100 | `bcdiv($kopecks, '100', 2)` — string-math; `decimal:2` cast. |
| CSV-схема расходится с реальной (Tasks 1-2 BLOCKED) | Schema parser placeholder; Http::fake тесты; impl Task 0 = manual curl после credentials. |
| Reset cron не запустился вовремя | Idempotent UPDATE; manual `php artisan projects:reset-monthly`. |
| Redis crash → Cache::add false → ноль алертов | Log::warning всегда; observability-alert out of Plan 4. |
| Pricing-tier editor: tier 7 c `leads_in_tier ≠ NULL` | Validation на бэке: ровно одна row с NULL = tier 7. |
| `charge_source='prepaid'` + `price ≠ 0` | CHECK `chk_lead_charges_prepaid_zero_price`. |

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

| # | Вопрос | Кто решает | Дефолт |
|---|---|---|---|
| 1 | Дефолтные tier-цены (500/450/400/350/300/270/250 руб) | Заказчик | placeholder, ждём согласования перед seed'ом |
| 2 | Email rate-limit 1/час/tenant — норма? | Заказчик | 1/час |
| 3 | Tenant видит ВСЕ ступени или только свою? | Заказчик | transparent (все) |
| 4 | CSV-схема (точные столбцы из `/admin/report/index?type=49`) | Discovery после credentials | placeholder webhook-payload |
| 5 | CSV окно (25h) — норма? | Заказчик/опыт | 25h |
| 6 | Drift threshold 5% — норма? | Заказчик/опыт | 5% |
| 7 | Pricing-tier-change при повышении цены | Заказчик | единая логика с понижением (effective 1-е след. месяца) |

Эти 7 — попадают в Открытые_вопросы реестра как новые `Биз-*` после approval'а спецификации.

### 7.7. Декомпозиция на impl-задачи (preview)

Детальный impl-plan будет написан через skill `superpowers:writing-plans` после approval'а spec'а. Preview:

1. **Task 1:** Schema delta v8.18 → v8.19.
2. **Task 2:** `PricingTierResolver` (pure unit, TDD).
3. **Task 3:** `LedgerService::chargeForDelivery` (integration, TDD).
4. **Task 4:** Integration `LedgerService` в `RouteSupplierLeadJob`.
5. **Task 5:** `ResetMonthlyCountersCommand` + Schedule entry.
6. **Task 6:** Auto-pause flow + `ZeroBalancePausedMail` + rate-limit.
7. **Task 7:** `SupplierPortalClient::downloadLeadsCsv` + `SupplierCsvParser`.
8. **Task 8:** `CsvReconcileJob` + `supplier_csv_reconcile_log` + `CsvDriftAlertMail`.
9. **Task 9:** `AdminPricingTiersController` backend + Vue view + Histoire.
10. **Task 10:** `AdminSuppliersController` backend + Vue view + Histoire.
11. **Task 11:** `TenantChargesController` + ChargesTab + CSV export + Histoire.
12. **Task 12:** Verification gate (full CV) + code-review subagent.

12 Tasks. TDD: red → green → refactor; атомарный коммит на Task.

## 8. Ограничения и неверифицированное

В соответствии с экономия-0% дисциплиной фиксирую явно, что **не верифицировано** на момент написания spec'а:

- `LeadRouter::matchEligibleProjects` действительно фильтрует `WHERE is_active = true` — будет проверено грепом и feature-тестом при impl.
- `config('services.supplier.alert_email')` ключ уже существует из Plan 3 — будет проверено грепом при impl.
- `AppLayout.vue` различает saas-admin vs tenant-user nav-tree — будет проверено грепом при impl.
- Точное число Pest baseline после Plan 4 (688) — округлённое; финальное число — после запуска `composer test`.
- Vuetify `<v-data-table>` cells применяют `font-feature-settings:'tnum'` без кастомного slot'а — будет проверено визуально при impl.
- OpenSpout `StreamedResponse` паттерн в проекте — упомянут в memory `feedback_environment.md`, но конкретный пример код в DealExport не перечитывал; найдём при impl.

## 9. Источник истины и cross-refs

- Spec Plan 4 (этот файл): docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md
- Parent spec: [docs/superpowers/specs/2026-05-10-supplier-integration-design.md](./2026-05-10-supplier-integration-design.md) §5.2, §7
- Schema: [db/schema.sql](../../../db/schema.sql) (v8.18; будет v8.19 после impl)
- CLAUDE.md §6 (текущая фаза) — будет обновлён после merge Plan 4
- Pravila §4.5 (3 варианта) — overridden brainstorming-skill согласно §11.1 + §12
