# Биллинг v2 Спек C — Преfflight баланса + VTB-эквайринг — План реализации

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Pravila §15.1** — git-коммит задачи только Sonnet/Opus субагентами (не Haiku); контроллер верифицирует `git rev-parse HEAD` после каждого субагента.

**Goal:** Защитить портал от заказа лидов у поставщика на клиентов без баланса (преfflight) + дать реальное пополнение баланса через безнал (PDF-счёт + сверка) с архитектурой под СБП/карты VTB.

**Architecture:** Преfflight — фильтр eligible-проектов до формулы `computeOrder` (формула не меняется) + флаги заморозки на tenant/project + ежедневный cron 18:00 MSK. VTB — интерфейс `TopupGatewayInterface` + 3 реализации (безнал полный, СБП/карты dev-заглушки) + журнал `topup_sessions`.

**Tech Stack:** PHP 8.3 / Laravel 13, PostgreSQL 16 (RLS), Pest 4, Vue 3 + Vuetify 3, Vitest, barryvdh/laravel-dompdf (новый).

**Спек:** [docs/superpowers/specs/2026-05-24-billing-v2-spec-c-preflight-vtb-design.md](../specs/2026-05-24-billing-v2-spec-c-preflight-vtb-design.md)

---

## Контекст для исполнителя (прочитать до начала)

**Что уже есть на `origin/main` (зависимости):**

- `App\Services\Billing\BalanceToLeadsConverter::convert(string $balanceRub, int $deliveredInMonth, Collection $tiers): array` — возвращает `['leads' => int, 'breakdown' => [...], 'current_tier' => ..., 'next_tier' => ...]`. Pure, bcmath. Используется в преfflight для «сколько лидов даёт баланс».
- `App\Services\Billing\PricingTierResolver` — резолвер ступени по порядковому номеру лида.
- `App\Services\Billing\LedgerService` — списания (не трогаем).
- `App\Services\Billing\BillingTopupService::topup(int $tenantId, string $amountRub, ?int $userId)` — MVP-stub пополнения (рефакторим).
- `App\Models\Tenant` — `balance_rub` (decimal:2), `delivered_in_month` (integer), оба в `$fillable`/`$casts`.
- `App\Models\BalanceTransaction` — константы `TYPE_TOPUP`, `TYPE_MANUAL_ADJUSTMENT` и др.; append-only через триггер `audit_block_mutation`; hash-chain через `audit_chain_hash`.
- `App\Services\Supplier\SupplierQuotaAllocator::allocate(...)` + `computeOrder(array $dailyLimits): int` — формула заказа. **Не меняем** — только фильтруем вход в caller'е.
- `App\Jobs\Supplier\SyncSupplierProjectsJob` — daily-пересчёт заказа поставщику. Caller allocator'а.
- Schedule живёт в `app/routes/console.php` с обёрткой `SchedulerHeartbeatTracker::recordRunResult(name, ok, err, ms)` (heartbeat hole #6) — все cron-задачи регистрируются через `->before()/->onSuccess()/->onFailure()` хуки.
- БД-роли: миграции с `CREATE TABLE` запускать через `DB::connection('pgsql_supplier')` + явные `GRANT` в DO-блоке для 4 ролей (см. [project_billing_v2 memory] урок Спека B; default privileges от postgres-superuser НЕ наследуются на чужие creator-роли).

**Деньги:** все операции через `bcadd`/`bcsub`/`bcmul`/`bcdiv` со строками, scale=2 для рублей, никогда PHP float. Списания/мутации баланса — `Tenant::lockForUpdate()` внутри транзакции.

**Время:** cut-off 18:00 MSK = `Europe/Moscow` timezone в Schedule. Тесты времени — `Carbon::setTestNow()` (см. урок Sprint 2 — без него evening-clock баги).

**Тесты:** Pest. Feature в `app/tests/Feature/Billing/` и `app/tests/Feature/Supplier/`; Unit в `app/tests/Unit/Billing/`. Запуск: `cd app && php artisan test --filter=...` или `composer test`.

---

## Phase 0 — Подготовка рабочего пространства

### Task 0.1: Создать worktree от свежего origin/main

**Files:** нет (git-операция)

- [ ] **Step 1: Обновить origin и создать worktree**

REQUIRED SUB-SKILL: `superpowers:using-git-worktrees` для изоляции.

```bash
git fetch origin
git worktree add -b feat/billing-v2-spec-c .claude/worktrees/billing-v2-spec-c origin/main
```

- [ ] **Step 2: Скопировать gitignored-файлы в worktree** (урок Sprint 4 — свежий worktree без `.env`, `storage`, бинарей)

```bash
# из основного checkout'а
cp app/.env .claude/worktrees/billing-v2-spec-c/app/.env
cp -r app/storage/framework .claude/worktrees/billing-v2-spec-c/app/storage/
cp app/_ide_helper.php .claude/worktrees/billing-v2-spec-c/app/ 2>/dev/null || true
cp bin/lychee.exe .claude/worktrees/billing-v2-spec-c/bin/ 2>/dev/null || true
```

- [ ] **Step 3: Проверить, что зависимости установлены**

Run: `cd .claude/worktrees/billing-v2-spec-c/app && composer install --no-interaction && npm ci`
Expected: установка без ошибок; `vendor/` и `node_modules/` на месте.

- [ ] **Step 4: Smoke миграции на тестовой БД**

Run: `cd .claude/worktrees/billing-v2-spec-c/app && php artisan migrate:fresh --env=testing && php artisan test --filter=BalanceToLeadsConverter`
Expected: миграции прошли, converter-тесты GREEN (зависимость Спека A на месте).

---

## Phase 1 — Преfflight баланса

### Task 1.1: Миграция БД — флаги заморозки + журнал

**Files:**
- Create: `app/database/migrations/2026_05_24_100000_add_balance_freeze_to_tenants_and_projects.php`
- Create: `db/migrations/2026_05_24_balance_freeze_log.sql` (для prod-синка вручную, как в Спеке B)
- Modify: `db/schema.sql` (добавить колонки + таблицу + запись в `db/CHANGELOG_schema.md`)

- [ ] **Step 1: Написать миграцию (флаги + журнальная таблица)**

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
    public function up(): void
    {
        // Флаги заморозки. tenant-level — пассивный износ; project-level — точечная перегрузка.
        DB::statement('ALTER TABLE tenants ADD COLUMN IF NOT EXISTS frozen_by_balance_at TIMESTAMP NULL');
        DB::statement('ALTER TABLE projects ADD COLUMN IF NOT EXISTS preflight_blocked_at TIMESTAMP NULL');

        DB::statement('CREATE INDEX IF NOT EXISTS tenants_frozen_by_balance_idx ON tenants (frozen_by_balance_at) WHERE frozen_by_balance_at IS NOT NULL');
        DB::statement('CREATE INDEX IF NOT EXISTS projects_preflight_blocked_idx ON projects (preflight_blocked_at) WHERE preflight_blocked_at IS NOT NULL');

        // Журнал заморозок/разморозок. Создаём через pgsql_supplier (урок Спека B — prod-роли).
        $supplier = DB::connection('pgsql_supplier');
        $supplier->statement(<<<'SQL'
            CREATE TABLE IF NOT EXISTS balance_freeze_log (
                id BIGSERIAL PRIMARY KEY,
                tenant_id BIGINT NOT NULL REFERENCES tenants(id),
                event_type VARCHAR(30) NOT NULL,
                triggered_by VARCHAR(30) NOT NULL,
                balance_rub_at_event DECIMAL(12,2) NOT NULL,
                required_leads INTEGER NOT NULL,
                capacity_leads INTEGER NOT NULL,
                total_daily_limit INTEGER NOT NULL,
                details JSONB,
                created_at TIMESTAMP NOT NULL DEFAULT now()
            )
        SQL);
        $supplier->statement('ALTER TABLE balance_freeze_log ENABLE ROW LEVEL SECURITY');
        $supplier->statement(<<<'SQL'
            CREATE POLICY tenant_isolation ON balance_freeze_log
            USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint)
        SQL);
        $supplier->statement('CREATE INDEX IF NOT EXISTS balance_freeze_log_tenant_idx ON balance_freeze_log (tenant_id, created_at DESC)');

        // Гранты для 4 ролей (mirror webhook_dedup_keys / supplier_lead_deliveries).
        foreach (['crm_app_user', 'crm_supplier_worker', 'crm_migrator', 'crm_app_admin'] as $role) {
            $supplier->statement(<<<SQL
                DO \$\$
                BEGIN
                    IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{$role}') THEN
                        GRANT SELECT, INSERT ON balance_freeze_log TO {$role};
                        GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO {$role};
                    END IF;
                END
                \$\$
            SQL);
        }
    }

    public function down(): void
    {
        DB::connection('pgsql_supplier')->statement('DROP TABLE IF EXISTS balance_freeze_log');
        DB::statement('ALTER TABLE projects DROP COLUMN IF EXISTS preflight_blocked_at');
        DB::statement('ALTER TABLE tenants DROP COLUMN IF EXISTS frozen_by_balance_at');
    }
};
```

- [ ] **Step 2: Прогнать миграцию на testing**

Run: `cd app && php artisan migrate --env=testing`
Expected: `2026_05_24_100000_add_balance_freeze... DONE`. Без ошибок прав.

- [ ] **Step 3: Проверить, что append-only НЕ нужен журналу** (в отличие от balance_transactions — журнал заморозок не финансовый, UPDATE не предполагается, но INSERT-only по дизайну). Зафиксировать решение комментарием в миграции.

- [ ] **Step 4: Синхронизировать `db/schema.sql` + CHANGELOG**

Добавить в `db/schema.sql` определения колонок и таблицы (вручную, по факту миграции). Записать в `db/CHANGELOG_schema.md` строку «v8.34 → v8.35: +balance_freeze_log, +tenants.frozen_by_balance_at, +projects.preflight_blocked_at».

- [ ] **Step 5: Commit**

```bash
git add app/database/migrations/2026_05_24_100000_*.php db/migrations/2026_05_24_balance_freeze_log.sql db/schema.sql db/CHANGELOG_schema.md
git commit -m "feat(billing-v2-c): миграция — флаги заморозки баланса + balance_freeze_log"
```

---

### Task 1.2: `BalancePreflightService` — pure-проверка платёжеспособности

**Files:**
- Create: `app/app/Services/Billing/BalancePreflightService.php`
- Create: `app/app/Services/Billing/PreflightResult.php`
- Test: `app/tests/Unit/Billing/BalancePreflightServiceTest.php`

- [ ] **Step 1: Написать failing-тест**

```php
<?php

declare(strict_types=1);

use App\Models\PricingTier;
use App\Services\Billing\BalancePreflightService;
use Illuminate\Database\Eloquent\Collection;

function makeTiers(): Collection
{
    // 1 ступень: цена 50₽/лид (5000 копеек), безлимитная.
    return new Collection([
        new PricingTier(['tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 5000, 'is_active' => true]),
    ]);
}

it('passes when balance covers full daily limit', function () {
    $service = new BalancePreflightService();
    // 2000₽ / 50₽ = 40 лидов capacity; нужно 30 → проходит.
    $result = $service->evaluate(
        balanceRub: '2000.00',
        deliveredInMonth: 0,
        requiredLeads: 30,
        tiers: makeTiers(),
    );

    expect($result->passes)->toBeTrue();
    expect($result->capacityLeads)->toBe(40);
    expect($result->requiredLeads)->toBe(30);
    expect($result->deficitLeads)->toBe(0);
});

it('fails when balance below full daily limit', function () {
    $service = new BalancePreflightService();
    // 1000₽ / 50₽ = 20 лидов capacity; нужно 30 → не проходит, дефицит 10.
    $result = $service->evaluate(
        balanceRub: '1000.00',
        deliveredInMonth: 0,
        requiredLeads: 30,
        tiers: makeTiers(),
    );

    expect($result->passes)->toBeFalse();
    expect($result->capacityLeads)->toBe(20);
    expect($result->deficitLeads)->toBe(10);
});

it('passes trivially when requiredLeads is zero', function () {
    $service = new BalancePreflightService();
    $result = $service->evaluate('0.00', 0, 0, makeTiers());
    expect($result->passes)->toBeTrue();
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && php artisan test --filter=BalancePreflightServiceTest`
Expected: FAIL — «Class BalancePreflightService not found».

- [ ] **Step 3: Реализовать PreflightResult (DTO)**

```php
<?php

declare(strict_types=1);

namespace App\Services\Billing;

final class PreflightResult
{
    public function __construct(
        public readonly bool $passes,
        public readonly int $requiredLeads,
        public readonly int $capacityLeads,
        public readonly int $deficitLeads,
    ) {}
}
```

- [ ] **Step 4: Реализовать BalancePreflightService**

```php
<?php

declare(strict_types=1);

namespace App\Services\Billing;

use Illuminate\Database\Eloquent\Collection;

/**
 * Pure: проходит ли клиент преfflight — хватает ли баланса на ПОЛНЫЙ дневной
 * лимит всех его eligible-проектов по текущему тарифу.
 *
 * Сравнение в ЛИДАХ (capacity vs required), не в рублях — переиспользует
 * BalanceToLeadsConverter::convert, который учитывает 7 ступеней и накопленный
 * месячный объём (deliveredInMonth).
 *
 * Spec: docs/superpowers/specs/2026-05-24-billing-v2-spec-c-preflight-vtb-design.md §3.3
 */
final class BalancePreflightService
{
    public function __construct(
        private readonly BalanceToLeadsConverter $converter = new BalanceToLeadsConverter(),
    ) {}

    /**
     * @param  Collection<int, \App\Models\PricingTier>  $tiers
     */
    public function evaluate(
        string $balanceRub,
        int $deliveredInMonth,
        int $requiredLeads,
        Collection $tiers,
    ): PreflightResult {
        if ($requiredLeads <= 0) {
            return new PreflightResult(true, 0, 0, 0);
        }

        $capacity = (int) $this->converter->convert($balanceRub, $deliveredInMonth, $tiers)['leads'];
        $passes = $capacity >= $requiredLeads;

        return new PreflightResult(
            passes: $passes,
            requiredLeads: $requiredLeads,
            capacityLeads: $capacity,
            deficitLeads: $passes ? 0 : ($requiredLeads - $capacity),
        );
    }
}
```

- [ ] **Step 5: Запустить — убедиться, что проходит**

Run: `cd app && php artisan test --filter=BalancePreflightServiceTest`
Expected: PASS (3 теста).

- [ ] **Step 6: Commit**

```bash
git add app/app/Services/Billing/BalancePreflightService.php app/app/Services/Billing/PreflightResult.php app/tests/Unit/Billing/BalancePreflightServiceTest.php
git commit -m "feat(billing-v2-c): BalancePreflightService — pure-проверка платёжеспособности"
```

---

### Task 1.3: Tenant/Project — хелперы платёжеспособности + резолвер eligible-лимита

**Files:**
- Modify: `app/app/Models/Tenant.php` (метод `requiredLeadsForTomorrow()`, scope, casts флага)
- Modify: `app/app/Models/Project.php` (cast `preflight_blocked_at`)
- Test: `app/tests/Feature/Billing/TenantPreflightTest.php`

- [ ] **Step 1: Написать failing-тест**

```php
<?php

declare(strict_types=1);

use App\Models\Project;
use App\Models\Tenant;

it('sums daily_limit of active eligible projects for required leads', function () {
    $tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
    Project::factory()->for($tenant)->create(['status' => 'active', 'daily_limit' => 10]);
    Project::factory()->for($tenant)->create(['status' => 'active', 'daily_limit' => 15]);
    Project::factory()->for($tenant)->create(['status' => 'paused', 'daily_limit' => 100]); // не считается

    expect($tenant->requiredLeadsForTomorrow())->toBe(25);
});

it('casts frozen_by_balance_at to datetime', function () {
    $tenant = Tenant::factory()->create(['frozen_by_balance_at' => now()]);
    expect($tenant->frozen_by_balance_at)->toBeInstanceOf(\Illuminate\Support\Carbon::class);
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && php artisan test --filter=TenantPreflightTest`
Expected: FAIL — метод `requiredLeadsForTomorrow` не существует.

- [ ] **Step 3: Реализовать в Tenant**

Добавить в `$casts`: `'frozen_by_balance_at' => 'datetime'`. Добавить в `$fillable`: `'frozen_by_balance_at'`. Метод:

```php
/**
 * Сумма daily_limit активных проектов — «сколько лидов клиент хочет в день».
 * Используется преfflight'ом как requiredLeads.
 */
public function requiredLeadsForTomorrow(): int
{
    return (int) $this->projects()
        ->where('status', 'active')
        ->sum('daily_limit');
}
```

В `Project` — `$casts`: `'preflight_blocked_at' => 'datetime'`, `$fillable` += `'preflight_blocked_at'`.

- [ ] **Step 4: Запустить — убедиться, что проходит**

Run: `cd app && php artisan test --filter=TenantPreflightTest`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/app/Models/Tenant.php app/app/Models/Project.php app/tests/Feature/Billing/TenantPreflightTest.php
git commit -m "feat(billing-v2-c): Tenant::requiredLeadsForTomorrow + cast флагов заморозки"
```

---

### Task 1.4: `BalancePreflightSweepJob` — ежедневный пересчёт заморозок

**Files:**
- Create: `app/app/Jobs/Billing/BalancePreflightSweepJob.php`
- Create: `app/app/Console/Commands/BillingPreflightSweepCommand.php`
- Modify: `app/routes/console.php` (Schedule @18:00 MSK + heartbeat)
- Test: `app/tests/Feature/Billing/BalancePreflightSweepJobTest.php`

- [ ] **Step 1: Написать failing-тест**

```php
<?php

declare(strict_types=1);

use App\Jobs\Billing\BalancePreflightSweepJob;
use App\Models\PricingTier;
use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Support\Facades\Mail;
use App\Mail\BalanceFrozenMail;

beforeEach(function () {
    PricingTier::factory()->create(['tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 5000, 'is_active' => true]);
});

it('freezes tenant whose balance no longer covers daily limit', function () {
    Mail::fake();
    // 500₽ / 50₽ = 10 лидов; проекты хотят 25 → заморозка.
    $tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
    Project::factory()->for($tenant)->create(['status' => 'active', 'daily_limit' => 25]);

    (new BalancePreflightSweepJob())->handle();

    expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull();
    Mail::assertQueued(BalanceFrozenMail::class);
});

it('unfreezes tenant whose balance now covers daily limit', function () {
    Mail::fake();
    // 2000₽ / 50₽ = 40 лидов; хотят 25 → разморозка.
    $tenant = Tenant::factory()->create(['balance_rub' => '2000.00', 'frozen_by_balance_at' => now()->subDay()]);
    Project::factory()->for($tenant)->create(['status' => 'active', 'daily_limit' => 25]);

    (new BalancePreflightSweepJob())->handle();

    expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
});

it('is idempotent — does not re-freeze already frozen tenant', function () {
    Mail::fake();
    $frozenAt = now()->subDay();
    $tenant = Tenant::factory()->create(['balance_rub' => '0.00', 'frozen_by_balance_at' => $frozenAt]);
    Project::factory()->for($tenant)->create(['status' => 'active', 'daily_limit' => 25]);

    (new BalancePreflightSweepJob())->handle();

    // Дата заморозки не перезаписана, повторного письма нет.
    expect($tenant->fresh()->frozen_by_balance_at->timestamp)->toBe($frozenAt->timestamp);
    Mail::assertNotQueued(BalanceFrozenMail::class);
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && php artisan test --filter=BalancePreflightSweepJobTest`
Expected: FAIL — класс не найден.

- [ ] **Step 3: Реализовать Job**

```php
<?php

declare(strict_types=1);

namespace App\Jobs\Billing;

use App\Mail\BalanceFrozenMail;
use App\Mail\BalanceUnfrozenMail;
use App\Models\PricingTier;
use App\Models\Tenant;
use App\Services\Billing\BalancePreflightService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;

/**
 * Ежедневный преfflight всех тенантов перед формированием заказа поставщику.
 * Запускается cron @18:00 MSK (routes/console.php). См. спек §3.5, §5.2.
 *
 * NB: crm_supplier_worker / системный контекст — джоб бегает без tenant-RLS;
 * запросы к projects/tenants явные по tenant_id (урок Спека B).
 */
final class BalancePreflightSweepJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public function handle(): void
    {
        $service = new BalancePreflightService();
        $tiers = PricingTier::query()->where('is_active', true)->get();

        Tenant::query()->whereNull('deleted_at')->chunkById(200, function ($tenants) use ($service, $tiers): void {
            foreach ($tenants as $tenant) {
                $this->evaluateTenant($tenant, $service, $tiers);
            }
        });
    }

    private function evaluateTenant(Tenant $tenant, BalancePreflightService $service, $tiers): void
    {
        $required = $tenant->requiredLeadsForTomorrow();
        $result = $service->evaluate(
            balanceRub: (string) $tenant->balance_rub,
            deliveredInMonth: (int) $tenant->delivered_in_month,
            requiredLeads: $required,
            tiers: $tiers,
        );

        $isFrozen = $tenant->frozen_by_balance_at !== null;

        // Переход active → frozen
        if (! $result->passes && ! $isFrozen) {
            $tenant->frozen_by_balance_at = now();
            $tenant->save();
            $this->logEvent($tenant, 'frozen', 'cutoff_18msk', $result);
            Mail::to($tenant->email)->queue(new BalanceFrozenMail($tenant, $result));

            return;
        }

        // Переход frozen → active
        if ($result->passes && $isFrozen) {
            $tenant->frozen_by_balance_at = null;
            $tenant->save();
            $this->logEvent($tenant, 'unfrozen', 'cutoff_18msk', $result);
            Mail::to($tenant->email)->queue(new BalanceUnfrozenMail($tenant, $result));

            return;
        }
        // Иначе состояние не изменилось — ничего не делаем (идемпотентность).
    }

    private function logEvent(Tenant $tenant, string $event, string $trigger, $result): void
    {
        DB::connection('pgsql_supplier')->table('balance_freeze_log')->insert([
            'tenant_id' => $tenant->id,
            'event_type' => $event,
            'triggered_by' => $trigger,
            'balance_rub_at_event' => $tenant->balance_rub,
            'required_leads' => $result->requiredLeads,
            'capacity_leads' => $result->capacityLeads,
            'total_daily_limit' => $result->requiredLeads,
            'details' => json_encode(['deficit_leads' => $result->deficitLeads]),
            'created_at' => now(),
        ]);
    }
}
```

- [ ] **Step 4: Реализовать Console-команду**

```php
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Jobs\Billing\BalancePreflightSweepJob;
use Illuminate\Console\Command;

final class BillingPreflightSweepCommand extends Command
{
    protected $signature = 'billing:preflight-sweep';

    protected $description = 'Ежедневный преfflight баланса — заморозка/разморозка тенантов (cut-off 18:00 MSK)';

    public function handle(): int
    {
        (new BalancePreflightSweepJob())->handle();
        $this->info('Преfflight sweep завершён.');

        return self::SUCCESS;
    }
}
```

- [ ] **Step 5: Зарегистрировать в Schedule (routes/console.php)**

Добавить после существующих задач (с heartbeat-обёрткой по образцу `projects:reset-delivered-today`):

```php
// Спек C §3.2: преfflight баланса в 18:00 MSK — формирование заказа поставщику без «бедных» клиентов.
Schedule::command('billing:preflight-sweep')
    ->dailyAt('18:00')
    ->timezone('Europe/Moscow')
    ->before(fn () => $startTimes['billing:preflight-sweep'] = microtime(true))
    ->onSuccess(function () use ($hb, &$startTimes): void {
        $name = 'billing:preflight-sweep';
        $ms = isset($startTimes[$name]) ? (int) ((microtime(true) - $startTimes[$name]) * 1000) : null;
        $hb->recordRunResult($name, true, null, $ms);
    })
    ->onFailure(function () use ($hb): void {
        $hb->recordRunResult('billing:preflight-sweep', false, 'Command failed', null);
    });
```

**ВАЖНО:** `billing:preflight-sweep` @18:00 должен идти **до** `SyncSupplierProjectsJob` (если тот в расписании). Проверить порядок и при необходимости сдвинуть Sync на 18:05.

- [ ] **Step 6: Запустить — убедиться, что проходит**

Run: `cd app && php artisan test --filter=BalancePreflightSweepJobTest`
Expected: PASS (3 теста).

- [ ] **Step 7: Commit**

```bash
git add app/app/Jobs/Billing/BalancePreflightSweepJob.php app/app/Console/Commands/BillingPreflightSweepCommand.php app/routes/console.php app/tests/Feature/Billing/BalancePreflightSweepJobTest.php
git commit -m "feat(billing-v2-c): BalancePreflightSweepJob + cron 18:00 MSK"
```

---

### Task 1.5: Mailable — 4 письма заморозки/разморозки

**Files:**
- Create: `app/app/Mail/BalanceFrozenMail.php` + `app/app/Mail/BalanceFrozenReminderMail.php` + `app/app/Mail/BalanceFrozenFinalMail.php` + `app/app/Mail/BalanceUnfrozenMail.php`
- Create: 4 blade-шаблона в `app/resources/views/emails/billing/`
- Test: `app/tests/Feature/Billing/BalanceFreezeMailTest.php`

- [ ] **Step 1: Написать failing-тест**

```php
<?php

declare(strict_types=1);

use App\Mail\BalanceFrozenMail;
use App\Models\Tenant;
use App\Services\Billing\PreflightResult;

it('renders frozen mail with deficit', function () {
    $tenant = Tenant::factory()->create(['name' => 'ООО Альфа']);
    $result = new PreflightResult(false, 30, 20, 10);

    $mail = new BalanceFrozenMail($tenant, $result);
    $rendered = $mail->render();

    expect($rendered)->toContain('приостановлен');
    expect($rendered)->toContain('10'); // дефицит лидов
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && php artisan test --filter=BalanceFreezeMailTest`
Expected: FAIL — класс не найден.

- [ ] **Step 3: Реализовать BalanceFrozenMail** (остальные 3 — по образцу, см. ниже)

```php
<?php

declare(strict_types=1);

namespace App\Mail;

use App\Models\Tenant;
use App\Services\Billing\PreflightResult;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

final class BalanceFrozenMail extends Mailable
{
    use Queueable, SerializesModels;

    public function __construct(
        public readonly Tenant $tenant,
        public readonly PreflightResult $result,
    ) {}

    public function envelope(): Envelope
    {
        return new Envelope(subject: 'Приём лидов приостановлен — недостаточно баланса');
    }

    public function content(): Content
    {
        return new Content(markdown: 'emails.billing.frozen', with: [
            'tenantName' => $this->tenant->name,
            'deficitLeads' => $this->result->deficitLeads,
            'capacityLeads' => $this->result->capacityLeads,
            'requiredLeads' => $this->result->requiredLeads,
        ]);
    }
}
```

- [ ] **Step 4: Реализовать 3 остальных Mailable** по тому же образцу:
  - `BalanceFrozenReminderMail` — subject «Приём лидов всё ещё приостановлен», шаблон `emails.billing.frozen-reminder`.
  - `BalanceFrozenFinalMail` — subject «Приём приостановлен 3 дня», шаблон `emails.billing.frozen-final`.
  - `BalanceUnfrozenMail` — subject «Приём лидов возобновлён», шаблон `emails.billing.unfrozen` (принимает `Tenant` + `PreflightResult`).

- [ ] **Step 5: Создать 4 markdown-шаблона** в `app/resources/views/emails/billing/`. Пример `frozen.blade.php`:

```blade
<x-mail::message>
# Приём лидов приостановлен

Здравствуйте, {{ $tenantName }}.

На вашем счёте не хватает баланса на дневной заказ лидов.

- Доступно по балансу: **{{ $capacityLeads }}** лидов
- Заказано проектами: **{{ $requiredLeads }}** лидов
- Не хватает: **{{ $deficitLeads }}** лидов

Чтобы возобновить приём — пополните баланс или сократите лимиты проектов до 18:00 МСК.

<x-mail::button :url="config('app.url').'/billing'">
Перейти в кабинет
</x-mail::button>

С уважением, Лидерра.
</x-mail::message>
```

- [ ] **Step 6: Запустить — убедиться, что проходит**

Run: `cd app && php artisan test --filter=BalanceFreezeMailTest`
Expected: PASS.

- [ ] **Step 7: Commit**

```bash
git add app/app/Mail/Balance*.php app/resources/views/emails/billing/ app/tests/Feature/Billing/BalanceFreezeMailTest.php
git commit -m "feat(billing-v2-c): 4 Mailable заморозки/разморозки баланса"
```

---

### Task 1.6: Повторные письма — reminder через 1 день + final через 3 дня

**Files:**
- Create: `app/app/Jobs/Billing/BalanceFrozenReminderJob.php`
- Modify: `app/routes/console.php` (Schedule daily + heartbeat)
- Test: `app/tests/Feature/Billing/BalanceFrozenReminderJobTest.php`

- [ ] **Step 1: Написать failing-тест**

```php
<?php

declare(strict_types=1);

use App\Jobs\Billing\BalanceFrozenReminderJob;
use App\Mail\BalanceFrozenFinalMail;
use App\Mail\BalanceFrozenReminderMail;
use App\Models\Tenant;
use Illuminate\Support\Facades\Mail;

it('sends reminder ~1 day after freeze', function () {
    Mail::fake();
    Tenant::factory()->create(['frozen_by_balance_at' => now()->subHours(25)]);

    (new BalanceFrozenReminderJob())->handle();

    Mail::assertQueued(BalanceFrozenReminderMail::class);
});

it('sends final ~3 days after freeze', function () {
    Mail::fake();
    Tenant::factory()->create(['frozen_by_balance_at' => now()->subHours(73)]);

    (new BalanceFrozenReminderJob())->handle();

    Mail::assertQueued(BalanceFrozenFinalMail::class);
});

it('sends nothing for freshly frozen tenant', function () {
    Mail::fake();
    Tenant::factory()->create(['frozen_by_balance_at' => now()->subHours(2)]);

    (new BalanceFrozenReminderJob())->handle();

    Mail::assertNotQueued(BalanceFrozenReminderMail::class);
    Mail::assertNotQueued(BalanceFrozenFinalMail::class);
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && php artisan test --filter=BalanceFrozenReminderJobTest`
Expected: FAIL.

- [ ] **Step 3: Реализовать Job** (throttle через колонки-маркеры или `mail_log`; для простоты — окно по `frozen_by_balance_at` + проверка ещё-не-слано через `balance_freeze_log` details)

```php
<?php

declare(strict_types=1);

namespace App\Jobs\Billing;

use App\Mail\BalanceFrozenFinalMail;
use App\Mail\BalanceFrozenReminderMail;
use App\Models\Tenant;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;

/**
 * Повторные письма заморозки: reminder в окне 24-48ч, final в окне 72-96ч.
 * Throttle через balance_freeze_log (event_type 'reminder_sent' / 'final_sent').
 * Запускается раз в сутки (routes/console.php). Спек §3.7.
 */
final class BalanceFrozenReminderJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public function handle(): void
    {
        Tenant::query()
            ->whereNotNull('frozen_by_balance_at')
            ->chunkById(200, function ($tenants): void {
                foreach ($tenants as $tenant) {
                    $hours = now()->diffInHours($tenant->frozen_by_balance_at);

                    if ($hours >= 24 && $hours < 48 && ! $this->alreadySent($tenant->id, 'reminder_sent')) {
                        Mail::to($tenant->email)->queue(new BalanceFrozenReminderMail($tenant));
                        $this->mark($tenant->id, 'reminder_sent');
                    } elseif ($hours >= 72 && $hours < 96 && ! $this->alreadySent($tenant->id, 'final_sent')) {
                        Mail::to($tenant->email)->queue(new BalanceFrozenFinalMail($tenant));
                        $this->mark($tenant->id, 'final_sent');
                    }
                }
            });
    }

    private function alreadySent(int $tenantId, string $marker): bool
    {
        return DB::connection('pgsql_supplier')->table('balance_freeze_log')
            ->where('tenant_id', $tenantId)
            ->where('event_type', $marker)
            ->where('created_at', '>=', now()->subDays(5))
            ->exists();
    }

    private function mark(int $tenantId, string $marker): void
    {
        DB::connection('pgsql_supplier')->table('balance_freeze_log')->insert([
            'tenant_id' => $tenantId,
            'event_type' => $marker,
            'triggered_by' => 'reminder_cron',
            'balance_rub_at_event' => 0,
            'required_leads' => 0,
            'capacity_leads' => 0,
            'total_daily_limit' => 0,
            'details' => null,
            'created_at' => now(),
        ]);
    }
}
```

**NB:** `BalanceFrozenReminderMail`/`BalanceFrozenFinalMail` конструкторы из Task 1.5 принимают только `Tenant` (без PreflightResult) — согласовать сигнатуру. Если в Task 1.5 они приняли `PreflightResult` — здесь пересчитать или упростить конструктор до `Tenant`. **Решение: эти два письма принимают только `Tenant`** (reminder/final не несут свежий дефицит).

- [ ] **Step 4: Зарегистрировать в Schedule** (daily @ 18:30 MSK, после основного sweep + heartbeat-обёртка по образцу Task 1.4 Step 5).

- [ ] **Step 5: Запустить — убедиться, что проходит**

Run: `cd app && php artisan test --filter=BalanceFrozenReminderJobTest`
Expected: PASS (3 теста).

- [ ] **Step 6: Commit**

```bash
git add app/app/Jobs/Billing/BalanceFrozenReminderJob.php app/routes/console.php app/tests/Feature/Billing/BalanceFrozenReminderJobTest.php
git commit -m "feat(billing-v2-c): повторные письма заморозки (reminder +1д, final +3д)"
```

---

### Task 1.7: `ProjectController` — преfflight при создании/правке проекта

**Files:**
- Modify: `app/app/Http/Controllers/Api/ProjectController.php` (store + update — 409 при перегрузке)
- Test: `app/tests/Feature/Project/ProjectPreflightTest.php`

- [ ] **Step 1: Написать failing-тест**

```php
<?php

declare(strict_types=1);

use App\Models\PricingTier;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;

beforeEach(function () {
    PricingTier::factory()->create(['tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 5000, 'is_active' => true]);
});

it('returns 409 when new project overloads balance', function () {
    $tenant = Tenant::factory()->create(['balance_rub' => '1000.00']); // = 20 лидов
    $user = User::factory()->for($tenant)->create();

    $resp = $this->actingAs($user)->postJson('/api/projects', [
        'name' => 'Тест',
        'daily_limit' => 30, // > 20 capacity
        // ... прочие обязательные поля проекта
    ]);

    $resp->assertStatus(409);
    $resp->assertJsonPath('error', 'balance_insufficient');
    $resp->assertJsonPath('deficit_leads', 10);
});

it('creates blocked project when force_save_blocked=true', function () {
    $tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
    $user = User::factory()->for($tenant)->create();

    $resp = $this->actingAs($user)->postJson('/api/projects', [
        'name' => 'Тест',
        'daily_limit' => 30,
        'force_save_blocked' => true,
    ]);

    $resp->assertStatus(201);
    expect(Project::first()->preflight_blocked_at)->not->toBeNull();
});

it('creates normally when within balance', function () {
    $tenant = Tenant::factory()->create(['balance_rub' => '2000.00']); // = 40 лидов
    $user = User::factory()->for($tenant)->create();

    $resp = $this->actingAs($user)->postJson('/api/projects', [
        'name' => 'Тест',
        'daily_limit' => 30,
    ]);

    $resp->assertStatus(201);
    expect(Project::first()->preflight_blocked_at)->toBeNull();
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && php artisan test --filter=ProjectPreflightTest`
Expected: FAIL — 409 не возвращается (создаёт нормально).

- [ ] **Step 3: Реализовать преfflight-проверку в `ProjectController::store`** (и аналогично `update`)

Вставить ПОСЛЕ валидации, ДО создания проекта:

```php
// Спек C §3.4: преfflight при создании — если сумма лимитов перевешивает баланс.
$tenant = $request->user()->tenant;
$tiers = \App\Models\PricingTier::query()->where('is_active', true)->get();
$service = new \App\Services\Billing\BalancePreflightService();

$existingLimit = (int) $tenant->projects()->where('status', 'active')->sum('daily_limit');
$wouldBeRequired = $existingLimit + (int) $validated['daily_limit'];

$result = $service->evaluate(
    balanceRub: (string) $tenant->balance_rub,
    deliveredInMonth: (int) $tenant->delivered_in_month,
    requiredLeads: $wouldBeRequired,
    tiers: $tiers,
);

$forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false);

if (! $result->passes && ! $forceSaveBlocked) {
    return response()->json([
        'error' => 'balance_insufficient',
        'current_balance_rub' => (string) $tenant->balance_rub,
        'current_capacity_leads' => $result->capacityLeads,
        'would_be_required_leads' => $wouldBeRequired,
        'deficit_leads' => $result->deficitLeads,
    ], 409);
}

// в массив создания проекта:
// 'preflight_blocked_at' => (! $result->passes && $forceSaveBlocked) ? now() : null,
```

Добавить `force_save_blocked` в валидацию как `nullable|boolean`.

- [ ] **Step 4: Запустить — убедиться, что проходит**

Run: `cd app && php artisan test --filter=ProjectPreflightTest`
Expected: PASS (3 теста).

- [ ] **Step 5: Commit**

```bash
git add app/app/Http/Controllers/Api/ProjectController.php app/tests/Feature/Project/ProjectPreflightTest.php
git commit -m "feat(billing-v2-c): ProjectController преfflight — 409 при перегрузке баланса"
```

---

### Task 1.8: Фильтр frozen-проектов в `SyncSupplierProjectsJob`

**Files:**
- Modify: `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` (исключить frozen перед allocator)
- Test: `app/tests/Feature/Supplier/SyncSupplierPreflightFilterTest.php`

- [ ] **Step 1: Написать failing-тест**

```php
<?php

declare(strict_types=1);

use App\Models\Project;
use App\Models\Tenant;

it('excludes projects of frozen tenants from supplier order', function () {
    // Тенант заморожен — его проекты не должны попасть в eligible-набор allocator'а.
    $frozenTenant = Tenant::factory()->create(['frozen_by_balance_at' => now()]);
    Project::factory()->for($frozenTenant)->create(['status' => 'active', 'daily_limit' => 50]);

    $activeTenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
    Project::factory()->for($activeTenant)->create(['status' => 'active', 'daily_limit' => 30]);

    // Вызвать метод сбора eligible-проектов и проверить, что frozen исключён.
    // (точная точка интеграции зависит от структуры Job — см. реализацию)
    $eligible = app(\App\Jobs\Supplier\SyncSupplierProjectsJob::class)->collectEligibleProjects(now());

    $tenantIds = $eligible->pluck('tenant_id')->unique()->all();
    expect($tenantIds)->not->toContain($frozenTenant->id);
    expect($tenantIds)->toContain($activeTenant->id);
});

it('excludes individually preflight-blocked projects', function () {
    $tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
    Project::factory()->for($tenant)->create(['status' => 'active', 'daily_limit' => 30, 'preflight_blocked_at' => null]);
    $blocked = Project::factory()->for($tenant)->create(['status' => 'active', 'daily_limit' => 20, 'preflight_blocked_at' => now()]);

    $eligible = app(\App\Jobs\Supplier\SyncSupplierProjectsJob::class)->collectEligibleProjects(now());

    expect($eligible->pluck('id')->all())->not->toContain($blocked->id);
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && php artisan test --filter=SyncSupplierPreflightFilterTest`
Expected: FAIL — frozen-проекты попадают в набор.

- [ ] **Step 3: Реализовать фильтр**

Найти в `SyncSupplierProjectsJob` место, где собираются eligible-проекты (метод `collectEligibleProjects` или inline-запрос). Добавить два условия:

```php
->whereNull('preflight_blocked_at')           // точечная перегрузка проекта
->whereHas('tenant', function ($q) {           // пассивная заморозка тенанта
    $q->whereNull('frozen_by_balance_at');
})
```

Если в Job нет выделенного метода `collectEligibleProjects` — выделить его рефакторингом (для тестируемости) и вызвать из `handle()`.

- [ ] **Step 4: Запустить — убедиться, что проходит**

Run: `cd app && php artisan test --filter=SyncSupplierPreflightFilterTest`
Expected: PASS (2 теста).

- [ ] **Step 5: Прогнать существующие supplier-тесты на регресс**

Run: `cd app && php artisan test --filter=SyncSupplier`
Expected: всё GREEN — формула не изменилась, только вход отфильтрован.

- [ ] **Step 6: Commit**

```bash
git add app/app/Jobs/Supplier/SyncSupplierProjectsJob.php app/tests/Feature/Supplier/SyncSupplierPreflightFilterTest.php
git commit -m "feat(billing-v2-c): SyncSupplierProjectsJob исключает frozen-проекты из заказа"
```

---

### Task 1.9: One-time команда `billing:preflight-initial-sweep`

**Files:**
- Create: `app/app/Console/Commands/BillingPreflightInitialSweepCommand.php`
- Test: `app/tests/Feature/Billing/BillingPreflightInitialSweepTest.php`

- [ ] **Step 1: Написать failing-тест**

```php
<?php

declare(strict_types=1);

use App\Models\PricingTier;
use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Support\Facades\Mail;

it('freezes pre-existing underfunded tenants on first run', function () {
    Mail::fake();
    PricingTier::factory()->create(['tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 5000, 'is_active' => true]);

    $tenant = Tenant::factory()->create(['balance_rub' => '0.00', 'frozen_by_balance_at' => null]);
    Project::factory()->for($tenant)->create(['status' => 'active', 'daily_limit' => 25]);

    $this->artisan('billing:preflight-initial-sweep')->assertSuccessful();

    expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull();
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && php artisan test --filter=BillingPreflightInitialSweepTest`
Expected: FAIL — команда не существует.

- [ ] **Step 3: Реализовать команду** (переиспользует `BalancePreflightSweepJob`, отличается стартовым письмом-пояснением — для MVP можно вызвать тот же sweep)

```php
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Jobs\Billing\BalancePreflightSweepJob;
use Illuminate\Console\Command;

/**
 * One-time: при выкатке преfflight прогнать всех тенантов и заморозить
 * недофинансированных. Запускается ОДИН раз вручную после миграции. Спек §3.9.
 */
final class BillingPreflightInitialSweepCommand extends Command
{
    protected $signature = 'billing:preflight-initial-sweep';

    protected $description = 'Разовый преfflight при внедрении — заморозить недофинансированных тенантов';

    public function handle(): int
    {
        $this->warn('Разовый преfflight всех тенантов. Запускать ОДИН раз после выкатки.');
        (new BalancePreflightSweepJob())->handle();
        $this->info('Initial sweep завершён.');

        return self::SUCCESS;
    }
}
```

- [ ] **Step 4: Запустить — убедиться, что проходит**

Run: `cd app && php artisan test --filter=BillingPreflightInitialSweepTest`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/app/Console/Commands/BillingPreflightInitialSweepCommand.php app/tests/Feature/Billing/BillingPreflightInitialSweepTest.php
git commit -m "feat(billing-v2-c): one-time billing:preflight-initial-sweep"
```

---

### Task 1.10: Frontend — баннер заморозки + индикатор ёмкости + диалог перегрузки

**Files:**
- Create: `app/resources/js/components/billing/BalanceFrozenBanner.vue`
- Create: `app/resources/js/components/billing/BalanceCapacityIndicator.vue`
- Create: `app/resources/js/components/projects/ProjectLimitOverloadDialog.vue`
- Modify: `app/resources/js/layouts/AppLayout.vue` (вставить баннер)
- Modify: проектную форму (где создаётся/правится проект) — перехват 409
- Test: `app/resources/js/components/billing/__tests__/BalanceCapacityIndicator.spec.ts` + `ProjectLimitOverloadDialog.spec.ts`

- [ ] **Step 1: Написать failing Vitest на индикатор**

```ts
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import BalanceCapacityIndicator from '../BalanceCapacityIndicator.vue'

describe('BalanceCapacityIndicator', () => {
  it('shows green when capacity exceeds required by 3+ days', () => {
    const w = mount(BalanceCapacityIndicator, {
      props: { balanceRub: '3000.00', capacityLeads: 90, requiredLeadsPerDay: 25 },
    })
    expect(w.text()).toContain('90')
    expect(w.classes()).toContain('capacity-ok')
  })

  it('shows red when capacity below required', () => {
    const w = mount(BalanceCapacityIndicator, {
      props: { balanceRub: '500.00', capacityLeads: 10, requiredLeadsPerDay: 25 },
    })
    expect(w.classes()).toContain('capacity-insufficient')
  })
})
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && npm run test:vue -- BalanceCapacityIndicator`
Expected: FAIL — компонент не существует.

- [ ] **Step 3: Реализовать `BalanceCapacityIndicator.vue`**

```vue
<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps<{
  balanceRub: string
  capacityLeads: number
  requiredLeadsPerDay: number
}>()

const daysLeft = computed(() =>
  props.requiredLeadsPerDay > 0 ? props.capacityLeads / props.requiredLeadsPerDay : Infinity,
)

const statusClass = computed(() => {
  if (props.capacityLeads < props.requiredLeadsPerDay) return 'capacity-insufficient'
  if (daysLeft.value < 3) return 'capacity-warning'
  return 'capacity-ok'
})
</script>

<template>
  <div class="balance-capacity" :class="statusClass">
    <span>Баланс: {{ balanceRub }}₽ = до {{ capacityLeads }} лидов по тарифу</span>
    <span>Проекты заказывают: {{ requiredLeadsPerDay }} лидов в день</span>
    <span v-if="statusClass === 'capacity-insufficient'">⚠️ Не хватает — пополните счёт</span>
    <span v-else-if="statusClass === 'capacity-warning'">Хватит на ~{{ daysLeft.toFixed(1) }} дн. — скоро потребуется пополнение</span>
    <span v-else>✅ Хватит на ~{{ daysLeft.toFixed(1) }} дн.</span>
  </div>
</template>

<style scoped>
.capacity-ok { color: rgb(var(--v-theme-success)); }
.capacity-warning { color: rgb(var(--v-theme-warning)); }
.capacity-insufficient { color: rgb(var(--v-theme-error)); }
</style>
```

- [ ] **Step 4: Реализовать `BalanceFrozenBanner.vue`** (красный баннер, показывается когда `tenant.frozen_by_balance_at !== null`, читает из tenantStore; две кнопки «Пополнить счёт» / «Перейти к проектам»).

- [ ] **Step 5: Реализовать `ProjectLimitOverloadDialog.vue`** (модалка, принимает 409-payload, 3 кнопки: «Сохранить и приостановить» → emit с `force_save_blocked=true`; «Поставить лимит 0» → emit с `daily_limit=0`; «Отмена» → close).

- [ ] **Step 6: Написать Vitest на диалог** (рендер payload, эмит правильных событий по кнопкам).

- [ ] **Step 7: Встроить баннер в `AppLayout.vue`** + перехват 409 в проектной форме (открыть диалог).

- [ ] **Step 8: Запустить Vitest**

Run: `cd app && npm run test:vue -- BalanceCapacityIndicator ProjectLimitOverloadDialog`
Expected: PASS.

- [ ] **Step 9: Commit**

```bash
git add app/resources/js/components/billing/ app/resources/js/components/projects/ProjectLimitOverloadDialog.vue app/resources/js/layouts/AppLayout.vue
git commit -m "feat(billing-v2-c): UI преfflight — баннер, индикатор ёмкости, диалог перегрузки"
```

---

## Phase 2 — Безнал (PDF-счёт) + админка пополнений

### Task 2.1: Миграция БД — реквизиты тенанта + topup_sessions

**Files:**
- Create: `app/database/migrations/2026_05_24_110000_add_legal_entity_and_topup_sessions.php`
- Create: `db/migrations/2026_05_24_topup_sessions.sql` (prod-синк)
- Modify: `db/schema.sql` + `db/CHANGELOG_schema.md`

- [ ] **Step 1: Написать миграцию**

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
    public function up(): void
    {
        // Реквизиты юр.лица тенанта (для счёта-фактуры).
        DB::statement("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS legal_entity_name VARCHAR(255)");
        DB::statement("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS legal_entity_inn VARCHAR(12)");
        DB::statement("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS legal_entity_kpp VARCHAR(9)");
        DB::statement("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS legal_entity_address TEXT");
        DB::statement("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS legal_entity_form VARCHAR(20)");

        // Журнал платёжных сессий — создаём через pgsql_supplier (prod-роли).
        $supplier = DB::connection('pgsql_supplier');
        $supplier->statement(<<<'SQL'
            CREATE TABLE IF NOT EXISTS 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,
                amount_rub DECIMAL(12,2) NOT NULL,
                provider_ref VARCHAR(100),
                status VARCHAR(20) NOT NULL DEFAULT 'pending',
                matched_at TIMESTAMP,
                confirmed_at TIMESTAMP,
                confirmed_by BIGINT REFERENCES users(id),
                failed_reason VARCHAR(500),
                metadata JSONB,
                created_at TIMESTAMP NOT NULL DEFAULT now(),
                updated_at TIMESTAMP NOT NULL DEFAULT now()
            )
        SQL);
        $supplier->statement('ALTER TABLE topup_sessions ENABLE ROW LEVEL SECURITY');
        $supplier->statement(<<<'SQL'
            CREATE POLICY tenant_isolation ON topup_sessions
            USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint)
        SQL);
        $supplier->statement('CREATE UNIQUE INDEX IF NOT EXISTS topup_sessions_provider_ref_uidx ON topup_sessions (provider_ref) WHERE provider_ref IS NOT NULL');
        $supplier->statement('CREATE INDEX IF NOT EXISTS topup_sessions_status_idx ON topup_sessions (status, created_at DESC)');

        foreach (['crm_app_user', 'crm_supplier_worker', 'crm_migrator', 'crm_app_admin'] as $role) {
            $supplier->statement(<<<SQL
                DO \$\$
                BEGIN
                    IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{$role}') THEN
                        GRANT SELECT, INSERT, UPDATE ON topup_sessions TO {$role};
                        GRANT USAGE, SELECT ON SEQUENCE topup_sessions_id_seq TO {$role};
                    END IF;
                END
                \$\$
            SQL);
        }
    }

    public function down(): void
    {
        DB::connection('pgsql_supplier')->statement('DROP TABLE IF EXISTS topup_sessions');
        foreach (['legal_entity_name', 'legal_entity_inn', 'legal_entity_kpp', 'legal_entity_address', 'legal_entity_form'] as $col) {
            DB::statement("ALTER TABLE tenants DROP COLUMN IF EXISTS {$col}");
        }
    }
};
```

- [ ] **Step 2: Прогнать миграцию**

Run: `cd app && php artisan migrate --env=testing`
Expected: DONE без ошибок прав.

- [ ] **Step 3: Синхронизировать `db/schema.sql` + CHANGELOG** (v8.35 → v8.36).

- [ ] **Step 4: Commit**

```bash
git add app/database/migrations/2026_05_24_110000_*.php db/migrations/2026_05_24_topup_sessions.sql db/schema.sql db/CHANGELOG_schema.md
git commit -m "feat(billing-v2-c): миграция — реквизиты юр.лица + topup_sessions"
```

---

### Task 2.2: Установить PDF-генератор

**Files:**
- Modify: `app/composer.json` (+ `barryvdh/laravel-dompdf`)

- [ ] **Step 1: Установить пакет**

Run: `cd app && composer require barryvdh/laravel-dompdf`
Expected: установлен `barryvdh/laravel-dompdf` (DomPDF-обёртка для Laravel). Auto-discovery подхватит провайдер.

- [ ] **Step 2: Smoke — фасад доступен**

Run: `cd app && php artisan tinker --execute="echo class_exists(\Barryvdh\DomPDF\Facade\Pdf::class) ? 'OK' : 'MISSING';"`
Expected: `OK`.

- [ ] **Step 3: Commit**

```bash
git add app/composer.json app/composer.lock
git commit -m "chore(billing-v2-c): + barryvdh/laravel-dompdf для счетов"
```

---

### Task 2.3: Модель TopupSession + `TopupGatewayInterface` + `BankTransferGateway`

**Files:**
- Create: `app/app/Models/TopupSession.php`
- Create: `app/app/Services/Billing/Gateway/TopupGatewayInterface.php`
- Create: `app/app/Services/Billing/Gateway/TopupSessionResult.php`
- Create: `app/app/Services/Billing/Gateway/BankTransferGateway.php`
- Test: `app/tests/Feature/Billing/BankTransferGatewayTest.php`

- [ ] **Step 1: Написать failing-тест**

```php
<?php

declare(strict_types=1);

use App\Models\Tenant;
use App\Models\TopupSession;
use App\Models\User;
use App\Services\Billing\Gateway\BankTransferGateway;

it('creates pending session with unique invoice number', function () {
    $tenant = Tenant::factory()->create(['legal_entity_name' => 'ООО Альфа', 'legal_entity_inn' => '7700000000']);
    $user = User::factory()->for($tenant)->create();

    $gateway = new BankTransferGateway();
    $result = $gateway->createSession($tenant->id, '100000.00', $user->id);

    $session = TopupSession::find($result->sessionId);
    expect($session->status)->toBe('pending');
    expect($session->method)->toBe('bank_transfer');
    expect($session->provider_ref)->toStartWith('SCH-');
    expect($result->invoiceUrl)->toContain((string) $result->sessionId);
});

it('rejects bank transfer when legal entity requisites are missing', function () {
    $tenant = Tenant::factory()->create(['legal_entity_name' => null]);
    $user = User::factory()->for($tenant)->create();

    $gateway = new BankTransferGateway();

    expect(fn () => $gateway->createSession($tenant->id, '100000.00', $user->id))
        ->toThrow(\App\Services\Billing\Gateway\MissingRequisitesException::class);
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && php artisan test --filter=BankTransferGatewayTest`
Expected: FAIL — классы не найдены.

- [ ] **Step 3: Реализовать модель TopupSession**

```php
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

final class TopupSession extends Model
{
    public const STATUS_PENDING = 'pending';
    public const STATUS_MATCHED = 'matched';
    public const STATUS_CONFIRMED = 'confirmed';
    public const STATUS_FAILED = 'failed';
    public const STATUS_EXPIRED = 'expired';

    public const METHOD_BANK_TRANSFER = 'bank_transfer';
    public const METHOD_SBP = 'sbp';
    public const METHOD_CARD = 'card';

    protected $fillable = [
        'tenant_id', 'user_id', 'method', 'amount_rub', 'provider_ref',
        'status', 'matched_at', 'confirmed_at', 'confirmed_by', 'failed_reason', 'metadata',
    ];

    protected $casts = [
        'amount_rub' => 'decimal:2',
        'matched_at' => 'datetime',
        'confirmed_at' => 'datetime',
        'metadata' => 'array',
    ];

    public function tenant(): BelongsTo
    {
        return $this->belongsTo(Tenant::class);
    }
}
```

- [ ] **Step 4: Реализовать интерфейс + DTO + исключение**

```php
<?php
// TopupSessionResult.php
declare(strict_types=1);

namespace App\Services\Billing\Gateway;

final class TopupSessionResult
{
    public function __construct(
        public readonly int $sessionId,
        public readonly string $status,
        public readonly ?string $invoiceUrl = null,
        public readonly ?string $qrCodeData = null,
        public readonly ?string $redirectUrl = null,
    ) {}
}
```

```php
<?php
// TopupGatewayInterface.php
declare(strict_types=1);

namespace App\Services\Billing\Gateway;

interface TopupGatewayInterface
{
    public function createSession(int $tenantId, string $amountRub, int $userId): TopupSessionResult;
}
```

```php
<?php
// MissingRequisitesException.php
declare(strict_types=1);

namespace App\Services\Billing\Gateway;

final class MissingRequisitesException extends \RuntimeException {}
```

- [ ] **Step 5: Реализовать BankTransferGateway**

```php
<?php

declare(strict_types=1);

namespace App\Services\Billing\Gateway;

use App\Models\Tenant;
use App\Models\TopupSession;

/**
 * Безнал — генерация платёжной сессии под счёт PDF. Спек §4.3.
 * Номер счёта SCH-{YYYY}-{tenant_id}-{seq} уникален через (tenant_id, seq).
 */
final class BankTransferGateway implements TopupGatewayInterface
{
    public function createSession(int $tenantId, string $amountRub, int $userId): TopupSessionResult
    {
        $tenant = Tenant::findOrFail($tenantId);

        if (empty($tenant->legal_entity_name) || empty($tenant->legal_entity_inn)) {
            throw new MissingRequisitesException(
                'Заполните реквизиты юр.лица перед формированием счёта.'
            );
        }

        $seq = TopupSession::where('tenant_id', $tenantId)
            ->where('method', TopupSession::METHOD_BANK_TRANSFER)
            ->count() + 1;
        $providerRef = sprintf('SCH-%s-%d-%03d', now()->year, $tenantId, $seq);

        $session = TopupSession::create([
            'tenant_id' => $tenantId,
            'user_id' => $userId,
            'method' => TopupSession::METHOD_BANK_TRANSFER,
            'amount_rub' => $amountRub,
            'provider_ref' => $providerRef,
            'status' => TopupSession::STATUS_PENDING,
        ]);

        return new TopupSessionResult(
            sessionId: $session->id,
            status: $session->status,
            invoiceUrl: "/api/billing/topup-sessions/{$session->id}/invoice.pdf",
        );
    }
}
```

- [ ] **Step 6: Запустить — убедиться, что проходит**

Run: `cd app && php artisan test --filter=BankTransferGatewayTest`
Expected: PASS (2 теста).

- [ ] **Step 7: Commit**

```bash
git add app/app/Models/TopupSession.php app/app/Services/Billing/Gateway/ app/tests/Feature/Billing/BankTransferGatewayTest.php
git commit -m "feat(billing-v2-c): TopupSession + TopupGatewayInterface + BankTransferGateway"
```

---

### Task 2.4: PDF-счёт (Blade-шаблон + endpoint)

**Files:**
- Create: `app/resources/views/pdf/invoice.blade.php`
- Modify: `app/app/Http/Controllers/Api/BillingController.php` (+ `invoicePdf`)
- Modify: `app/routes/web.php` (route)
- Test: `app/tests/Feature/Billing/InvoicePdfTest.php`

- [ ] **Step 1: Написать failing-тест**

```php
<?php

declare(strict_types=1);

use App\Models\Tenant;
use App\Models\TopupSession;
use App\Models\User;

it('returns PDF invoice for own session', function () {
    $tenant = Tenant::factory()->create([
        'legal_entity_name' => 'ООО Альфа',
        'legal_entity_inn' => '7700000000',
    ]);
    $user = User::factory()->for($tenant)->create();
    $session = TopupSession::create([
        'tenant_id' => $tenant->id, 'user_id' => $user->id,
        'method' => 'bank_transfer', 'amount_rub' => '100000.00',
        'provider_ref' => 'SCH-2026-1-001', 'status' => 'pending',
    ]);

    $resp = $this->actingAs($user)->get("/api/billing/topup-sessions/{$session->id}/invoice.pdf");

    $resp->assertOk();
    expect($resp->headers->get('content-type'))->toContain('application/pdf');
});

it('forbids access to another tenant session', function () {
    $tenantA = Tenant::factory()->create(['legal_entity_name' => 'A', 'legal_entity_inn' => '1']);
    $tenantB = Tenant::factory()->create();
    $userB = User::factory()->for($tenantB)->create();
    $session = TopupSession::create([
        'tenant_id' => $tenantA->id, 'method' => 'bank_transfer',
        'amount_rub' => '100.00', 'provider_ref' => 'SCH-2026-1-001', 'status' => 'pending',
    ]);

    $this->actingAs($userB)->get("/api/billing/topup-sessions/{$session->id}/invoice.pdf")
        ->assertForbidden();
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && php artisan test --filter=InvoicePdfTest`
Expected: FAIL — route 404.

- [ ] **Step 3: Создать Blade-шаблон `invoice.blade.php`** (шапка реквизитов Лидерры из config, реквизиты плательщика из tenant, назначение платежа с номером счёта, сумма, срок 5 дней). Минимальный валидный HTML для DomPDF.

- [ ] **Step 4: Реализовать `BillingController::invoicePdf`**

```php
public function invoicePdf(int $sessionId)
{
    $session = \App\Models\TopupSession::findOrFail($sessionId);

    abort_unless($session->tenant_id === auth()->user()->tenant_id, 403);

    $tenant = $session->tenant;
    $pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('pdf.invoice', [
        'session' => $session,
        'tenant' => $tenant,
        'liderra' => config('billing.requisites'),
    ]);

    return $pdf->download("invoice-{$session->provider_ref}.pdf");
}
```

Добавить route в `web.php` под auth:sanctum: `Route::get('/api/billing/topup-sessions/{id}/invoice.pdf', [BillingController::class, 'invoicePdf'])`. Добавить `config/billing.php` с `requisites` (название ООО, ИНН, КПП, р/с — placeholder до Б-1).

- [ ] **Step 5: Запустить — убедиться, что проходит**

Run: `cd app && php artisan test --filter=InvoicePdfTest`
Expected: PASS (2 теста).

- [ ] **Step 6: Commit**

```bash
git add app/resources/views/pdf/invoice.blade.php app/app/Http/Controllers/Api/BillingController.php app/routes/web.php app/config/billing.php app/tests/Feature/Billing/InvoicePdfTest.php
git commit -m "feat(billing-v2-c): PDF-счёт на пополнение + RLS-проверка доступа"
```

---

### Task 2.5: Рефакторинг `BillingTopupService` под gateway + `confirmPayment`

**Files:**
- Modify: `app/app/Services/Billing/BillingTopupService.php`
- Test: `app/tests/Feature/Billing/BillingTopupConfirmTest.php`

- [ ] **Step 1: Написать failing-тест**

```php
<?php

declare(strict_types=1);

use App\Models\BalanceTransaction;
use App\Models\Tenant;
use App\Models\TopupSession;
use App\Models\User;
use App\Services\Billing\BillingTopupService;

it('confirms matched session and credits balance', function () {
    $tenant = Tenant::factory()->create(['balance_rub' => '500.00']);
    $admin = User::factory()->create();
    $session = TopupSession::create([
        'tenant_id' => $tenant->id, 'method' => 'bank_transfer',
        'amount_rub' => '100000.00', 'provider_ref' => 'SCH-2026-1-001', 'status' => 'matched',
    ]);

    $service = new BillingTopupService();
    $service->confirmPayment('SCH-2026-1-001', $admin->id);

    expect($tenant->fresh()->balance_rub)->toBe('100500.00');
    expect($session->fresh()->status)->toBe('confirmed');
    expect($session->fresh()->confirmed_by)->toBe($admin->id);
    expect(BalanceTransaction::where('tenant_id', $tenant->id)->where('type', 'topup')->count())->toBe(1);
});

it('is idempotent — double confirm does not double-credit', function () {
    $tenant = Tenant::factory()->create(['balance_rub' => '0.00']);
    $admin = User::factory()->create();
    TopupSession::create([
        'tenant_id' => $tenant->id, 'method' => 'bank_transfer',
        'amount_rub' => '1000.00', 'provider_ref' => 'SCH-2026-1-002', 'status' => 'matched',
    ]);

    $service = new BillingTopupService();
    $service->confirmPayment('SCH-2026-1-002', $admin->id);
    $service->confirmPayment('SCH-2026-1-002', $admin->id); // повтор

    expect($tenant->fresh()->balance_rub)->toBe('1000.00'); // не задвоилось
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && php artisan test --filter=BillingTopupConfirmTest`
Expected: FAIL — метод `confirmPayment` не существует.

- [ ] **Step 3: Реализовать `confirmPayment`** (lockForUpdate на tenant + session, idempotent guard на `status !== 'confirmed'`, bcadd, BalanceTransaction с `topup_session_id` в metadata)

```php
public function confirmPayment(string $providerRef, ?int $adminUserId): ?BalanceTransaction
{
    return DB::transaction(function () use ($providerRef, $adminUserId) {
        $session = TopupSession::where('provider_ref', $providerRef)->lockForUpdate()->firstOrFail();

        if ($session->status === TopupSession::STATUS_CONFIRMED) {
            return null; // идемпотентность
        }

        /** @var Tenant $tenant */
        $tenant = Tenant::lockForUpdate()->findOrFail($session->tenant_id);
        $newBalance = bcadd((string) $tenant->balance_rub, (string) $session->amount_rub, 2);
        $tenant->balance_rub = $newBalance;
        $tenant->save();

        $session->status = TopupSession::STATUS_CONFIRMED;
        $session->confirmed_at = now();
        $session->confirmed_by = $adminUserId;
        $session->save();

        return BalanceTransaction::create([
            'tenant_id' => $tenant->id,
            'type' => BalanceTransaction::TYPE_TOPUP,
            'amount_rub' => $session->amount_rub,
            'amount_leads' => 0,
            'balance_rub_after' => $newBalance,
            'balance_leads_after' => 0,
            'description' => "Пополнение по счёту {$providerRef}",
            'user_id' => $adminUserId,
            'created_at' => now(),
        ]);
    });
}
```

Сохранить старый метод `topup()` для обратной совместимости (admin adjustBalance его использует) либо переключить на новый flow.

- [ ] **Step 4: Запустить — убедиться, что проходит**

Run: `cd app && php artisan test --filter=BillingTopupConfirmTest`
Expected: PASS (2 теста).

- [ ] **Step 5: Связка с преfflight — после confirm перепроверить заморозку**

Добавить в конце `confirmPayment` (после коммита транзакции, или внутри): если `tenant.frozen_by_balance_at !== null` — прогнать `BalancePreflightService::evaluate`, и если теперь проходит — снять заморозку + `BalanceUnfrozenMail` + лог. Написать доп. тест `it('unfreezes tenant on topup confirm')`.

- [ ] **Step 6: Commit**

```bash
git add app/app/Services/Billing/BillingTopupService.php app/tests/Feature/Billing/BillingTopupConfirmTest.php
git commit -m "feat(billing-v2-c): BillingTopupService::confirmPayment + авто-разморозка"
```

---

### Task 2.6: `VtbStatementSyncJob` (авто-поиск) + dev-симулятор

**Files:**
- Create: `app/app/Jobs/Billing/VtbStatementSyncJob.php`
- Create: `app/app/Console/Commands/VtbStatementSimulateCommand.php` (dev only)
- Create: `app/app/Services/Billing/VtbBusinessClient.php` (интерфейс + null/dev реализация)
- Test: `app/tests/Feature/Billing/VtbStatementSyncTest.php`

- [ ] **Step 1: Написать failing-тест**

```php
<?php

declare(strict_types=1);

use App\Models\Tenant;
use App\Models\TopupSession;

it('matches incoming payment by invoice number in purpose', function () {
    $tenant = Tenant::factory()->create();
    $session = TopupSession::create([
        'tenant_id' => $tenant->id, 'method' => 'bank_transfer',
        'amount_rub' => '100000.00', 'provider_ref' => 'SCH-2026-1-001', 'status' => 'pending',
    ]);

    // Симуляция входящей транзакции через dev-команду
    $this->artisan('billing:vtb-statement-simulate', [
        'session' => $session->id,
        '--amount' => '100000.00',
    ])->assertSuccessful();

    expect($session->fresh()->status)->toBe('matched');
    expect($session->fresh()->matched_at)->not->toBeNull();
});

it('does not match when amount differs', function () {
    $tenant = Tenant::factory()->create();
    $session = TopupSession::create([
        'tenant_id' => $tenant->id, 'method' => 'bank_transfer',
        'amount_rub' => '100000.00', 'provider_ref' => 'SCH-2026-1-002', 'status' => 'pending',
    ]);

    $this->artisan('billing:vtb-statement-simulate', [
        'session' => $session->id,
        '--amount' => '50000.00', // не совпадает
    ])->assertFailed();

    expect($session->fresh()->status)->toBe('pending');
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && php artisan test --filter=VtbStatementSyncTest`
Expected: FAIL — команда не существует.

- [ ] **Step 3: Реализовать `VtbBusinessClient`** — интерфейс `fetchStatement(Carbon $since): array` + `DevVtbBusinessClient` (возвращает пусто; реальная интеграция — после Б-1). Привязать в ServiceProvider по `app()->environment()`.

- [ ] **Step 4: Реализовать матчинг-логику** в `VtbStatementSyncJob::matchTransaction(string $purpose, string $amount)`: парсит `SCH-\d{4}-\d+-\d{3}` из назначения, находит сессию, проверяет совпадение суммы → `status='matched'`, `matched_at=now()`. Если admin-setting `auto_confirm=true` → сразу `BillingTopupService::confirmPayment`.

- [ ] **Step 5: Реализовать dev-команду `billing:vtb-statement-simulate {session} --amount=`** — формирует фейковую транзакцию с `purpose = session.provider_ref` и зовёт тот же матчинг.

- [ ] **Step 6: Зарегистрировать `VtbStatementSyncJob` в Schedule** — каждые 15 минут (`->everyFifteenMinutes()`), но только не-dev (в dev — manual через симулятор). Heartbeat-обёртка.

- [ ] **Step 7: Запустить — убедиться, что проходит**

Run: `cd app && php artisan test --filter=VtbStatementSyncTest`
Expected: PASS (2 теста).

- [ ] **Step 8: Commit**

```bash
git add app/app/Jobs/Billing/VtbStatementSyncJob.php app/app/Console/Commands/VtbStatementSimulateCommand.php app/app/Services/Billing/VtbBusinessClient.php app/routes/console.php app/tests/Feature/Billing/VtbStatementSyncTest.php
git commit -m "feat(billing-v2-c): VtbStatementSyncJob авто-поиск платежа + dev-симулятор"
```

---

### Task 2.7: Админка — журнал ожидающих платежей + подтверждение + настройка режима

**Files:**
- Create: `app/app/Http/Controllers/Api/Admin/PendingTopupsController.php`
- Create: `app/app/Http/Controllers/Api/Admin/BillingSettingsController.php`
- Modify: `app/routes/web.php` (admin routes)
- Test: `app/tests/Feature/Admin/PendingTopupsTest.php`

- [ ] **Step 1: Написать failing-тест**

```php
<?php

declare(strict_types=1);

use App\Models\Tenant;
use App\Models\TopupSession;
use App\Models\User;

it('lists matched and pending sessions for admin', function () {
    $admin = User::factory()->create(['is_saas_admin' => true]);
    $tenant = Tenant::factory()->create();
    TopupSession::create(['tenant_id' => $tenant->id, 'method' => 'bank_transfer', 'amount_rub' => '100.00', 'provider_ref' => 'SCH-1', 'status' => 'matched']);
    TopupSession::create(['tenant_id' => $tenant->id, 'method' => 'bank_transfer', 'amount_rub' => '200.00', 'provider_ref' => 'SCH-2', 'status' => 'confirmed']);

    $resp = $this->actingAs($admin)->getJson('/api/admin/topup-sessions/pending');

    $resp->assertOk();
    $resp->assertJsonCount(1, 'data'); // только matched/pending, не confirmed
});

it('admin confirms a matched payment', function () {
    $admin = User::factory()->create(['is_saas_admin' => true]);
    $tenant = Tenant::factory()->create(['balance_rub' => '0.00']);
    $session = TopupSession::create(['tenant_id' => $tenant->id, 'method' => 'bank_transfer', 'amount_rub' => '5000.00', 'provider_ref' => 'SCH-3', 'status' => 'matched']);

    $this->actingAs($admin)->postJson("/api/admin/topup-sessions/{$session->id}/confirm")->assertOk();

    expect($tenant->fresh()->balance_rub)->toBe('5000.00');
    expect($session->fresh()->status)->toBe('confirmed');
});

it('non-admin cannot access pending list', function () {
    $user = User::factory()->create(['is_saas_admin' => false]);
    $this->actingAs($user)->getJson('/api/admin/topup-sessions/pending')->assertForbidden();
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && php artisan test --filter=PendingTopupsTest`
Expected: FAIL — routes 404.

- [ ] **Step 3: Реализовать `PendingTopupsController`** (`index` — фильтр status in [pending, matched]; `confirm` — зовёт `BillingTopupService::confirmPayment($session->provider_ref, $admin->id)`; `reject` — status=failed). Маршруты под `saas-admin` middleware.

- [ ] **Step 4: Реализовать `BillingSettingsController`** (GET/PUT `auto_confirm` флага в `SystemSetting` модели — ключ `billing.topup_auto_confirm`, default false).

- [ ] **Step 5: Зарегистрировать routes** в `web.php` под `saas-admin` middleware.

- [ ] **Step 6: Запустить — убедиться, что проходит**

Run: `cd app && php artisan test --filter=PendingTopupsTest`
Expected: PASS (3 теста).

- [ ] **Step 7: Commit**

```bash
git add app/app/Http/Controllers/Api/Admin/PendingTopupsController.php app/app/Http/Controllers/Api/Admin/BillingSettingsController.php app/routes/web.php app/tests/Feature/Admin/PendingTopupsTest.php
git commit -m "feat(billing-v2-c): админка ожидающих платежей + подтверждение + режим auto/manual"
```

---

### Task 2.8: `NotifyPendingConfirmationsJob` — часовой алерт админу

**Files:**
- Create: `app/app/Jobs/Billing/NotifyPendingConfirmationsJob.php`
- Create: `app/app/Mail/PendingConfirmationsAdminMail.php` + шаблон
- Modify: `app/routes/console.php` (hourly + heartbeat)
- Test: `app/tests/Feature/Billing/NotifyPendingConfirmationsTest.php`

- [ ] **Step 1: Написать failing-тест**

```php
<?php

declare(strict_types=1);

use App\Jobs\Billing\NotifyPendingConfirmationsJob;
use App\Mail\PendingConfirmationsAdminMail;
use App\Models\SystemSetting;
use App\Models\Tenant;
use App\Models\TopupSession;
use Illuminate\Support\Facades\Mail;

it('alerts admin when matched payments await confirmation in manual mode', function () {
    Mail::fake();
    SystemSetting::set('billing.topup_auto_confirm', false);
    $tenant = Tenant::factory()->create();
    TopupSession::create(['tenant_id' => $tenant->id, 'method' => 'bank_transfer', 'amount_rub' => '100.00', 'provider_ref' => 'SCH-1', 'status' => 'matched']);

    (new NotifyPendingConfirmationsJob())->handle();

    Mail::assertQueued(PendingConfirmationsAdminMail::class);
});

it('sends nothing in auto mode', function () {
    Mail::fake();
    SystemSetting::set('billing.topup_auto_confirm', true);
    $tenant = Tenant::factory()->create();
    TopupSession::create(['tenant_id' => $tenant->id, 'method' => 'bank_transfer', 'amount_rub' => '100.00', 'provider_ref' => 'SCH-2', 'status' => 'matched']);

    (new NotifyPendingConfirmationsJob())->handle();

    Mail::assertNotQueued(PendingConfirmationsAdminMail::class);
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && php artisan test --filter=NotifyPendingConfirmationsTest`
Expected: FAIL.

- [ ] **Step 3: Реализовать Job** (проверяет `SystemSetting::get('billing.topup_auto_confirm')` — если false и есть `matched`-сессии → `PendingConfirmationsAdminMail` на `config('billing.admin_email')`).

- [ ] **Step 4: Реализовать Mailable + шаблон** (count непровер. + ссылка на админку).

- [ ] **Step 5: Зарегистрировать в Schedule** — `->hourly()` + heartbeat.

- [ ] **Step 6: Запустить — убедиться, что проходит**

Run: `cd app && php artisan test --filter=NotifyPendingConfirmationsTest`
Expected: PASS (2 теста).

- [ ] **Step 7: Commit**

```bash
git add app/app/Jobs/Billing/NotifyPendingConfirmationsJob.php app/app/Mail/PendingConfirmationsAdminMail.php app/resources/views/emails/billing/ app/routes/console.php app/tests/Feature/Billing/NotifyPendingConfirmationsTest.php
git commit -m "feat(billing-v2-c): часовой алерт админу о непровер. платежах"
```

---

### Task 2.9: Frontend — пополнение (выбор метода + безнал) + реквизиты + админка

**Files:**
- Create: `app/resources/js/views/billing/TopupView.vue`
- Create: `app/resources/js/components/billing/TopupMethodPicker.vue`
- Create: `app/resources/js/components/billing/BankTransferInvoiceView.vue`
- Create: `app/resources/js/components/settings/LegalEntityForm.vue`
- Create: `app/resources/js/views/admin/PendingPaymentsAdminView.vue`
- Create: `app/resources/js/views/admin/BillingSettingsAdminView.vue`
- Modify: router (новые маршруты `/billing/topup`, `/admin/pending-payments`)
- Test: `app/resources/js/views/billing/__tests__/TopupView.spec.ts` + `LegalEntityForm.spec.ts`

- [ ] **Step 1: Написать failing Vitest на LegalEntityForm** (валидация ИНН 10/12 цифр, КПП 9, форма юрлица).

- [ ] **Step 2: Запустить — FAIL.**

Run: `cd app && npm run test:vue -- LegalEntityForm`

- [ ] **Step 3: Реализовать `LegalEntityForm.vue`** (поля: название, ИНН, КПП, адрес, форма; клиентская валидация ИНН/КПП; PATCH в API настроек тенанта).

- [ ] **Step 4: Реализовать `TopupMethodPicker.vue`** (3 таба: Безнал / СБП / Карта; для каждого — ввод суммы с min/max из config).

- [ ] **Step 5: Реализовать `BankTransferInvoiceView.vue`** (после createSession — кнопка «Скачать счёт PDF» + инструкция «оплати → жди до 1 раб. дня → подтвердим»).

- [ ] **Step 6: Реализовать `TopupView.vue`** (обёртка picker → метод-специфичный компонент).

- [ ] **Step 7: Реализовать `PendingPaymentsAdminView.vue`** (таблица matched/pending + кнопки «Подтвердить»/«Отклонить»).

- [ ] **Step 8: Реализовать `BillingSettingsAdminView.vue`** (переключатель auto/manual).

- [ ] **Step 9: Зарегистрировать маршруты** + вставить `LegalEntityForm` в Настройки клиента.

- [ ] **Step 10: Написать Vitest на TopupView** (рендер picker, переключение методов).

- [ ] **Step 11: Запустить Vitest**

Run: `cd app && npm run test:vue -- TopupView LegalEntityForm`
Expected: PASS.

- [ ] **Step 12: Commit**

```bash
git add app/resources/js/views/billing/ app/resources/js/components/billing/ app/resources/js/components/settings/LegalEntityForm.vue app/resources/js/views/admin/PendingPaymentsAdminView.vue app/resources/js/views/admin/BillingSettingsAdminView.vue app/resources/js/router/
git commit -m "feat(billing-v2-c): UI пополнения (безнал) + реквизиты юр.лица + админка платежей"
```

---

## Phase 3 — СБП и Карты (dev-заглушки) + 54-ФЗ архитектура

### Task 3.1: `SbpGateway` (dev-режим) + фоновое авто-подтверждение

**Files:**
- Create: `app/app/Services/Billing/Gateway/SbpGateway.php`
- Create: `app/app/Jobs/Billing/DevAutoConfirmSessionJob.php`
- Test: `app/tests/Feature/Billing/SbpGatewayTest.php`

- [ ] **Step 1: Написать failing-тест**

```php
<?php

declare(strict_types=1);

use App\Models\Tenant;
use App\Models\TopupSession;
use App\Models\User;
use App\Services\Billing\Gateway\SbpGateway;

it('creates SBP session with QR code data in dev mode', function () {
    $tenant = Tenant::factory()->create();
    $user = User::factory()->for($tenant)->create();

    $gateway = new SbpGateway();
    $result = $gateway->createSession($tenant->id, '5000.00', $user->id);

    $session = TopupSession::find($result->sessionId);
    expect($session->method)->toBe('sbp');
    expect($session->status)->toBe('pending');
    expect($result->qrCodeData)->toStartWith('data:image/'); // фейковый QR в dev
});
```

- [ ] **Step 2: Запустить — FAIL.**

Run: `cd app && php artisan test --filter=SbpGatewayTest`

- [ ] **Step 3: Реализовать `SbpGateway`**

```php
<?php

declare(strict_types=1);

namespace App\Services\Billing\Gateway;

use App\Jobs\Billing\DevAutoConfirmSessionJob;
use App\Models\TopupSession;

/**
 * СБП через VTB. Спек §4.4.
 * Dev/test: фейковый QR + авто-подтверждение через 5 сек (DevAutoConfirmSessionJob).
 * Prod (после Б-1): реальный VTB SBP API + callback /api/billing/vtb-sbp/callback.
 */
final class SbpGateway implements TopupGatewayInterface
{
    public function createSession(int $tenantId, string $amountRub, int $userId): TopupSessionResult
    {
        $session = TopupSession::create([
            'tenant_id' => $tenantId,
            'user_id' => $userId,
            'method' => TopupSession::METHOD_SBP,
            'amount_rub' => $amountRub,
            'provider_ref' => 'SBP-'.now()->timestamp.'-'.$tenantId,
            'status' => TopupSession::STATUS_PENDING,
        ]);

        if (app()->environment(['local', 'testing'])) {
            // 1x1 прозрачный PNG как заглушка QR.
            $fakeQr = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
            DevAutoConfirmSessionJob::dispatch($session->id)->delay(now()->addSeconds(5));

            return new TopupSessionResult(
                sessionId: $session->id,
                status: $session->status,
                qrCodeData: $fakeQr,
            );
        }

        // prod: TODO после Б-1 — VTB SBP API
        throw new \RuntimeException('VTB SBP not configured (pending Б-1)');
    }
}
```

- [ ] **Step 4: Реализовать `DevAutoConfirmSessionJob`** (в dev/test переводит сессию matched → confirmed через `BillingTopupService::confirmPayment`; gate `app()->environment(['local','testing'])`).

- [ ] **Step 5: Запустить — PASS.**

Run: `cd app && php artisan test --filter=SbpGatewayTest`

- [ ] **Step 6: Commit**

```bash
git add app/app/Services/Billing/Gateway/SbpGateway.php app/app/Jobs/Billing/DevAutoConfirmSessionJob.php app/tests/Feature/Billing/SbpGatewayTest.php
git commit -m "feat(billing-v2-c): SbpGateway dev-заглушка + авто-подтверждение"
```

---

### Task 3.2: `CardGateway` (dev-режим) + dev-mock страница эквайринга

**Files:**
- Create: `app/app/Services/Billing/Gateway/CardGateway.php`
- Create: `app/resources/js/views/dev/DevMockAcquiringView.vue` (только dev)
- Modify: router (dev-only route `/dev-mock-vtb-acquiring/:sessionId`)
- Test: `app/tests/Feature/Billing/CardGatewayTest.php`

- [ ] **Step 1: Написать failing-тест**

```php
<?php

declare(strict_types=1);

use App\Models\Tenant;
use App\Models\TopupSession;
use App\Models\User;
use App\Services\Billing\Gateway\CardGateway;

it('creates card session with dev redirect url', function () {
    $tenant = Tenant::factory()->create();
    $user = User::factory()->for($tenant)->create();

    $gateway = new CardGateway();
    $result = $gateway->createSession($tenant->id, '3000.00', $user->id);

    $session = TopupSession::find($result->sessionId);
    expect($session->method)->toBe('card');
    expect($result->redirectUrl)->toContain('dev-mock-vtb-acquiring');
    expect($result->redirectUrl)->toContain((string) $result->sessionId);
});
```

- [ ] **Step 2: Запустить — FAIL.**

Run: `cd app && php artisan test --filter=CardGatewayTest`

- [ ] **Step 3: Реализовать `CardGateway`** (dev — redirect на `/dev-mock-vtb-acquiring/{id}`; prod — throw pending Б-1).

- [ ] **Step 4: Реализовать `DevMockAcquiringView.vue`** (две кнопки «Симулировать успех» / «Симулировать ошибку» → POST на dev-endpoint, который переводит сессию confirmed/failed). Endpoint в `BillingController::devMockAcquiringResult` под gate `app()->environment(['local'])`.

- [ ] **Step 5: Зарегистрировать dev-route** (router + backend, gated на local).

- [ ] **Step 6: Запустить — PASS.**

Run: `cd app && php artisan test --filter=CardGatewayTest`

- [ ] **Step 7: Commit**

```bash
git add app/app/Services/Billing/Gateway/CardGateway.php app/resources/js/views/dev/DevMockAcquiringView.vue app/app/Http/Controllers/Api/BillingController.php app/routes/ app/tests/Feature/Billing/CardGatewayTest.php
git commit -m "feat(billing-v2-c): CardGateway dev-заглушка + mock-страница эквайринга"
```

---

### Task 3.3: `FiscalReceiptProvider` — архитектура 54-ФЗ

**Files:**
- Create: `app/app/Services/Billing/Fiscal/FiscalReceiptProvider.php` (interface)
- Create: `app/app/Services/Billing/Fiscal/NoOpFiscalProvider.php`
- Create: `app/app/Services/Billing/Fiscal/DevMockFiscalProvider.php`
- Create: `app/app/Services/Billing/Fiscal/AtolOnlineFiscalProvider.php` (stub — throw pending Б-1)
- Create: `app/app/Services/Billing/Fiscal/FiscalReceipt.php` (DTO)
- Modify: `app/app/Providers/AppServiceProvider.php` (биндинг провайдера по методу/env)
- Test: `app/tests/Unit/Billing/FiscalReceiptProviderTest.php`

- [ ] **Step 1: Написать failing-тест**

```php
<?php

declare(strict_types=1);

use App\Models\TopupSession;
use App\Services\Billing\Fiscal\DevMockFiscalProvider;
use App\Services\Billing\Fiscal\NoOpFiscalProvider;

it('NoOp returns "not required" for bank transfer', function () {
    $session = new TopupSession(['method' => 'bank_transfer', 'amount_rub' => '100.00']);
    $receipt = (new NoOpFiscalProvider())->issueReceipt($session);
    expect($receipt->required)->toBeFalse();
});

it('DevMock returns fake receipt id for SBP', function () {
    $session = new TopupSession(['method' => 'sbp', 'amount_rub' => '100.00']);
    $receipt = (new DevMockFiscalProvider())->issueReceipt($session);
    expect($receipt->required)->toBeTrue();
    expect($receipt->receiptId)->toStartWith('DEV-RECEIPT-');
});
```

- [ ] **Step 2: Запустить — FAIL.**

Run: `cd app && php artisan test --filter=FiscalReceiptProviderTest`

- [ ] **Step 3: Реализовать DTO + interface + 3 реализации**

```php
<?php
// FiscalReceipt.php
declare(strict_types=1);

namespace App\Services\Billing\Fiscal;

final class FiscalReceipt
{
    public function __construct(
        public readonly bool $required,
        public readonly ?string $receiptId = null,
        public readonly ?string $note = null,
    ) {}
}
```

```php
<?php
// FiscalReceiptProvider.php
declare(strict_types=1);

namespace App\Services\Billing\Fiscal;

use App\Models\TopupSession;

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

`NoOpFiscalProvider` → `new FiscalReceipt(false, null, 'Чек не требуется по 54-ФЗ (B2B безнал)')`.
`DevMockFiscalProvider` → `new FiscalReceipt(true, 'DEV-RECEIPT-'.uniqid())`.
`AtolOnlineFiscalProvider` → `throw new \RuntimeException('ОФД-Атол not configured (pending Б-1)')`.

- [ ] **Step 4: Биндинг в `AppServiceProvider`** — резолвер «по методу платежа»: bank_transfer → NoOp; sbp/card → (dev: DevMock, prod: AtolOnline). Это можно сделать фабрикой `FiscalProviderFactory::forMethod(string $method)`.

- [ ] **Step 5: Запустить — PASS.**

Run: `cd app && php artisan test --filter=FiscalReceiptProviderTest`

- [ ] **Step 6: Commit**

```bash
git add app/app/Services/Billing/Fiscal/ app/app/Providers/AppServiceProvider.php app/tests/Unit/Billing/FiscalReceiptProviderTest.php
git commit -m "feat(billing-v2-c): архитектура 54-ФЗ — FiscalReceiptProvider + 3 реализации"
```

---

### Task 3.4: Подключить выбор gateway + fiscal в `BillingTopupService::initiateTopup`

**Files:**
- Modify: `app/app/Services/Billing/BillingTopupService.php` (resolveGateway + initiateTopup)
- Modify: `app/app/Http/Controllers/Api/BillingController.php` (+ `initiateTopup` endpoint)
- Modify: `app/routes/web.php`
- Test: `app/tests/Feature/Billing/InitiateTopupTest.php`

- [ ] **Step 1: Написать failing-тест**

```php
<?php

declare(strict_types=1);

use App\Models\Tenant;
use App\Models\User;

it('initiates bank transfer topup via endpoint', function () {
    $tenant = Tenant::factory()->create(['legal_entity_name' => 'ООО Альфа', 'legal_entity_inn' => '7700000000']);
    $user = User::factory()->for($tenant)->create();

    $resp = $this->actingAs($user)->postJson('/api/billing/topup/initiate', [
        'method' => 'bank_transfer',
        'amount_rub' => '100000.00',
    ]);

    $resp->assertOk();
    $resp->assertJsonStructure(['session_id', 'status', 'invoice_url']);
});

it('rejects amount below minimum', function () {
    $tenant = Tenant::factory()->create();
    $user = User::factory()->for($tenant)->create();

    $this->actingAs($user)->postJson('/api/billing/topup/initiate', [
        'method' => 'sbp',
        'amount_rub' => '10.00', // < min 100
    ])->assertStatus(422);
});
```

- [ ] **Step 2: Запустить — FAIL.**

Run: `cd app && php artisan test --filter=InitiateTopupTest`

- [ ] **Step 3: Реализовать `BillingTopupService::initiateTopup`** (resolveGateway по `method` → bank_transfer/sbp/card; вернуть `TopupSessionResult`) + `resolveGateway(string): TopupGatewayInterface`.

- [ ] **Step 4: Реализовать `BillingController::initiateTopup`** (валидация method enum + amount min/max из `config/billing.php` per-method; зов сервиса; JSON-ответ session_id/status/invoice_url/qr_code_data/redirect_url по методу).

- [ ] **Step 5: Зарегистрировать route** `POST /api/billing/topup/initiate` под auth:sanctum + tenant.

- [ ] **Step 6: Запустить — PASS.**

Run: `cd app && php artisan test --filter=InitiateTopupTest`

- [ ] **Step 7: Commit**

```bash
git add app/app/Services/Billing/BillingTopupService.php app/app/Http/Controllers/Api/BillingController.php app/routes/web.php app/tests/Feature/Billing/InitiateTopupTest.php
git commit -m "feat(billing-v2-c): initiateTopup — единый вход для 3 методов оплаты"
```

---

## Phase 4 — End-to-end сценарии + регрессия

### Task 4.1: Pest E2E — преfflight (заморозка → пополнение → разморозка)

**Files:**
- Create: `app/tests/Feature/Billing/PreflightE2ETest.php`

- [ ] **Step 1: Написать E2E-тест полного цикла**

```php
<?php

declare(strict_types=1);

use App\Jobs\Billing\BalancePreflightSweepJob;
use App\Models\PricingTier;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Billing\BillingTopupService;
use Illuminate\Support\Facades\Mail;

beforeEach(function () {
    PricingTier::factory()->create(['tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 5000, 'is_active' => true]);
});

it('full cycle: sufficient → drained → frozen → topup → unfrozen', function () {
    Mail::fake();

    // 1. Клиент с балансом на 25 лидов, заказывает 25.
    $tenant = Tenant::factory()->create(['balance_rub' => '1250.00']); // 1250/50 = 25
    Project::factory()->for($tenant)->create(['status' => 'active', 'daily_limit' => 25]);

    // Преfflight cut-off — проходит.
    (new BalancePreflightSweepJob())->handle();
    expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();

    // 2. Баланс истощился за день (симулируем списанием в ноль).
    $tenant->update(['balance_rub' => '0.00']);

    // 3. Следующий cut-off — заморозка.
    (new BalancePreflightSweepJob())->handle();
    expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull();

    // 4. Клиент пополняет — авто-разморозка через confirmPayment.
    $session = \App\Models\TopupSession::create([
        'tenant_id' => $tenant->id, 'method' => 'bank_transfer',
        'amount_rub' => '2000.00', 'provider_ref' => 'SCH-E2E-1', 'status' => 'matched',
    ]);
    (new BillingTopupService())->confirmPayment('SCH-E2E-1', null);

    // 5. Разморожен.
    expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
    expect($tenant->fresh()->balance_rub)->toBe('2000.00');
});
```

- [ ] **Step 2: Запустить — должен пройти на готовой реализации Phase 1+2**

Run: `cd app && php artisan test --filter=PreflightE2ETest`
Expected: PASS. (Если FAIL — починить связку confirmPayment → unfreeze из Task 2.5 Step 5.)

- [ ] **Step 3: Commit**

```bash
git add app/tests/Feature/Billing/PreflightE2ETest.php
git commit -m "test(billing-v2-c): E2E преfflight — полный цикл заморозки/разморозки"
```

---

### Task 4.2: Pest E2E — безнал (счёт → выписка → подтверждение)

**Files:**
- Create: `app/tests/Feature/Billing/BankTransferE2ETest.php`

- [ ] **Step 1: Написать E2E-тест**

```php
<?php

declare(strict_types=1);

use App\Models\BalanceTransaction;
use App\Models\Tenant;
use App\Models\TopupSession;
use App\Models\User;

it('full cycle: initiate → invoice → statement match → admin confirm → balance credited', function () {
    $tenant = Tenant::factory()->create([
        'balance_rub' => '0.00',
        'legal_entity_name' => 'ООО Альфа', 'legal_entity_inn' => '7700000000',
    ]);
    $user = User::factory()->for($tenant)->create();
    $admin = User::factory()->create(['is_saas_admin' => true]);

    // 1. Клиент инициирует безнал.
    $init = $this->actingAs($user)->postJson('/api/billing/topup/initiate', [
        'method' => 'bank_transfer', 'amount_rub' => '100000.00',
    ])->assertOk();
    $sessionId = $init->json('session_id');
    $providerRef = TopupSession::find($sessionId)->provider_ref;

    // 2. Клиент скачивает PDF.
    $this->actingAs($user)->get("/api/billing/topup-sessions/{$sessionId}/invoice.pdf")->assertOk();

    // 3. Выписка нашла платёж (dev-симулятор).
    $this->artisan('billing:vtb-statement-simulate', ['session' => $sessionId, '--amount' => '100000.00'])
        ->assertSuccessful();
    expect(TopupSession::find($sessionId)->status)->toBe('matched');

    // 4. Админ подтверждает.
    $this->actingAs($admin)->postJson("/api/admin/topup-sessions/{$sessionId}/confirm")->assertOk();

    // 5. Баланс зачислен.
    expect($tenant->fresh()->balance_rub)->toBe('100000.00');
    expect(BalanceTransaction::where('tenant_id', $tenant->id)->where('type', 'topup')->count())->toBe(1);
});
```

- [ ] **Step 2: Запустить — PASS.**

Run: `cd app && php artisan test --filter=BankTransferE2ETest`

- [ ] **Step 3: Commit**

```bash
git add app/tests/Feature/Billing/BankTransferE2ETest.php
git commit -m "test(billing-v2-c): E2E безнал — счёт → выписка → подтверждение"
```

---

### Task 4.3: Полная регрессия

**Files:** нет (прогон)

- [ ] **Step 1: Pest --parallel**

REQUIRED SUB-SKILL: `regression` (skill `/regression full`).

Run: `cd app && php artisan test --parallel`
Expected: всё GREEN, 0 regressions. Особо проверить `SyncSupplier*`, `Billing*`, `Project*`.

- [ ] **Step 2: Larastan**

Run: `cd app && composer stan`
Expected: 0 новых ошибок (baseline machine-specific — см. project_qa_checklist; не регенерировать baseline зря).

- [ ] **Step 3: Pint**

Run: `cd app && composer pint`
Expected: все файлы отформатированы.

- [ ] **Step 4: Vitest**

Run: `cd app && npm run test:vue`
Expected: всё GREEN включая новые компоненты.

- [ ] **Step 5: Vite build**

Run: `cd app && npm run build`
Expected: сборка без ошибок.

- [ ] **Step 6: gitleaks + lychee**

Run: `./bin/gitleaks.exe detect` + `npm run links`
Expected: 0 leaks, 0 broken links.

- [ ] **Step 7: Зафиксировать статус-строку регрессии** (Pest N/N, Vitest N/N, build OK, gitleaks 0) в финальный отчёт.

---

## Phase 5 — Документация + ADR + push

### Task 5.1: ADR-016 — архитектура преfflight + VTB

**Files:**
- Create: `docs/adr/016-preflight-vtb-architecture.md`

- [ ] **Step 1: Написать ADR** (Context — переплата поставщику + нет реального пополнения; Decision — преfflight как фильтр eligible до формулы + gateway-pattern для 3 методов + dev-заглушки до Б-1; Consequences — границы PF1..PFN; Status — Accepted). REQUIRED SUB-SKILL: `adr-kit:adr`.

- [ ] **Step 2: Commit**

```bash
git add docs/adr/016-preflight-vtb-architecture.md
git commit -m "docs(adr): ADR-016 — преfflight баланса + VTB gateway-архитектура"
```

---

### Task 5.2: Обновить memory + нормативку (по необходимости)

**Files:**
- Modify: `memory/project_billing_v2.md` (Спек C: статус, артефакты, что выкачено)
- Modify: `memory/MEMORY.md` (одна строка-указатель, если меняется)

- [ ] **Step 1: Обновить `project_billing_v2.md`** — раздел «Спек C» из PENDING brainstorm → IMPLEMENTED (worktree, ветка, фазы, что dev-заглушки до Б-1, что pending). Через Write tool (это memory, не код).

- [ ] **Step 2: CLAUDE.md / Pravila / Tooling** — **проверить**, нужны ли правки. Преfflight/VTB — это фича портала, не новый инструмент тулчейна → **скорее всего нормативка не меняется**. Если нужно — через `/claude-md-management:claude-md-improver` (НЕ прямой Edit CLAUDE.md, кроме worktree-эксцепшна §5 п.10).

- [ ] **Step 3: Commit (если были правки)**

```bash
git add docs/
git commit -m "docs(billing-v2-c): обновление контекста после Спека C"
```

---

### Task 5.3: Финиш ветки — PR / merge

**Files:** нет (git)

- [ ] **Step 1: Финальная регрессия** (повтор Task 4.3 целиком на чистом дереве).

- [ ] **Step 2: Решить способ интеграции**

REQUIRED SUB-SKILL: `superpowers:finishing-a-development-branch`.

Варианты: PR в main (через `gh`) ИЛИ FF-merge в main (паттерн Спека B `git push origin feat/billing-v2-spec-c:main` через worktree). **Только после явного «выкатываем» от заказчика** (Pravila §2.2 — не закрывать без подтверждения).

- [ ] **Step 3: НЕ выкатывать на прод автоматически.** Боевой деплой VTB-части невозможен до Б-1 (нет реквизитов). Преfflight-часть можно выкатить — но **только по явному решению заказчика** + ручной прогон `billing:preflight-initial-sweep` на проде с предупреждением клиентам.

- [ ] **Step 4: Обновить `ПИЛОТ.md`** (если/когда выкачено на боевой liderra.ru).

---

## Карта файлов (сводка)

**Создаются:**

- БД: 2 миграции + 2 prod-sql.
- Бэк-сервисы: `BalancePreflightService`, `PreflightResult`, `Gateway/{TopupGatewayInterface, TopupSessionResult, BankTransferGateway, SbpGateway, CardGateway, MissingRequisitesException}`, `Fiscal/{FiscalReceiptProvider, FiscalReceipt, NoOpFiscalProvider, DevMockFiscalProvider, AtolOnlineFiscalProvider}`, `VtbBusinessClient`.
- Бэк-модели: `TopupSession`.
- Бэк-джобы: `BalancePreflightSweepJob`, `BalanceFrozenReminderJob`, `VtbStatementSyncJob`, `NotifyPendingConfirmationsJob`, `DevAutoConfirmSessionJob`.
- Бэк-команды: `BillingPreflightSweepCommand`, `BillingPreflightInitialSweepCommand`, `VtbStatementSimulateCommand`.
- Бэк-контроллеры: `Admin/PendingTopupsController`, `Admin/BillingSettingsController`.
- Бэк-Mail: `BalanceFrozenMail`, `BalanceFrozenReminderMail`, `BalanceFrozenFinalMail`, `BalanceUnfrozenMail`, `PendingConfirmationsAdminMail`.
- Blade: `pdf/invoice.blade.php` + 5 email-шаблонов.
- Конфиг: `config/billing.php`.
- Фронт: `BalanceFrozenBanner`, `BalanceCapacityIndicator`, `ProjectLimitOverloadDialog`, `TopupView`, `TopupMethodPicker`, `BankTransferInvoiceView`, `LegalEntityForm`, `PendingPaymentsAdminView`, `BillingSettingsAdminView`, `DevMockAcquiringView`.
- Docs: `docs/adr/016-preflight-vtb-architecture.md`.

**Модифицируются:**

- `Models/Tenant.php`, `Models/Project.php`, `BillingTopupService.php`, `BillingController.php`, `ProjectController.php`, `SyncSupplierProjectsJob.php`, `routes/console.php`, `routes/web.php`, `AppServiceProvider.php`, `db/schema.sql`, `db/CHANGELOG_schema.md`, `composer.json`, router фронта, `AppLayout.vue`, проектная форма.

**НЕ трогаются** (важно): `SupplierQuotaAllocator.php` (формула неизменна), `LedgerService.php`, `BalanceToLeadsConverter.php`, `PricingTierResolver.php`.

---

## Заметки для исполнителя

**По §3.9 спека (граничные случаи):** главный инвариант «баланс не уходит в минус» держится `LedgerService` (он уже защищён от списания ниже нуля) + ежедневным преfflight. Поэтому отдельное «предупреждение админа при ретро-операциях» (CSV-импорт исторических лидов / ручная правка баланса между cut-off и началом дня) — **опциональная UX-полировка**, не блокер. Если делать — добавить confirm-диалог в `ImportController` и в `AdminTenantsController::adjustBalance` UI с текстом «эта операция может вывести клиента в заморозку на следующем cut-off». Минимально — можно вынести в отдельную мелкую задачу или Спек D. Преfflight отработает корректно в любом случае.

**По UI-задачам (1.10, 2.9, 3.2):** для критичных компонентов (`BalanceCapacityIndicator`) дан полный код. Для остальных Vue-компонентов дан контракт (props / emit / поведение) — исполнитель пишет разметку по образцу первого компонента и существующих паттернов проекта (Vuetify 3, Composition API, `<script setup lang="ts">`). Это сознательное решение: расписывать 10 Vue-компонентов построчно раздуло бы план без пользы. Каждый UI-компонент имеет Vitest-тест с явными ожиданиями — они и фиксируют контракт.

**Порядок cron (важно):** `billing:preflight-sweep` @18:00 MSK должен отработать **до** `SyncSupplierProjectsJob`. Проверить текущее расписание Sync и при пересечении сдвинуть Sync на 18:05+ (Task 1.4 Step 5).

---

## Out of scope (отдельные планы после Б-1)

- Боевая интеграция VTB Acquiring (карты, 3DS, chargeback).
- Боевая интеграция VTB SBP API (callbacks).
- Боевая интеграция VTB Бизнес API (авто-сверка выписки).
- Боевая интеграция ОФД-Атол (фискализация).
- Боевые секреты в YC Lockbox (SEC-5).
- Спек D: «отдать разморозившемуся клиенту лиды через шеринг» + «приоритет шеринга по платёжеспособности».

