docs(billing-v2-c): спек C + план реализации (преfflight + VTB)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-24 10:46:59 +03:00
committed by Дмитрий
parent 247cc38217
commit 073d3659e8
2 changed files with 53 additions and 81 deletions
@@ -1,10 +1,10 @@
# Биллинг v2 Спек C — Префлайт баланса + VTB-эквайринг — План реализации
# Биллинг 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:** Защитить портал от заказа лидов у поставщика на клиентов без баланса (префлайт) + дать реальное пополнение баланса через безнал (PDF-счёт + сверка) с архитектурой под СБП/карты VTB.
**Goal:** Защитить портал от заказа лидов у поставщика на клиентов без баланса (преfflight) + дать реальное пополнение баланса через безнал (PDF-счёт + сверка) с архитектурой под СБП/карты VTB.
**Architecture:** Префлайт — фильтр eligible-проектов до формулы `computeOrder` (формула не меняется) + флаги заморозки на tenant/project + ежедневный cron 18:00 MSK. VTB — интерфейс `TopupGatewayInterface` + 3 реализации (безнал полный, СБП/карты dev-заглушки) + журнал `topup_sessions`.
**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 (новый).
@@ -16,7 +16,7 @@
**Что уже есть на `origin/main` (зависимости):**
- `App\Services\Billing\BalanceToLeadsConverter::convert(string $balanceRub, int $deliveredInMonth, Collection $tiers): array` — возвращает `['leads' => int, 'breakdown' => [...], 'current_tier' => ..., 'next_tier' => ...]`. Pure, bcmath. Используется в префлайт для «сколько лидов даёт баланс».
- `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 пополнения (рефакторим).
@@ -72,12 +72,11 @@ Expected: миграции прошли, converter-тесты GREEN (завис
---
## Phase 1 — Префлайт баланса
## 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`)
@@ -173,7 +172,6 @@ git commit -m "feat(billing-v2-c): миграция — флаги заморо
### 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`
@@ -272,7 +270,7 @@ namespace App\Services\Billing;
use Illuminate\Database\Eloquent\Collection;
/**
* Pure: проходит ли клиент префлайт — хватает ли баланса на ПОЛНЫЙ дневной
* Pure: проходит ли клиент преfflight — хватает ли баланса на ПОЛНЫЙ дневной
* лимит всех его eligible-проектов по текущему тарифу.
*
* Сравнение в ЛИДАХ (capacity vs required), не в рублях — переиспользует
@@ -330,7 +328,6 @@ 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`
@@ -372,7 +369,7 @@ Expected: FAIL — метод `requiredLeadsForTomorrow` не существуе
```php
/**
* Сумма daily_limit активных проектов — «сколько лидов клиент хочет в день».
* Используется префлайт'ом как requiredLeads.
* Используется преfflight'ом как requiredLeads.
*/
public function requiredLeadsForTomorrow(): int
{
@@ -401,7 +398,6 @@ 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)
@@ -489,7 +485,7 @@ 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;
@@ -578,12 +574,12 @@ final class BillingPreflightSweepCommand extends Command
{
protected $signature = 'billing:preflight-sweep';
protected $description = 'Ежедневный префлайт баланса — заморозка/разморозка тенантов (cut-off 18:00 MSK)';
protected $description = 'Ежедневный преfflight баланса — заморозка/разморозка тенантов (cut-off 18:00 MSK)';
public function handle(): int
{
(new BalancePreflightSweepJob())->handle();
$this->info('Префлайт sweep завершён.');
$this->info('Преfflight sweep завершён.');
return self::SUCCESS;
}
@@ -595,7 +591,7 @@ final class BillingPreflightSweepCommand extends Command
Добавить после существующих задач (с heartbeat-обёрткой по образцу `projects:reset-delivered-today`):
```php
// Спек C §3.2: префлайт баланса в 18:00 MSK — формирование заказа поставщику без «бедных» клиентов.
// Спек C §3.2: преfflight баланса в 18:00 MSK — формирование заказа поставщику без «бедных» клиентов.
Schedule::command('billing:preflight-sweep')
->dailyAt('18:00')
->timezone('Europe/Moscow')
@@ -629,7 +625,6 @@ 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`
@@ -751,7 +746,6 @@ 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`
@@ -894,10 +888,9 @@ git commit -m "feat(billing-v2-c): повторные письма заморо
---
### Task 1.7: `ProjectController` — префлайт при создании/правке проекта
### Task 1.7: `ProjectController` — преfflight при создании/правке проекта
**Files:**
- Modify: `app/app/Http/Controllers/Api/ProjectController.php` (store + update — 409 при перегрузке)
- Test: `app/tests/Feature/Project/ProjectPreflightTest.php`
@@ -965,12 +958,12 @@ it('creates normally when within balance', function () {
Run: `cd app && php artisan test --filter=ProjectPreflightTest`
Expected: FAIL — 409 не возвращается (создаёт нормально).
- [ ] **Step 3: Реализовать префлайт-проверку в `ProjectController::store`** (и аналогично `update`)
- [ ] **Step 3: Реализовать преfflight-проверку в `ProjectController::store`** (и аналогично `update`)
Вставить ПОСЛЕ валидации, ДО создания проекта:
```php
// Спек C §3.4: префлайт при создании — если сумма лимитов перевешивает баланс.
// Спек C §3.4: преfflight при создании — если сумма лимитов перевешивает баланс.
$tenant = $request->user()->tenant;
$tiers = \App\Models\PricingTier::query()->where('is_active', true)->get();
$service = new \App\Services\Billing\BalancePreflightService();
@@ -1012,7 +1005,7 @@ Expected: PASS (3 теста).
```bash
git add app/app/Http/Controllers/Api/ProjectController.php app/tests/Feature/Project/ProjectPreflightTest.php
git commit -m "feat(billing-v2-c): ProjectController префлайт — 409 при перегрузке баланса"
git commit -m "feat(billing-v2-c): ProjectController преfflight — 409 при перегрузке баланса"
```
---
@@ -1020,7 +1013,6 @@ git commit -m "feat(billing-v2-c): ProjectController префлайт — 409 п
### Task 1.8: Фильтр frozen-проектов в `SyncSupplierProjectsJob`
**Files:**
- Modify: `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` (исключить frozen перед allocator)
- Test: `app/tests/Feature/Supplier/SyncSupplierPreflightFilterTest.php`
@@ -1102,7 +1094,6 @@ git commit -m "feat(billing-v2-c): SyncSupplierProjectsJob исключает fr
### Task 1.9: One-time команда `billing:preflight-initial-sweep`
**Files:**
- Create: `app/app/Console/Commands/BillingPreflightInitialSweepCommand.php`
- Test: `app/tests/Feature/Billing/BillingPreflightInitialSweepTest.php`
@@ -1149,18 +1140,18 @@ use App\Jobs\Billing\BalancePreflightSweepJob;
use Illuminate\Console\Command;
/**
* One-time: при выкатке префлайт прогнать всех тенантов и заморозить
* One-time: при выкатке преfflight прогнать всех тенантов и заморозить
* недофинансированных. Запускается ОДИН раз вручную после миграции. Спек §3.9.
*/
final class BillingPreflightInitialSweepCommand extends Command
{
protected $signature = 'billing:preflight-initial-sweep';
protected $description = 'Разовый префлайт при внедрении — заморозить недофинансированных тенантов';
protected $description = 'Разовый преfflight при внедрении — заморозить недофинансированных тенантов';
public function handle(): int
{
$this->warn('Разовый префлайт всех тенантов. Запускать ОДИН раз после выкатки.');
$this->warn('Разовый преfflight всех тенантов. Запускать ОДИН раз после выкатки.');
(new BalancePreflightSweepJob())->handle();
$this->info('Initial sweep завершён.');
@@ -1186,7 +1177,6 @@ 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`
@@ -1281,7 +1271,7 @@ Expected: PASS.
```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 префлайт — баннер, индикатор ёмкости, диалог перегрузки"
git commit -m "feat(billing-v2-c): UI преfflight — баннер, индикатор ёмкости, диалог перегрузки"
```
---
@@ -1291,7 +1281,6 @@ git commit -m "feat(billing-v2-c): UI префлайт — баннер, инд
### 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`
@@ -1388,7 +1377,6 @@ git commit -m "feat(billing-v2-c): миграция — реквизиты юр.
### Task 2.2: Установить PDF-генератор
**Files:**
- Modify: `app/composer.json` (+ `barryvdh/laravel-dompdf`)
- [ ] **Step 1: Установить пакет**
@@ -1413,7 +1401,6 @@ 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`
@@ -1617,7 +1604,6 @@ git commit -m "feat(billing-v2-c): TopupSession + TopupGatewayInterface + BankTr
### 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)
@@ -1712,7 +1698,6 @@ 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`
@@ -1812,7 +1797,7 @@ public function confirmPayment(string $providerRef, ?int $adminUserId): ?Balance
Run: `cd app && php artisan test --filter=BillingTopupConfirmTest`
Expected: PASS (2 теста).
- [ ] **Step 5: Связка с префлайт — после confirm перепроверить заморозку**
- [ ] **Step 5: Связка с преfflight — после confirm перепроверить заморозку**
Добавить в конце `confirmPayment` (после коммита транзакции, или внутри): если `tenant.frozen_by_balance_at !== null` — прогнать `BalancePreflightService::evaluate`, и если теперь проходит — снять заморозку + `BalanceUnfrozenMail` + лог. Написать доп. тест `it('unfreezes tenant on topup confirm')`.
@@ -1828,7 +1813,6 @@ 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 реализация)
@@ -1907,7 +1891,6 @@ git commit -m "feat(billing-v2-c): VtbStatementSyncJob авто-поиск пл
### 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)
@@ -1981,7 +1964,6 @@ git commit -m "feat(billing-v2-c): админка ожидающих плате
### 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)
@@ -2052,7 +2034,6 @@ 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`
@@ -2103,7 +2084,6 @@ git commit -m "feat(billing-v2-c): UI пополнения (безнал) + ре
### 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`
@@ -2204,7 +2184,6 @@ 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`)
@@ -2262,7 +2241,6 @@ 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`
@@ -2356,7 +2334,6 @@ git commit -m "feat(billing-v2-c): архитектура 54-ФЗ — FiscalRece
### 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`
@@ -2421,10 +2398,9 @@ git commit -m "feat(billing-v2-c): initiateTopup — единый вход дл
## Phase 4 — End-to-end сценарии + регрессия
### Task 4.1: Pest E2E — префлайт (заморозка → пополнение → разморозка)
### Task 4.1: Pest E2E — преfflight (заморозка → пополнение → разморозка)
**Files:**
- Create: `app/tests/Feature/Billing/PreflightE2ETest.php`
- [ ] **Step 1: Написать E2E-тест полного цикла**
@@ -2453,7 +2429,7 @@ it('full cycle: sufficient → drained → frozen → topup → unfrozen', funct
$tenant = Tenant::factory()->create(['balance_rub' => '1250.00']); // 1250/50 = 25
Project::factory()->for($tenant)->create(['status' => 'active', 'daily_limit' => 25]);
// Префлайт cut-off — проходит.
// Преfflight cut-off — проходит.
(new BalancePreflightSweepJob())->handle();
expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
@@ -2486,7 +2462,7 @@ Expected: PASS. (Если FAIL — починить связку confirmPayment
```bash
git add app/tests/Feature/Billing/PreflightE2ETest.php
git commit -m "test(billing-v2-c): E2E префлайт — полный цикл заморозки/разморозки"
git commit -m "test(billing-v2-c): E2E преfflight — полный цикл заморозки/разморозки"
```
---
@@ -2494,7 +2470,6 @@ git commit -m "test(billing-v2-c): E2E префлайт — полный цик
### Task 4.2: Pest E2E — безнал (счёт → выписка → подтверждение)
**Files:**
- Create: `app/tests/Feature/Billing/BankTransferE2ETest.php`
- [ ] **Step 1: Написать E2E-тест**
@@ -2596,19 +2571,18 @@ Expected: 0 leaks, 0 broken links.
## Phase 5 — Документация + ADR + push
### Task 5.1: ADR-016 — архитектура префлайт + VTB
### Task 5.1: ADR-016 — архитектура преfflight + VTB
**Files:**
- Create: `docs/adr/016-preflight-vtb-architecture.md`
- [ ] **Step 1: Написать ADR** (Context — переплата поставщику + нет реального пополнения; Decision — префлайт как фильтр eligible до формулы + gateway-pattern для 3 методов + dev-заглушки до Б-1; Consequences — границы PF1..PFN; Status — Accepted). REQUIRED SUB-SKILL: `adr-kit:adr`.
- [ ] **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 — префлайт баланса + VTB gateway-архитектура"
git commit -m "docs(adr): ADR-016 — преfflight баланса + VTB gateway-архитектура"
```
---
@@ -2616,13 +2590,12 @@ git commit -m "docs(adr): ADR-016 — префлайт баланса + VTB gate
### 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****проверить**, нужны ли правки. Префлайт/VTB — это фича портала, не новый инструмент тулчейна → **скорее всего нормативка не меняется**. Если нужно — через `/claude-md-management:claude-md-improver` (НЕ прямой Edit CLAUDE.md, кроме worktree-эксцепшна §5 п.10).
- [ ] **Step 2: CLAUDE.md / Pravila / Tooling****проверить**, нужны ли правки. Преfflight/VTB — это фича портала, не новый инструмент тулчейна → **скорее всего нормативка не меняется**. Если нужно — через `/claude-md-management:claude-md-improver` (НЕ прямой Edit CLAUDE.md, кроме worktree-эксцепшна §5 п.10).
- [ ] **Step 3: Commit (если были правки)**
@@ -2645,7 +2618,7 @@ 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 (нет реквизитов). Префлайт-часть можно выкатить — но **только по явному решению заказчика** + ручной прогон `billing:preflight-initial-sweep` на проде с предупреждением клиентам.
- [ ] **Step 3: НЕ выкатывать на прод автоматически.** Боевой деплой VTB-части невозможен до Б-1 (нет реквизитов). Преfflight-часть можно выкатить — но **только по явному решению заказчика** + ручной прогон `billing:preflight-initial-sweep` на проде с предупреждением клиентам.
- [ ] **Step 4: Обновить `ПИЛОТ.md`** (если/когда выкачено на боевой liderra.ru).
@@ -2677,7 +2650,7 @@ REQUIRED SUB-SKILL: `superpowers:finishing-a-development-branch`.
## Заметки для исполнителя
**По §3.9 спека (граничные случаи):** главный инвариант «баланс не уходит в минус» держится `LedgerService` (он уже защищён от списания ниже нуля) + ежедневным префлайт. Поэтому отдельное «предупреждение админа при ретро-операциях» (CSV-импорт исторических лидов / ручная правка баланса между cut-off и началом дня) — **опциональная UX-полировка**, не блокер. Если делать — добавить confirm-диалог в `ImportController` и в `AdminTenantsController::adjustBalance` UI с текстом «эта операция может вывести клиента в заморозку на следующем cut-off». Минимально — можно вынести в отдельную мелкую задачу или Спек D. Префлайт отработает корректно в любом случае.
**По §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-тест с явными ожиданиями — они и фиксируют контракт.
@@ -2693,3 +2666,4 @@ REQUIRED SUB-SKILL: `superpowers:finishing-a-development-branch`.
- Боевая интеграция ОФД-Атол (фискализация).
- Боевые секреты в YC Lockbox (SEC-5).
- Спек D: «отдать разморозившемуся клиенту лиды через шеринг» + «приоритет шеринга по платёжеспособности».
@@ -34,7 +34,7 @@ order = max(самый_большой_лимит, ceil(сумма_лимитов
### §1.2 Проблемы
**Префлайт:**
**Преfflight:**
1. **Портал переплачивает поставщику** за лиды клиентов, у которых нет денег. Это прямой убыток — закупка оплачена, а продажа не состоится.
2. **Нет проактивной защиты при создании/изменении проектов.** Клиент может выставить лимит, который сам по себе не оплачиваем. Сейчас проблема всплывает только в момент списания.
@@ -42,8 +42,8 @@ order = max(самый_большой_лимит, ceil(сумма_лимитов
**VTB-эквайринг:**
1. **Реального пополнения нет**`BillingTopupService` это заглушка. Деньги попадают на баланс только через ручное действие админа (`AdminTenantsController::adjustBalance`).
2. **Нет 54-ФЗ фискализации** при ритейл-платежах (после подключения карт/СБП — обязательно).
4. **Реального пополнения нет**`BillingTopupService` это заглушка. Деньги попадают на баланс только через ручное действие админа (`AdminTenantsController::adjustBalance`).
5. **Нет 54-ФЗ фискализации** при ритейл-платежах (после подключения карт/СБП — обязательно).
### §1.3 Триггер
@@ -57,7 +57,7 @@ order = max(самый_большой_лимит, ceil(сумма_лимитов
### §2.1 Что делаем
**Префлайт баланса:**
**Преfflight баланса:**
- Расширение `SupplierQuotaAllocator` для учёта баланса клиента (фильтрация eligible-проектов до `computeOrder`).
- Активная проверка при создании/правке проекта в личном кабинете (диалог выбора).
@@ -81,17 +81,17 @@ order = max(самый_большой_лимит, ceil(сумма_лимитов
- **«Отдать разморозившемуся клиенту лиды, уже купленные сегодня, через шеринг»** — отложено в Спек D (см. §8).
- **Возвраты пополнений** (refund) — не реализуем (Спек A: «возвраты не делаем»).
- **Recurring-платежи** (автосписание) — не реализуем.
- **Изменение формулы `computeOrder`** — формула остаётся прежней, префлайт только фильтрует входной список.
- **Изменение формулы `computeOrder`** — формула остаётся прежней, преfflight только фильтрует входной список.
---
## §3. Решение — часть 1: Префлайт баланса
## §3. Решение — часть 1: Преfflight баланса
### §3.1 Главный инвариант
**Баланс клиента никогда не уходит в минус.** Гарант — префлайт, который проверяет «хватит ли на полный дневной заказ» **до** того, как заказ уйдёт поставщику. Если хватает — клиент в заказе; если поставщик пришлёт меньше планируемого (норма), остаток баланса уходит в следующий день.
**Баланс клиента никогда не уходит в минус.** Гарант — преfflight, который проверяет «хватит ли на полный дневной заказ» **до** того, как заказ уйдёт поставщику. Если хватает — клиент в заказе; если поставщик пришлёт меньше планируемого (норма), остаток баланса уходит в следующий день.
### §3.2 Когда срабатывает префлайт
### §3.2 Когда срабатывает преfflight
**Одна основная точка:** ежедневный cut-off в **18:00 MSK** (включая выходные).
@@ -124,11 +124,11 @@ $requiredLeads = $tenant->projects()
$passes = $capacity >= $requiredLeads;
```
Если `passes=true` — клиент проходит префлайт. Если `false` — не проходит.
Если `passes=true` — клиент проходит преfflight. Если `false` — не проходит.
7-ступенчатый расчёт уже реализован в `BalanceToLeadsConverter::convert` (Спек A) — он сам пройдёт по ступеням, учтёт «текущую» (где сейчас клиент в накопленном объёме) и переход на следующие при росте.
**NB:** проверяется на **полный лимит** проектов, не на «уже отгруженное + остаток сегодняшнего дня». Это потому, что префлайт работает один раз перед формированием заказа на завтра, а не во время выдачи.
**NB:** проверяется на **полный лимит** проектов, не на «уже отгруженное + остаток сегодняшнего дня». Это потому, что преfflight работает один раз перед формированием заказа на завтра, а не во время выдачи.
### §3.4 Что делает портал при создании/правке «перегруженного» проекта
@@ -218,16 +218,16 @@ $passes = $capacity >= $requiredLeads;
- **Онлайн-режим** (сейчас, для малого числа клиентов): любое изменение в проектах Лидерры → немедленный апдейт на сервере поставщика (`SyncSupplierProjectJob` per-project). Поставщик сохраняет, использует в своём 21:00 слепке.
- **Batch до 18:00** (на будущее при росте): накопленные изменения уезжают одним пакетом перед 18:00 (`SyncSupplierProjectsJob` daily cron).
**Префлайт работает одинаково в обоих режимах** — он только меняет, какие проекты идут в `SyncSupplierProjectJob` (исключает frozen-проекты из `active_today`). Дальше — стандартный механизм синхронизации.
**Преfflight работает одинаково в обоих режимах** — он только меняет, какие проекты идут в `SyncSupplierProjectJob` (исключает frozen-проекты из `active_today`). Дальше — стандартный механизм синхронизации.
### §3.9 Граничные случаи
| Случай | Поведение |
|---|---|
| Ретро-операция (CSV-импорт исторических лидов) между 18:00 и началом следующего дня списывает баланс ниже плана | Допускается, но админ предупреждается в UI «эта операция может вывести клиента в заморозку, продолжить?». Если согласился — выполняется; на следующем cut-off клиент будет в заморозке. Минусовых балансов не возникает (CSV-импорт делает обычные `lead_charges` через `LedgerService`, который остаётся защищён от минуса) |
| Ручная правка баланса админом (`adjustBalance`) уменьшает баланс ниже плана | Аналогично — админ предупреждается, ответственность на нём. Префлайт отработает на следующем cut-off |
| Клиент уже в минусовом балансе на момент запуска префлайт (legacy состояние) | Одноразовая artisan-команда `billing:preflight-initial-sweep` — проходит по всем тенантам, помечает `frozen_by_balance_at` где нужно, отправляет письма с пояснением «у вас активирована новая защита баланса». Запускается один раз при выкатке миграции |
| Тарифная ступень меняется в течение дня (накопился объём) | Префлайт на 18:00 MSK использует **текущую** ступень. На завтра ступень может быть другой — но это уже зона следующего cut-off |
| Ручная правка баланса админом (`adjustBalance`) уменьшает баланс ниже плана | Аналогично — админ предупреждается, ответственность на нём. Преfflight отработает на следующем cut-off |
| Клиент уже в минусовом балансе на момент запуска преfflight (legacy состояние) | Одноразовая artisan-команда `billing:preflight-initial-sweep` — проходит по всем тенантам, помечает `frozen_by_balance_at` где нужно, отправляет письма с пояснением «у вас активирована новая защита баланса». Запускается один раз при выкатке миграции |
| Тарифная ступень меняется в течение дня (накопился объём) | Преfflight на 18:00 MSK использует **текущую** ступень. На завтра ступень может быть другой — но это уже зона следующего cut-off |
| Поставщик прислал меньше планируемого (норма) | Остаток баланса клиента — экономия для следующего дня. Никаких корректировок |
| Клиент пополнил после 18:00 | В сегодня-в-21:00-слепок поставщика не успевает, но в личном кабинете тут же «Возобновлено». В следующий вечерний cut-off — в заказ на послезавтра |
@@ -239,7 +239,7 @@ $passes = $capacity >= $requiredLeads;
order = max(самый_большой_лимит, ceil(сумма_лимитов ÷ 3))
```
Префлайт **не меняет формулу**, а **фильтрует входной массив `daily_limits`** — выкидывает клиентов, не прошедших проверку.
Преfflight **не меняет формулу**, а **фильтрует входной массив `daily_limits`** — выкидывает клиентов, не прошедших проверку.
**Эффект зависит от того, кого выкинули:**
@@ -256,7 +256,7 @@ order = max(самый_большой_лимит, ceil(сумма_лимитов
### §3.11 Журналирование
При каждом срабатывании префлайт (заморозка / разморозка) — строка в новой таблице `balance_freeze_log`:
При каждом срабатывании преfflight (заморозка / разморозка) — строка в новой таблице `balance_freeze_log`:
```sql
CREATE TABLE balance_freeze_log (
@@ -488,7 +488,7 @@ ALTER TABLE tenants ADD COLUMN legal_entity_form VARCHAR(20); -- 'OOO' | 'IP
| **Фронт-views** | `TopupView.vue` (новый, обёртка с переключателем methods); `BillingFrozenInfoView.vue` |
| **Pinia** | `billingStore` (расширение под topup-сессии); `tenantStore` (frozen-флаг) |
### §5.2 Sequence-диаграмма префлайт на cut-off
### §5.2 Sequence-диаграмма преfflight на cut-off
```
18:00 MSK Cron
@@ -518,7 +518,7 @@ ALTER TABLE tenants ADD COLUMN legal_entity_form VARCHAR(20); -- 'OOO' | 'IP
│ │
│ └── для следующего tenant...
18:05 MSK (после префлайт) — обычный SyncSupplierProjectsJob запускается
18:05 MSK (после преfflight) — обычный SyncSupplierProjectsJob запускается
├── SupplierQuotaAllocator::allocate(eligible_projects)
│ │
@@ -635,12 +635,11 @@ $dto = SupplierQuotaAllocator::allocate(..., $eligibleProjects, $targetDate);
## §6. Сценарии (end-to-end)
### §6.1 Префлайт — пассивный износ
### §6.1 Преfflight — пассивный износ
**Воскресенье 00:00.** Клиент с балансом 1000₽ = 30 лидов (tier 3, цена ~33₽/лид). Проекты заказывают 25/день. Запас 5.
**Воскресенье 18:00.** Cron `BalancePreflightSweepJob`:
- `BalancePreflightService::evaluate(client)``passes=true` (хватает на 25).
- `frozen_by_balance_at` остаётся `NULL`.
@@ -649,7 +648,6 @@ $dto = SupplierQuotaAllocator::allocate(..., $eligibleProjects, $targetDate);
**Понедельник.** В течение дня партии лидов приходят, клиент получает все 25, баланс падает до 0. Никаких внутридневных стопов.
**Понедельник 18:00.** Cron snova:
- `evaluate(client)``passes=false` (0₽ ≠ 25 лидов).
- `frozen_by_balance_at = now()`.
- `balance_freeze_log.insert(event='frozen', triggered_by='cutoff_18msk')`.
@@ -663,11 +661,11 @@ $dto = SupplierQuotaAllocator::allocate(..., $eligibleProjects, $targetDate);
**Вторник 18:00.** Cron snova — клиент в заказе на среду. Со среды получает лиды.
### §6.2 Префлайт — активная нехватка при создании проекта
### §6.2 Преfflight — активная нехватка при создании проекта
**Клиент с балансом 1000₽ = 30 лидов.** Имеет 3 проекта по 10 = 30 лимит. Всё впритык.
**Клиент создаёт 4-й проект с лимитом 20.** Бэк (`ProjectController::store`) делает превью-префлайт:
**Клиент создаёт 4-й проект с лимитом 20.** Бэк (`ProjectController::store`) делает превью-преfflight:
- `Σ daily_limit (после сохранения) = 50` лидов
- `capacity = BalanceToLeadsConverter::convert(1000₽, delivered, tiers)['leads'] = 30` лидов
@@ -766,7 +764,7 @@ $dto = SupplierQuotaAllocator::allocate(..., $eligibleProjects, $targetDate);
Предварительная разбивка на фазы (для оценки масштаба):
**Phase 1 — Префлайт баланса** (~5-7 задач):
**Phase 1 — Преfflight баланса** (~5-7 задач):
- Миграция БД (`frozen_by_balance_at`, `preflight_blocked_at`, `balance_freeze_log`).
- `BalancePreflightService` (pure) + тесты.
@@ -798,7 +796,7 @@ $dto = SupplierQuotaAllocator::allocate(..., $eligibleProjects, $targetDate);
**Phase 4 — Тесты + smoke** (~3-4 задач):
- Pest end-to-end сценарии префлайт (frozen/unfrozen flow).
- Pest end-to-end сценарии преfflight (frozen/unfrozen flow).
- Pest end-to-end сценарии безнала (создание счёта → симуляция выписки → подтверждение).
- Vitest на новые Vue-компоненты.
- Регрессия (Pest --parallel + Vitest + lychee + gitleaks).