# Спек 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 | 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.
