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:
@@ -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 | 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.
|
||||
Reference in New Issue
Block a user