docs(spec): Billing v2 Spec A — единый ₽-баланс + унификация tariff_plans

Дизайн-документ Спека A серии «Биллинг v2» (Спек B — дубли, Спек C — preflight + VTB).

Approach 3: чистый разрез + унификация tariff_plans.
- tenants.balance_leads → DROP (двухфазный релиз с idempotent artisan-командой)
- tariff_plans.price_per_lead/price_monthly/included_leads/trial_bonus_leads/billing_model → DROP
- pricing_tiers остаётся единственным источником цены за лид
- Новый pure-сервис BalanceToLeadsConverter (точный расчёт по ступеням)
- LedgerService::chargeForDelivery упрощается (только rub-ветка)
- BillingController::wallet отдаёт affordable_leads + current_tier + tiers_preview
- AdminPricingTiersController fix: float → bcmul + decimal validation
- 19 находок аудита Биллинга закрываются в этом спеке (P0=5, P1=6, P2=4, связанные=4)

Out of scope: возвраты, VTB-эквайринг (спек C), auto-stop проектов (спек C),
дубли (спек B).

Двухфазный релиз: код+data migration → 24-72ч наблюдение → ALTER TABLE.

cspell: +4 слова (vtb, брейнсторм, брейнсторму, подписочной).
This commit is contained in:
Дмитрий
2026-05-23 11:34:51 +03:00
parent 86d8e25cb4
commit 866bf1765e
2 changed files with 639 additions and 0 deletions
+6
View File
@@ -1658,3 +1658,9 @@ Vite
сериализуется
флагует
клиентно
# Billing v2 Spec A (23.05.2026)
vtb
брейнсторм
подписочной
брейнсторму
@@ -0,0 +1,633 @@
# Спек A — Биллинг v2: единый ₽-баланс + унификация tariff_plans
**Дата:** 2026-05-23
**Статус:** Design (awaiting user review)
**Автор:** Claude Opus 4.7 (под руководством заказчика)
**Брейнсторм:** сессия 23.05.2026
**Триггер:** «баланс только в рублях и перевести его в лиды в соответствии с тарифом, клиент видит и то и то» + аудит раздела «Биллинг» с 19 находками.
**Часть серии из 3 спеков:**
- **Спек A (этот)** — балансовая модель + аудит UI.
- Спек B — дубли (`DuplicateDetector` ↔ кросс-месячные кейсы).
- Спек C — preflight баланса + остановка всех проектов + пересчёт заказа поставщику + VTB-эквайринг.
---
## §1. Контекст и проблема
### §1.1 Текущая модель
Сейчас у тенанта **два баланса** ([db/schema.sql](../../../db/schema.sql) таблица `tenants`):
- `balance_leads` (INTEGER) — предоплаченные лиды поштучно.
- `balance_rub` (DECIMAL) — рублёвый баланс.
При доставке лида ([LedgerService::chargeForDelivery](../../../app/app/Services/Billing/LedgerService.php)):
1. Подбирается ступень из `pricing_tiers` (7 ступеней объёмного тарифа).
2. Если `balance_leads >= 1` → списываем 1 лид, цена `lead_charges.price_per_lead_kopecks=0`, `charge_source='prepaid'`.
3. Иначе — списываем рубли по цене ступени, `charge_source='rub'`.
Параллельно в `tariff_plans` есть колонки `price_per_lead`, `price_monthly`, `included_leads`, `trial_bonus_leads`, `billing_model` — второе понятие «цены за лид» и «включённых лидов», которое не используется в горячем пути (`LedgerService` смотрит только `pricing_tiers`), но висит в схеме и читается из API.
### §1.2 Проблемы
1. Клиенту трудно понять «сколько лидов у меня хватит» — два кошелька с разными правилами трат.
2. Концепция «предоплаченных лидов» (`balance_leads`) дублирует ту же ценность, что и `balance_rub`, но в другой валюте.
3. `tariff_plans.price_per_lead``pricing_tiers.price_per_lead_kopecks` — конфликт источников истины.
4. UI раздела «Биллинг» содержит 19 формальных находок (см. §7).
5. Концепция «включённых лидов» (`included_leads`) при подписочной модели (`billing_model='monthly'`/`'hybrid'`) — мёртвый код.
### §1.3 Триггер
Заказчик 23.05.2026: «**баланс только в рублях и перевести его в лиды в соответствии с тарифом, клиент видит и то и то**». Дальше через брейнсторм согласован Approach 3 — «Чистый разрез + унификация tariff_plans».
---
## §2. Решение
**Подход:** единый ₽-баланс, лиды — деривативом через pure-сервис, `tariff_plans` ужимается до «название и фичи».
### §2.1 Ключевые тезисы
1. **Единый ₽-баланс.** Колонка `tenants.balance_leads` удаляется. Существующие ненулевые остатки конвертируются в `balance_rub` по цене ступени 1 (консервативно, в пользу клиента) одноразовой artisan-командой.
2. **Лиды — деривативом** через pure-сервис `BalanceToLeadsConverter`. Точный расчёт по ступеням: сколько лидов клиент реально получит при текущем балансе, учитывая уже доставленные за месяц и пересечения ступеней.
3. **`tariff_plans` — только название и фичи.** Колонки `price_per_lead`, `price_monthly`, `included_leads`, `trial_bonus_leads`, `billing_model` удаляются. Все цены — только из `pricing_tiers`.
4. **Никаких возвратов** (`refund`). Соответствующий таб/фильтр удаляются. (Если бизнес-нужда подтвердится — отдельный спек.)
5. **Все P0/P1/P2 находки реестра** (§7) закрываются в рамках этого спека.
### §2.2 Что НЕ делаем (явно — out of scope)
- VTB-эквайринг и реальная оплата → **спек C**.
- Auto-stop всех проектов клиента при нехватке баланса + пересчёт заказа у поставщика → **спек C**.
- Дубли (`DuplicateDetector` 24h окно, кросс-месячные кейсы) → **спек B**.
- Сверка с поставщиком CSV (`CsvReconcileJob`) — не трогаем.
- `SupplierQuotaAllocator::computeOrder` — не трогаем.
- Возвраты (`refund`) — не реализуем.
---
## §3. Архитектура
### §3.1 Карта изменений
| Слой | Что |
|---|---|
| **БД** | `tenants` (DROP `balance_leads`), `tariff_plans` (DROP 5 колонок), `balance_transactions` (новый `type='migration'`), `lead_charges` (без изменений в схеме) |
| **Бэк-сервисы** | `LedgerService` (упрощается), `BillingTopupService` (без изменений), **новый** `BalanceToLeadsConverter` (pure) |
| **Бэк-контроллеры** | `BillingController` (wallet + transactions), `TenantChargesController` (export), `AdminPricingTiersController` (bcmul fix) |
| **Бэк-команды** | **новая** `BillingMigrateLeadsToRubCommand` (artisan, идемпотентная) |
| **Фронт-страница** | `BillingView`, `views/billing/ChargesTab` |
| **Фронт-компоненты** | `BalanceCard`, `TransactionsTable`, `InvoicesTable`, `TopupDialog` (минимально) |
| **Новый UI** | `TierPricesPanel` (7-ступенчатая таблица с подсветкой текущей, сворачиваемая) |
| **Seeders** | `DemoSeeder`, `TenantSeeder` (если ссылаются на удаляемые поля) |
| **Тесты** | Pest +3 новых файла, ~6 обновляемых; Vitest +1, ~4 обновляемых; Histoire +1, ~2 обновляемых |
### §3.2 Изменения схемы БД
#### §3.2.1 Phase 1 — data migration (artisan-команда)
Команда `php artisan billing:migrate-leads-to-rub`:
```
ДЛЯ КАЖДОГО tenant С balance_leads > 0:
В транзакции с lockForUpdate(tenant):
1. Если balance_leads <= 0 → no-op (идемпотентность).
2. migrated_kopecks := balance_leads × pricing_tiers[tier_no=1, активная на сегодня].price_per_lead_kopecks
migrated_rub := bcdiv(migrated_kopecks, '100', 2)
3. new_balance_rub := bcadd(balance_rub, migrated_rub, 2)
4. UPDATE tenants SET balance_rub = new_balance_rub, balance_leads = 0 WHERE id = tenant.id
5. INSERT balance_transactions(
type = 'migration',
amount_leads = -balance_leads,
amount_rub = '+' || migrated_rub,
balance_leads_after = 0,
balance_rub_after = new_balance_rub,
description = 'Конвертация предоплаченных лидов в ₽ (миграция модели биллинга)',
created_at = now()
)
```
Свойства:
- **Идемпотентна:** повторный запуск — no-op (проверка `balance_leads > 0`).
- **Аудит:** одна `balance_transactions(type='migration')` на тенанта — единственный пейпер-трейл.
- **Защита:** lockForUpdate против параллельных списаний/пополнений.
#### §3.2.2 Phase 2 — schema cleanup (отдельный коммит, **после кодовой части в проде**)
Миграция Laravel:
```sql
ALTER TABLE tenants DROP COLUMN balance_leads;
ALTER TABLE tariff_plans
DROP COLUMN price_per_lead,
DROP COLUMN price_monthly,
DROP COLUMN included_leads,
DROP COLUMN trial_bonus_leads,
DROP COLUMN billing_model;
-- balance_transactions.amount_leads — остаётся nullable INT навсегда (история).
-- lead_charges.charge_source + chk_lead_charges_prepaid_zero_price — остаются (история).
-- pricing_tiers — без изменений.
-- balance_transactions hash-chain триггеры — не трогаем.
```
После Phase 2: `tariff_plans` содержит только `id, code, name, description, features (jsonb), limits (jsonb), is_active, is_public, sort_order, created_at, updated_at`. Превращается из «тарифного плана» в «пакет фич/лимитов».
#### §3.2.3 Новые константы
- `BalanceTransaction::TYPE_MIGRATION = 'migration'` (добавляем).
- `BalanceTransaction::TYPE_REFUND`**не вводим** (возвратов нет в этом спеке).
### §3.3 Изменения бэка
#### §3.3.1 Новый pure-сервис `BalanceToLeadsConverter`
Файл: `app/app/Services/Billing/BalanceToLeadsConverter.php`.
Сигнатура:
```php
final class BalanceToLeadsConverter
{
/**
* @param string $balanceRub DECIMAL-строка («5000.00»), bcmath
* @param int $deliveredInMonth tenants.delivered_in_month
* @param Collection<int, PricingTier> $activeTiers
* @return array{
* leads: int,
* breakdown: list<array{tier_no:int, leads:int, price_rub:string}>,
* current_tier: array{no:int, price_rub:string, leads_left_in_tier:int}|null,
* next_tier: array{no:int, price_rub:string, leads_in_tier:int}|null
* }
*/
public function convert(string $balanceRub, int $deliveredInMonth, Collection $activeTiers): array;
}
```
Алгоритм (псевдокод):
```
balance_kopecks := bcmul(balanceRub, '100', 0) # string-int
sorted := tiers.sortBy('tier_no').values()
total_leads := 0
breakdown := []
cumulative := 0 # сколько лидов покрыто пройденными ступенями (для определения «вы здесь»)
current_tier := null
next_tier := null
ДЛЯ tier В sorted:
tier_start := cumulative + 1
tier_cap := (tier.leads_in_tier === null) ? INF : tier.leads_in_tier
tier_end := cumulative + tier_cap
# сколько слотов в этой ступени ещё не «съедено» уже доставленными
slots_left_in_tier := max(0, tier_end - max(tier_start - 1, deliveredInMonth))
# «текущая ступень» — первая, где (deliveredInMonth + 1) попадает
ЕСЛИ current_tier IS null AND deliveredInMonth < tier_end:
current_tier := { no: tier.tier_no, price_rub: tier.price_rub, leads_left_in_tier: slots_left_in_tier }
ЕСЛИ slots_left_in_tier <= 0:
cumulative := tier_end
ПРОДОЛЖИТЬ
price_kopecks := tier.price_per_lead_kopecks
ЕСЛИ price_kopecks <= 0:
# бесплатная ступень (теоретически — пока не используется)
total_leads += slots_left_in_tier
breakdown.append({ tier_no, leads: slots_left_in_tier, price_rub: '0.00' })
cumulative := tier_end
ПРОДОЛЖИТЬ
# сколько лидов в этой ступени можем себе позволить
affordable_in_tier := (int) bcdiv(balance_kopecks, price_kopecks, 0)
take := min(slots_left_in_tier, affordable_in_tier)
ЕСЛИ take > 0:
total_leads += take
breakdown.append({ tier_no, leads: take, price_rub: format(price_kopecks) })
balance_kopecks := bcsub(balance_kopecks, bcmul(price_kopecks, take, 0), 0)
ЕСЛИ take < slots_left_in_tier:
# баланс кончился в этой ступени — следующей нет смысла
# next_tier остаётся null (нет смысла показывать)
ВЫЙТИ
cumulative := tier_end
ЕСЛИ tier.leads_in_tier === null: ВЫЙТИ # «всё свыше»
# next_tier — следующая после current_tier
next_idx := sorted.findIndex(t => t.tier_no > current_tier.no)
ЕСЛИ next_idx !== -1:
next_tier := { no: sorted[next_idx].tier_no, price_rub, leads_in_tier: sorted[next_idx].leads_in_tier }
ВЕРНУТЬ { leads: total_leads, breakdown, current_tier, next_tier }
```
Деньги — bcmath, без PHP float. Pure (без БД-обращений). Тестируется изолированно.
#### §3.3.2 `LedgerService::chargeForDelivery` (упрощённый)
Удаляется dual-balance ветвление. Метод ужимается до:
```php
public function chargeForDelivery(Tenant $lockedTenant, Deal $deal, ?SupplierLead $lead = null): ChargeResult
{
$activeTiers = $this->tiers->activeAt(Carbon::now('Europe/Moscow'));
$tier = $this->resolver->resolveForCount($activeTiers, ($lockedTenant->delivered_in_month ?? 0) + 1);
$priceKopecks = (int) $tier->price_per_lead_kopecks;
// bcmath check: balance_rub × 100 >= priceKopecks
$balanceKopecks = bcmul((string) $lockedTenant->balance_rub, '100', 0);
if (bccomp($balanceKopecks, (string) $priceKopecks, 0) < 0) {
throw new InsufficientBalanceException(
priceKopecks: $priceKopecks,
balanceRub: (string) $lockedTenant->balance_rub,
);
}
$amountRub = bcdiv((string) $priceKopecks, '100', 2);
$newBalanceRub = bcsub((string) $lockedTenant->balance_rub, $amountRub, 2);
DB::table('tenants')->where('id', $lockedTenant->id)->update(['balance_rub' => $newBalanceRub]);
$lockedTenant->increment('delivered_in_month', 1);
$lockedTenant->refresh();
LeadCharge::create([
'tenant_id' => $lockedTenant->id,
'deal_id' => $deal->id,
'deal_received_at' => $deal->received_at,
'tier_no' => $tier->tier_no,
'price_per_lead_kopecks' => $priceKopecks,
'charge_source' => 'rub', // всегда
'charged_at' => now(),
'created_at' => now(),
]);
BalanceTransaction::create([
'tenant_id' => $lockedTenant->id,
'type' => BalanceTransaction::TYPE_LEAD_CHARGE,
'amount_leads' => null, // история - больше не пишем
'amount_rub' => '-' . $amountRub,
'balance_leads_after' => null,
'balance_rub_after' => (string) $lockedTenant->balance_rub,
'related_type' => Deal::class,
'related_id' => $deal->id,
'created_at' => now(),
]);
// supplier_lead_costs - без изменений
if ($lead !== null) {
$supplierId = $this->resolveSupplierId($lead);
if ($supplierId !== null) {
$supplier = Supplier::findOrFail($supplierId);
DB::table('supplier_lead_costs')->insert([
'deal_id' => $deal->id,
'received_at' => $deal->received_at,
'supplier_id' => $supplierId,
'cost_rub' => $supplier->cost_rub,
'created_at' => now(),
]);
}
}
return new ChargeResult('rub', $tier, $priceKopecks);
}
```
Удаляется:
- Приватный метод `decideSource()`.
- Поле `ChargeResult::$source` (или всегда `'rub'`).
- Параметр `InsufficientBalanceException::$balanceLeads`.
#### §3.3.3 `BillingController::wallet`
Новая структура ответа:
```json
{
"balance_rub": "5000.00",
"affordable_leads": 46,
"current_tier": { "no": 1, "price_rub": "120.00", "leads_left_in_tier": 20 },
"next_tier": { "no": 2, "price_rub": "100.00", "leads_in_tier": 100 },
"delivered_in_month": 30,
"runway_days": 12,
"tiers_preview": [
{ "tier_no": 1, "leads_in_tier": 50, "price_rub": "120.00" },
{ "tier_no": 2, "leads_in_tier": 100, "price_rub": "100.00" },
...
{ "tier_no": 7, "leads_in_tier": null, "price_rub": "60.00" }
],
"tariff": { "code": "...", "name": "...", "features": [...] }
}
```
`runway_days` пересчитывается как `affordable_leads / средний_лидов_в_день_за_30дн`. Если средняя = 0 → `null`. Если `affordable_leads = 0``0`. Одна формула для всего экрана.
`tariff` — без `price_monthly`, `billing_model`, `included_leads` (поля удалены).
#### §3.3.4 `BillingController::transactions`
Удалить фильтр `refund` из validation:
```diff
- if (is_string($type) && in_array($type, ['topup', 'lead_charge', 'refund'], true)) {
+ if (is_string($type) && in_array($type, ['topup', 'lead_charge', 'migration'], true)) {
```
#### §3.3.5 `AdminPricingTiersController::store`
```diff
- 'tiers.*.price_rub' => ['required', 'numeric', 'min:0'],
+ 'tiers.*.price_rub' => ['required', 'string', 'regex:/^\d+(\.\d{1,2})?$/'],
- 'price_per_lead_kopecks' => (int) round(((float) $tier['price_rub']) * 100),
+ 'price_per_lead_kopecks' => (int) bcmul((string) $tier['price_rub'], '100', 0),
```
#### §3.3.6 `TenantChargesController::export`
Заполняем колонку `balance_rub_after` через JOIN к `balance_transactions`:
```sql
JOIN balance_transactions bt ON bt.related_type = 'App\Models\Deal'
AND bt.related_id = lead_charges.deal_id
AND bt.tenant_id = lead_charges.tenant_id
```
#### §3.3.7 Seeders cleanup
Перед миграцией `grep -r 'balance_leads\|trial_bonus_leads\|included_leads\|billing_model\|price_per_lead\|price_monthly' app/database/seeders/` — заменить все ссылки. Бонусные лиды при подключении тарифа выдаются как ₽ через `BillingTopupService::topup($tenantId, $startBonusRub, null)` с описанием «Стартовый бонус».
### §3.4 Изменения фронта
#### §3.4.1 Типы (`app/resources/js/api/billing.ts`)
```typescript
export interface Wallet {
balance_rub: string
affordable_leads: number
current_tier: { no: number; price_rub: string; leads_left_in_tier: number }
next_tier: { no: number; price_rub: string; leads_in_tier: number } | null
delivered_in_month: number
runway_days: number | null
tiers_preview: Array<{ tier_no: number; leads_in_tier: number | null; price_rub: string }>
tariff: { code: string; name: string; features: string[] } | null
}
export interface BillingTransaction {
id: number
code: string
type: 'topup' | 'lead_charge' | 'migration' // 'refund' удалён
description: string | null
amount_rub: string
amount_leads: number | null // история, может быть null
balance_rub_after: string
display_amount_rub: string // новое: всегда ₽-эквивалент (для исторических prepaid)
created_at: string
}
```
#### §3.4.2 `BillingView.vue`
- Шапка `page-stats`: удалить «N лидов запас». Остаётся «`X` кошелёк · хватит на `Y` дн.» (если `runway_days` не null).
- Под `BalanceCard` — новый блок `TierPricesPanel` (см. §3.4.6), перед `TransactionsTable`.
#### §3.4.3 `BalanceCard.vue`
3 карточки:
| # | Заголовок | Контент |
|---|---|---|
| 1 | «Кошелёк ₽» (тёмная) | `balanceRub ₽` + мелким «мин. пополнение 100 ₽» (удалить «округление вниз ₽→лиды») + кнопка «Пополнить» + disabled «Автопополнение» |
| 2 | «**≈ N лидов**» | `affordable_leads` крупно + tooltip «Точный расчёт по текущим ценам. Меняется при переходе ступеней.» + sub-line «сейчас по `current_tier.price_rub` ₽/лид» |
| 3 | «Что входит» | `tariff.name` + список `tariff.features` (галочки). Без `price_monthly`. Кнопка «Сменить тариф» disabled остаётся. |
Удалить:
- «Баланс лидов (ГЦК)» текст.
- Аббревиатуру «(ГЦК)».
- Текст «округление вниз ₽→лиды».
- Префикс `tariff_price` («₽/мес»).
#### §3.4.4 `TransactionsTable.vue`
- Массив `TABS` — удалить пункт `{ id: 'refund', ... }`.
- Функция `txAmountText` — переписать: всегда выводит ₽-эквивалент через `display_amount_rub` (бэк отдаёт уже посчитанный).
- `formatWhen` — добавить год: `{ year: '2-digit', day: '2-digit', month: '2-digit', hour, minute }` → «23.05.26, 14:30».
#### §3.4.5 `InvoicesTable.vue`
- Сумма с «₽»: `formatPlain(Number(inv.amount_total)) + ' ₽'`.
- Empty-state без изменений («Счета появятся после первой оплаты»).
#### §3.4.6 `ChargesTab.vue`
- Удалить `v-select` «Источник» (`source` ref, `sources` массив).
- Удалить колонку «Источник» из `headers`.
- Колонка «Цена»: для исторических строк с `price_per_lead_kopecks === 0` (prepaid) — серое «0 ₽ (из бесплатного)» с tooltip «До перехода на новую модель эти лиды списывались из бесплатного остатка».
- POST → GET для экспорта (находка #13) — отложено.
#### §3.4.7 `TopupDialog.vue`
В этом спеке **не трогаем** (VTB перекроит — спек C). Минимум 100₽ остаётся.
#### §3.4.8 Новый `TierPricesPanel.vue`
Свёрнутый по умолчанию `<v-expansion-panel>` с заголовком «Цены за лид (7 ступеней)». Внутри — таблица 7 строк:
| Ступень | Диапазон | Цена |
|---|---|---|
| 1 | 150 лидов | 120 ₽ |
| 2 | 51150 лидов | 100 ₽ |
| ... | ... | ... |
| 7 | 1501+ | 60 ₽ |
С подсветкой (бордер + чип «вы здесь») текущей ступени из `current_tier.no`. Данные — из `wallet.tiers_preview` (один API-запрос, не два).
### §3.5 Тесты
#### §3.5.1 Pest (новые)
- `Tests/Unit/Services/Billing/BalanceToLeadsConverterTest.php` — ≥8 кейсов (пустой баланс, одна ступень, переход ступеней, последняя `NULL`-ступень, `delivered_in_month` пропуск, граничные копейки, bcmath-точность, неактивные ступени).
- `Tests/Feature/Billing/MigrationLeadsToRubTest.php` — конвертация по tier 1, INSERT `balance_transactions(type='migration')`, идемпотентность, lockForUpdate.
- `Tests/Feature/Billing/WalletApiTest.php``/api/billing/wallet` отдаёт `affordable_leads`, `current_tier`, `next_tier`, `tiers_preview`, `tariff` без удалённых полей.
#### §3.5.2 Pest (обновляемые)
- `LedgerServiceTest` — удалить кейсы prepaid-ветки, оставить только rub.
- `BillingControllerTest::transactions` — убрать кейс `type=refund`.
- `AdminPricingTiersControllerTest` — кейс «цена 10.10 → 1010 копеек» через bcmul.
- `TenantChargesControllerTest::export` — ассертить `balance_rub_after` заполнен.
#### §3.5.3 Pest (удаляемые)
- Все кейсы с `balance_leads--` или `charge_source='prepaid'` для **новых** сделок.
#### §3.5.4 Vitest
- `BalanceCard.spec.ts` — обновить (≈ N лидов, tooltip, без «(ГЦК)»).
- `TransactionsTable.spec.ts` — без таба «Возвраты», конвертация через `display_amount_rub`.
- `ChargesTab.spec.ts` — без фильтра/колонки «Источник».
- `InvoicesTable.spec.ts` — формат суммы с «₽».
- **Новый** `TierPricesPanel.spec.ts` — 7 ступеней рендерятся, текущая подсвечена.
- `BillingView.spec.ts` — шапка без «лидов запас», `TierPricesPanel` свёрнут по умолчанию.
#### §3.5.5 Histoire
- `BillingView.story.vue`, `BalanceCard.story.vue` — обновить fixture'ы.
- **Новый** `TierPricesPanel.story.vue` — 3 вариации (на ступени 1, 3, 7).
#### §3.5.6 Larastan / type-check
- Удалить `Tenant::balance_leads` свойство (PHPDoc + `$casts`).
- vue-tsc после изменения `Wallet`-интерфейса найдёт все потребители — поправить точечно.
---
## §4. Миграция и релиз
### §4.1 Двухфазное развёртывание (критично)
#### Фаза A — код + data migration (PR #1)
1. Все code-side изменения (LedgerService, контроллеры, фронт, тесты, конвертер).
2. Новая artisan-команда `php artisan billing:migrate-leads-to-rub`.
3. **Колонка `balance_leads` остаётся в БД** — код её больше не читает/пишет, но физически на месте (страховка от мгновенного rollback).
4. Прогон на проде:
- бэкап БД (`pg_dump`),
- деплой кода,
- `php artisan billing:migrate-leads-to-rub`,
- smoke-тесты на 2 demo тенантах (`/api/billing/wallet`, доставка тестового лида),
- 24-72 ч наблюдения через `balance_transactions(type='migration')` audit-log.
#### Фаза B — schema cleanup (PR #2, через 1-3 дня после Фазы A в проде)
1. Grep-проверка: `grep -r 'balance_leads\|price_per_lead\|price_monthly\|included_leads\|trial_bonus_leads\|billing_model' app/` (исключая `lead_charges.price_per_lead_kopecks` — другое поле).
2. Миграция Laravel `ALTER TABLE` (§3.2.2).
3. Деплой.
**Rollback Фазы A:** `balance_leads` ещё в БД → обратный SQL по `balance_transactions.amount_leads` для строк `type='migration'`. Поэтому Фаза B — отдельный PR.
### §4.2 Регрессионные критерии (`/regression full` перед merge каждой фазы)
- Pest --parallel зелёный (целевое: +20-30 новых ассертов).
- Vitest зелёный (+10-15 новых ассертов).
- Larastan 0 ошибок.
- Vite build OK.
- Histoire build OK.
- Pa11y `/billing` — 0 violations.
- gitleaks 0, lychee 0 broken.
### §4.3 Контракты и инварианты
- **bcmath** для всех мутаций `balance_rub` (никогда PHP float).
- **append-only** `balance_transactions` и `lead_charges` — hash-chain триггеры в БД не трогаем.
- **Никогда** `balance_rub < 0``InsufficientBalanceException` перед мутацией.
- **delivered_in_month** — единственный счётчик «лидов в этом месяце», обнуляется `ResetMonthlyCountersCommand` 1-го числа месяца.
---
## §5. Алгоритм конвертации `BalanceToLeadsConverter::convert` — рабочий пример
**Вход:**
- `balanceRub = '5000.00'`
- `deliveredInMonth = 30`
- `tiers`:
- tier 1: leads_in_tier=50, price=120₽ (12000 коп)
- tier 2: leads_in_tier=100, price=100₽ (10000 коп)
- tier 3: leads_in_tier=200, price=80₽ (8000 коп)
- ...
- tier 7: leads_in_tier=NULL, price=60₽ (6000 коп)
**Прогон:**
- balance_kopecks = 500 000
- **tier 1:** tier_start=1, tier_end=50, slots_left = 50max(0, 30) = 20.
- current_tier := { no:1, price:'120.00', leads_left:20 }
- affordable_in_tier = floor(500000/12000) = 41 → take = min(20, 41) = 20
- total = 20; balance_kopecks = 500000 20×12000 = 260000
- take == slots_left → продолжаем; cumulative = 50.
- **tier 2:** tier_start=51, tier_end=150, slots_left = 150max(50, 30) = 100.
- affordable = floor(260000/10000) = 26 → take = min(100, 26) = 26
- total = 46; balance_kopecks = 260000 26×10000 = 0
- take < slots_left → выход.
- **Итог:** `{ leads: 46, breakdown: [{1, 20, '120.00'}, {2, 26, '100.00'}], current_tier: {1, '120.00', 20}, next_tier: {2, '100.00', 100} }`
UI: «**≈ 46 лидов**» крупно. Tooltip: «20 лидов по 120 ₽ + 26 по 100 ₽».
---
## §6. Реестр находок «Биллинг» (закрывается в этом спеке)
**P0 — критичные:**
- **№1.** «Баланс лидов (ГЦК)» карточка → «≈ N лидов» с tooltip. Убрать «(ГЦК)».
- **№2.** Дубль `balance_leads` в шапке `BillingView` — удалить из `page-stats`.
- **№3.** Таб «Возвраты» в `TransactionsTable` + фильтр `refund` в `BillingController::transactions` — удалить (без возвратов в этом спеке).
- **№4.** Чип `prepaid` и фильтр «Источник» в `ChargesTab` — удалить (исторические строки помечаются tooltip'ом).
- **№5.** `InvoicesTable.amount_total` без «₽» — добавить суффикс.
**P1 — важные:**
- **№6.** `BillingController::runwayDays` — переписать на `affordable_leads / средний_лидов_в_день` (одна формула с шапкой).
- **№7.** `AdminPricingTiersController::store` — float → bcmul + `regex:/^\d+(\.\d{1,2})?$/` validation.
- **№8.** «Округление вниз ₽→лиды» в `BalanceCard` — удалить (после конвертера термин не нужен).
- **№9.** `TopupDialog` алерт «Платёжный шлюз...» — оставить как есть (VTB перекроит в спеке C).
- **№10.** `TopupDialog.PRESETS` — синхронизировать с VTB после спека C; в этом спеке не трогаем.
- **№11.** `txAmountText` «− 1 лид.» — переписать через `display_amount_rub` от бэка.
**P2 — нюансы:**
- **№12.** `TransactionsTable.formatWhen` — добавить год.
- **№13.** `ChargesTab.exportCsv` POST → GET — отложено (не блокер).
- **№14.** `TenantChargesController::export.balance_rub_after` пустой — заполнить через JOIN.
- **№15.** `InvoicesTable.amount_total → Number()` precision — отложено (под VTB).
**Связанные (вне этого спека):**
- **№16.** `DuplicateDetector.WINDOW_HOURS = 24` → спек B.
- **№17.** `SupplierQuotaAllocator::computeOrder` без учёта баланса → спек C.
- **№18.** `RouteSupplierLeadJob::handleInsufficientBalance` останавливает один проект → спек C.
- **№19.** `BillingTopupService` зачисляет сразу → спек C (VTB).
---
## §7. Открытые вопросы
Все решения согласованы в брейнсторме 23.05.2026:
- Вариант 3 (с унификацией `tariff_plans`) — выбран.
- Точный расчёт по ступеням — выбран (не «по текущей ступени», не «по ступени 1»).
- `balance_leads` удаляется полностью (не остаётся как «подарочный остаток»).
- Возвраты — не реализуем.
- Конвертер — pure, на бэке, один движок для шапки + карточки + runway.
- `TierPricesPanel` — свёрнут по умолчанию.
- `tiers_preview` встроен в `/api/billing/wallet` (один запрос).
- Релиз — двухфазный (код+data → ALTER TABLE).
- Миграция данных — artisan-команда, идемпотентная.
---
## §8. Связанные документы
- Брейнсторм-сессия: 23.05.2026 (transcript не сохранён отдельно — содержание этого спека отражает все решения).
- Исходный дизайн биллинга: [docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md](2026-05-11-plan4-billing-csv-admin-design.md).
- Спек B (дубли) — будет создан после Спека A.
- Спек C (preflight + VTB) — будет создан после Спека B.
---
## §9. Следующие шаги
1. **Пользовательское ревью** этого спека.
2. После одобрения — переход к `superpowers:writing-plans` для генерации детального плана реализации (`docs/superpowers/plans/2026-05-23-billing-v2-spec-a-balance-rub-plan.md`).
3. Реализация по плану в отдельной ветке (предположительно `feat/billing-v2-spec-a`).
4. Релиз Phase A → наблюдение → релиз Phase B.
5. Переход к брейнсторму Спека C.