diff --git a/cspell-words.txt b/cspell-words.txt index 51afbaf0..fbdd7465 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -1658,3 +1658,9 @@ Vite сериализуется флагует клиентно + +# Billing v2 Spec A (23.05.2026) +vtb +брейнсторм +подписочной +брейнсторму diff --git a/docs/superpowers/specs/2026-05-23-billing-v2-spec-a-balance-rub-design.md b/docs/superpowers/specs/2026-05-23-billing-v2-spec-a-balance-rub-design.md new file mode 100644 index 00000000..1459c09d --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-billing-v2-spec-a-balance-rub-design.md @@ -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 $activeTiers + * @return array{ + * leads: int, + * breakdown: list, + * 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` + +Свёрнутый по умолчанию `` с заголовком «Цены за лид (7 ступеней)». Внутри — таблица 7 строк: + +| Ступень | Диапазон | Цена | +|---|---|---| +| 1 | 1–50 лидов | 120 ₽ | +| 2 | 51–150 лидов | 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 = 50−max(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 = 150−max(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.