diff --git a/cspell-words.txt b/cspell-words.txt index fbdd7465..e3c41949 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -1664,3 +1664,4 @@ vtb брейнсторм подписочной брейнсторму +ревьюю diff --git a/docs/superpowers/plans/2026-05-23-billing-v2-spec-a-balance-rub-plan.md b/docs/superpowers/plans/2026-05-23-billing-v2-spec-a-balance-rub-plan.md new file mode 100644 index 00000000..1c93204d --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-billing-v2-spec-a-balance-rub-plan.md @@ -0,0 +1,2361 @@ +# Billing v2 Spec A Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Subagent model constraint (Pravila §15.1):** Sonnet or Opus only — NOT Haiku — for any task that includes a git commit step. Naïve Haiku subagents have hijacked parallel-session branches in past sprints. + +**Goal:** Реализовать Спек A серии «Биллинг v2»: убрать `tenants.balance_leads`, упразднить ценовые колонки в `tariff_plans`, добавить pure-сервис `BalanceToLeadsConverter` (точный расчёт ₽→лиды по ступеням), закрыть 15 находок аудита Биллинга в коде и UI. + +**Architecture:** Двухфазный релиз. Phase A (PR #1) — все code-side изменения + идемпотентная artisan-команда миграции данных, колонки остаются в БД как страховка. Phase B (PR #2, через ≥72ч в проде) — `ALTER TABLE ... DROP COLUMN`. Все мутации денег — через bcmath, без PHP float. Новый pure-сервис `BalanceToLeadsConverter` — единственный движок «₽ → лиды» (используется в `wallet`, `runwayDays`, новом UI-блоке «Цены за лид»). + +**Tech Stack:** PHP 8.3 / Laravel 13 / Pest 4 / Vue 3.5 / Vuetify 3.12 / Vitest 4 / Histoire / PostgreSQL 16 / bcmath / Memurai. + +**Spec:** [docs/superpowers/specs/2026-05-23-billing-v2-spec-a-balance-rub-design.md](../specs/2026-05-23-billing-v2-spec-a-balance-rub-design.md) + +--- + +## Phase 0: Setup & branching + +### Task 0.1: Create isolated worktree from origin/main + +**Files:** + +- N/A (git operation) + +- [ ] **Step 1: Verify clean baseline of working repo** + +Run: `git status --short && git rev-parse --abbrev-ref HEAD` +Expected: any branch — note current branch and uncommitted state. + +- [ ] **Step 2: Fetch origin** + +Run: `git fetch origin` +Expected: fetch completes, `origin/main` updated. + +- [ ] **Step 3: Create worktree from origin/main** + +Run: `git worktree add -B feat/billing-v2-spec-a ../worktree-billing-v2-spec-a origin/main` +Expected: new worktree created at `../worktree-billing-v2-spec-a` checked out on branch `feat/billing-v2-spec-a` from latest `origin/main`. + +- [ ] **Step 4: Verify worktree is clean and up-to-date** + +Run from worktree dir: + +``` +cd ../worktree-billing-v2-spec-a +git status --short +git log -1 --oneline +``` + +Expected: empty status; HEAD matches latest `origin/main` commit. + +- [ ] **Step 5: Copy gitignored runtime files into worktree** + +Worktree dir doesn't have `.env`, `storage/`, `bin/lychee.exe`, etc. Copy from main checkout: + +``` +cp ..//app/.env app/.env +cp -r ..//app/storage app/storage +cp ..//bin/* bin/ +``` + +(Quirk from memory: Sprint 4 / project_sprint4_progress — fresh worktree needs gitignored files copied.) + +- [ ] **Step 6: Sanity-check worktree dev stack** + +Run from worktree dir: + +``` +cd app && php artisan migrate:status | head -20 +``` + +Expected: prints existing migrations status — confirms DB connection works. + +No commit in this task — worktree is a setup operation. + +--- + +## Phase A: Code + Data Migration + +### Task A.1: Add `TYPE_MIGRATION` constant + extend CHECK constraint + +**Files:** + +- Modify: `app/app/Models/BalanceTransaction.php` (around line 29-33) +- Create: `app/database/migrations/2026_05_23_100001_extend_balance_transactions_type_for_migration.php` +- Test: `app/tests/Unit/Models/BalanceTransactionMigrationTypeTest.php` + +- [ ] **Step 1: Write the failing test** + +Create `app/tests/Unit/Models/BalanceTransactionMigrationTypeTest.php`: + +```php +create(['balance_rub' => '1000.00']); + + $tx = BalanceTransaction::create([ + 'tenant_id' => $tenant->id, + 'type' => BalanceTransaction::TYPE_MIGRATION, + 'amount_leads' => -5, + 'amount_rub' => '600.00', + 'balance_leads_after' => 0, + 'balance_rub_after' => '1600.00', + 'description' => 'test migration', + ]); + + expect($tx->type)->toBe('migration'); +}); + +it('exposes TYPE_MIGRATION constant', function () { + expect(BalanceTransaction::TYPE_MIGRATION)->toBe('migration'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run from `app/`: `./vendor/bin/pest tests/Unit/Models/BalanceTransactionMigrationTypeTest.php` +Expected: FAIL — either `BalanceTransaction::TYPE_MIGRATION` undefined or PostgreSQL rejects `type='migration'` (CHECK violation). + +- [ ] **Step 3: Add the constant to the model** + +In `app/app/Models/BalanceTransaction.php`, after `TYPE_REFUND`: + +```php +public const TYPE_REFUND = 'refund'; +public const TYPE_MIGRATION = 'migration'; +``` + +- [ ] **Step 4: Create migration to extend CHECK constraint** + +Create `app/database/migrations/2026_05_23_100001_extend_balance_transactions_type_for_migration.php`: + +```php +tier_no = $no; + $t->leads_in_tier = $cap; + $t->price_per_lead_kopecks = $priceKopecks; + $t->is_active = true; + return $t; +} + +beforeEach(function () { + $this->tiers = new Collection([ + buildTier(1, 50, 12000), + buildTier(2, 100, 10000), + buildTier(3, 200, 8000), + buildTier(7, null, 6000), + ]); +}); + +it('returns 0 leads when balance is zero', function () { + $result = (new BalanceToLeadsConverter())->convert('0.00', 0, $this->tiers); + expect($result['leads'])->toBe(0); +}); + +it('takes only tier 1 when balance covers only first tier', function () { + $result = (new BalanceToLeadsConverter())->convert('1200.00', 0, $this->tiers); + expect($result['leads'])->toBe(10); + expect($result['breakdown'])->toHaveCount(1); + expect($result['breakdown'][0]['tier_no'])->toBe(1); + expect($result['breakdown'][0]['leads'])->toBe(10); +}); + +it('crosses tier 1 → tier 2 with deliveredInMonth=30', function () { + // 30 already delivered, 5000 ₽ remaining + // tier 1 has 20 slots × 120₽ = 2400 ₽ → 20 leads, balance = 2600 ₽ + // tier 2 has 100 slots, 2600/100 = 26 leads → 26 leads + // total = 46 + $result = (new BalanceToLeadsConverter())->convert('5000.00', 30, $this->tiers); + expect($result['leads'])->toBe(46); + expect($result['breakdown'])->toHaveCount(2); + expect($result['breakdown'][0])->toMatchArray(['tier_no' => 1, 'leads' => 20]); + expect($result['breakdown'][1])->toMatchArray(['tier_no' => 2, 'leads' => 26]); + expect($result['current_tier']['no'])->toBe(1); + expect($result['current_tier']['leads_left_in_tier'])->toBe(20); + expect($result['next_tier']['no'])->toBe(2); +}); + +it('skips already-exhausted tier 1 when deliveredInMonth=50', function () { + $result = (new BalanceToLeadsConverter())->convert('1000.00', 50, $this->tiers); + // tier 1 exhausted; on tier 2: 1000/100 = 10 leads + expect($result['leads'])->toBe(10); + expect($result['current_tier']['no'])->toBe(2); +}); + +it('uses last tier with leads_in_tier=NULL as catch-all', function () { + $result = (new BalanceToLeadsConverter())->convert('600.00', 350, $this->tiers); + // tier 1+2+3 exhausted (350 = 50+100+200). Tier 7: 600/60 = 10 leads. + expect($result['leads'])->toBe(10); + expect($result['current_tier']['no'])->toBe(7); +}); + +it('handles exact-kopeck balance precisely (no float drift)', function () { + $result = (new BalanceToLeadsConverter())->convert('120.00', 0, $this->tiers); + expect($result['leads'])->toBe(1); +}); + +it('returns 0 when balance is less than tier 1 price', function () { + $result = (new BalanceToLeadsConverter())->convert('119.99', 0, $this->tiers); + expect($result['leads'])->toBe(0); +}); + +it('ignores inactive tiers', function () { + $inactive = buildTier(2, 100, 1); + $inactive->is_active = false; + $tiers = new Collection([buildTier(1, 50, 12000), $inactive, buildTier(7, null, 6000)]); + $result = (new BalanceToLeadsConverter())->convert('5000.00', 50, $tiers); + // tier 1 exhausted, tier 2 inactive (skipped), tier 7: 5000/60 = 83 leads + expect($result['leads'])->toBe(83); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./vendor/bin/pest tests/Unit/Services/Billing/BalanceToLeadsConverterTest.php` +Expected: FAIL — class `BalanceToLeadsConverter` does not exist. + +- [ ] **Step 3: Implement the converter** + +Create `app/app/Services/Billing/BalanceToLeadsConverter.php`: + +```php +, + * 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 $tiers): array + { + $balanceKopecks = bcmul($balanceRub, '100', 0); + $sorted = $tiers + ->filter(fn (PricingTier $t) => (bool) $t->is_active) + ->sortBy('tier_no') + ->values(); + + $totalLeads = 0; + $breakdown = []; + $cumulative = 0; + $currentTier = null; + $stopped = false; + + foreach ($sorted as $tier) { + $tierCap = $tier->leads_in_tier === null ? PHP_INT_MAX : (int) $tier->leads_in_tier; + $tierStart = $cumulative + 1; + $tierEnd = $cumulative + $tierCap; + + // «Текущая ступень» — первая, в которую попадает (deliveredInMonth + 1). + if ($currentTier === null && $deliveredInMonth < $tierEnd) { + $slotsLeft = max(0, $tierEnd - max($tierStart - 1, $deliveredInMonth)); + $currentTier = [ + 'no' => (int) $tier->tier_no, + 'price_rub' => self::kopecksToRub((int) $tier->price_per_lead_kopecks), + 'leads_left_in_tier' => $tier->leads_in_tier === null ? PHP_INT_MAX : $slotsLeft, + ]; + } + + $slotsLeftInTier = max(0, $tierEnd - max($tierStart - 1, $deliveredInMonth)); + if ($slotsLeftInTier <= 0) { + $cumulative = $tierEnd; + continue; + } + + $priceKopecks = (int) $tier->price_per_lead_kopecks; + if ($priceKopecks <= 0) { + $totalLeads += $slotsLeftInTier; + $breakdown[] = [ + 'tier_no' => (int) $tier->tier_no, + 'leads' => $slotsLeftInTier, + 'price_rub' => '0.00', + ]; + $cumulative = $tierEnd; + continue; + } + + $affordableInTier = (int) bcdiv($balanceKopecks, (string) $priceKopecks, 0); + $take = min($slotsLeftInTier, $affordableInTier); + + if ($take > 0) { + $totalLeads += $take; + $breakdown[] = [ + 'tier_no' => (int) $tier->tier_no, + 'leads' => $take, + 'price_rub' => self::kopecksToRub($priceKopecks), + ]; + $balanceKopecks = bcsub( + $balanceKopecks, + bcmul((string) $priceKopecks, (string) $take, 0), + 0 + ); + } + + if ($take < $slotsLeftInTier) { + $stopped = true; + break; + } + if ($tier->leads_in_tier === null) { + break; + } + $cumulative = $tierEnd; + } + + $nextTier = null; + if ($currentTier !== null && ! $stopped) { + foreach ($sorted as $tier) { + if ((int) $tier->tier_no > $currentTier['no']) { + $nextTier = [ + 'no' => (int) $tier->tier_no, + 'price_rub' => self::kopecksToRub((int) $tier->price_per_lead_kopecks), + 'leads_in_tier' => $tier->leads_in_tier === null ? 0 : (int) $tier->leads_in_tier, + ]; + break; + } + } + } + + return [ + 'leads' => $totalLeads, + 'breakdown' => $breakdown, + 'current_tier' => $currentTier, + 'next_tier' => $nextTier, + ]; + } + + private static function kopecksToRub(int $kopecks): string + { + return bcdiv((string) $kopecks, '100', 2); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `./vendor/bin/pest tests/Unit/Services/Billing/BalanceToLeadsConverterTest.php` +Expected: PASS (8 tests). + +- [ ] **Step 5: Commit** + +``` +git add app/app/Services/Billing/BalanceToLeadsConverter.php \ + app/tests/Unit/Services/Billing/BalanceToLeadsConverterTest.php +git commit -m "feat(billing-v2): add BalanceToLeadsConverter (pure ₽→лиды по ступеням)" +``` + +--- + +### Task A.3: Simplify `InsufficientBalanceException` (drop `balanceLeads` param) + +**Files:** + +- Modify: `app/app/Exceptions/Billing/InsufficientBalanceException.php` +- Search for callers: `app/app/Services/Billing/LedgerService.php`, `app/app/Jobs/RouteSupplierLeadJob.php` (logging) + +- [ ] **Step 1: Read the exception file** + +Read `app/app/Exceptions/Billing/InsufficientBalanceException.php` and identify the constructor signature. + +- [ ] **Step 2: Update test for the simplified exception** + +In `app/tests/Unit/Exceptions/InsufficientBalanceExceptionTest.php` (create if missing): + +```php +priceKopecks)->toBe(12000); + expect($e->balanceRub)->toBe('50.00'); + expect(property_exists($e, 'balanceLeads'))->toBeFalse(); +}); +``` + +- [ ] **Step 3: Run test to verify fail (likely fails — balanceLeads still exists)** + +Run: `./vendor/bin/pest tests/Unit/Exceptions/InsufficientBalanceExceptionTest.php` +Expected: FAIL. + +- [ ] **Step 4: Update the exception class** + +Edit `InsufficientBalanceException.php` constructor: remove `int $balanceLeads` parameter and property. + +Resulting class (approx): + +```php +final class InsufficientBalanceException extends \RuntimeException +{ + public function __construct( + public readonly int $priceKopecks, + public readonly string $balanceRub, + ) { + parent::__construct( + sprintf('Insufficient balance: %d kopecks needed, %s ₽ available', $priceKopecks, $balanceRub) + ); + } +} +``` + +- [ ] **Step 5: Update callers (logging in RouteSupplierLeadJob)** + +In `app/app/Jobs/RouteSupplierLeadJob.php::handleInsufficientBalance` — remove the `'balance_leads' => $e->balanceLeads` key from the Log::warning context array. + +- [ ] **Step 6: Run tests to verify pass** + +Run: `./vendor/bin/pest tests/Unit/Exceptions/InsufficientBalanceExceptionTest.php` +Expected: PASS. + +- [ ] **Step 7: Run full LedgerService + RouteSupplierLeadJob tests** + +Run: `./vendor/bin/pest tests/Feature/Billing tests/Feature/Supplier --filter=Ledger` +Expected: PASS (or document any failure for Task A.5/A.6 follow-up). + +- [ ] **Step 8: Commit** + +``` +git add app/app/Exceptions/Billing/InsufficientBalanceException.php \ + app/app/Jobs/RouteSupplierLeadJob.php \ + app/tests/Unit/Exceptions/InsufficientBalanceExceptionTest.php +git commit -m "refactor(billing-v2): drop balanceLeads from InsufficientBalanceException" +``` + +--- + +### Task A.4: Simplify `ChargeResult` DTO + +**Files:** + +- Modify: `app/app/Services/Billing/ChargeResult.php` + +- [ ] **Step 1: Read current ChargeResult** + +Read the file. It currently has 3 readonly properties: `$source`, `$tier`, `$priceKopecks`. + +- [ ] **Step 2: Decision** + +Keep `$source` but make it always `'rub'` (for backward log compatibility), or drop entirely. Per spec §3.3.2 — drop. Update DTO to 2 properties. + +- [ ] **Step 3: Update ChargeResult class** + +```php +final readonly class ChargeResult +{ + public function __construct( + public PricingTier $tier, + public int $priceKopecks, + ) {} +} +``` + +- [ ] **Step 4: Update callers (LedgerService::chargeForDelivery return; tests reading ->source)** + +Grep: `grep -rn '->source' app/app/Services/Billing app/tests` — replace any `->source` assertion with `->priceKopecks > 0 ? 'rub' : 'free'` equivalent OR drop the source assertion entirely. + +- [ ] **Step 5: Commit** + +``` +git add app/app/Services/Billing/ChargeResult.php +git commit -m "refactor(billing-v2): drop ChargeResult::source (always rub now)" +``` + +--- + +### Task A.5: Simplify `LedgerService::chargeForDelivery` (drop prepaid branch) + +**Files:** + +- Modify: `app/app/Services/Billing/LedgerService.php` +- Test: `app/tests/Feature/Billing/LedgerServiceTest.php` (existing) + +- [ ] **Step 1: Update the existing test — remove prepaid cases, keep only rub** + +In `LedgerServiceTest.php` (read first), find and DELETE: + +- Any `it('charges from balance_leads first when available', ...)` test. +- Any test asserting `'charge_source' => 'prepaid'`. +- Any test asserting `balance_leads` was decremented. + +KEEP and ADJUST: + +- `it('throws InsufficientBalanceException when balance_rub × 100 < priceKopecks', ...)` — make sure it doesn't set `balance_leads` in arrange step. +- `it('charges balance_rub by tier price', ...)` — confirm asserts `charge_source='rub'` and `balance_rub` decremented exactly by tier price. + +Add new test: + +```php +it('always uses charge_source=rub regardless of historic balance_leads value', function () { + /** @var Tenant $tenant */ + $tenant = Tenant::factory()->create([ + 'balance_rub' => '1000.00', + 'balance_leads' => 5, // historical leftover — must be ignored + ]); + // ... rest similar to existing rub test, but assert charge_source='rub' + // and balance_leads UNCHANGED (still 5 — we don't touch the column). +}); +``` + +- [ ] **Step 2: Run test to verify fail (current code still decrements balance_leads)** + +Run: `./vendor/bin/pest tests/Feature/Billing/LedgerServiceTest.php` +Expected: FAIL on the new test (current code decrements balance_leads if it's >= 1). + +- [ ] **Step 3: Rewrite `chargeForDelivery` per spec §4.2** + +Replace the body of `LedgerService::chargeForDelivery` (lines 46-115 currently) with the simplified version per spec: + +```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: 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(), + ]); + + if ($lead !== null) { + $supplierId = $this->resolveSupplierId($lead); + if ($supplierId !== null) { + /** @var Supplier $supplier */ + $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($tier, $priceKopecks); +} +``` + +Delete the now-unused private method `decideSource()`. + +- [ ] **Step 4: Run tests to verify pass** + +Run: `./vendor/bin/pest tests/Feature/Billing/LedgerServiceTest.php` +Expected: PASS (all green, including the «balance_leads untouched» new test). + +- [ ] **Step 5: Run downstream RouteSupplierLeadJob tests** + +Run: `./vendor/bin/pest tests/Feature/Supplier/RouteSupplierLeadJobTest.php` +Expected: PASS (auto-pause flow still works since `InsufficientBalanceException` shape stayed compatible). + +- [ ] **Step 6: Commit** + +``` +git add app/app/Services/Billing/LedgerService.php \ + app/tests/Feature/Billing/LedgerServiceTest.php +git commit -m "refactor(billing-v2): LedgerService — drop prepaid branch, always rub" +``` + +--- + +### Task A.6: Update `BillingController::wallet` response shape + +**Files:** + +- Modify: `app/app/Http/Controllers/Api/BillingController.php` +- Test: `app/tests/Feature/Api/BillingControllerWalletTest.php` (existing or new) + +- [ ] **Step 1: Write/update the wallet test** + +Update `BillingControllerWalletTest.php` to assert the new shape: + +```php +it('returns affordable_leads, current_tier, next_tier, tiers_preview', function () { + $tenant = Tenant::factory()->create([ + 'balance_rub' => '5000.00', + 'delivered_in_month' => 30, + ]); + // seed pricing_tiers: 1=50/120₽, 2=100/100₽, ..., 7=NULL/60₽ + seedDefaultTiers(); + $user = User::factory()->for($tenant)->create(); + + $resp = $this->actingAs($user)->getJson('/api/billing/wallet'); + + $resp->assertOk()->assertJsonStructure([ + 'balance_rub', + 'affordable_leads', + 'current_tier' => ['no', 'price_rub', 'leads_left_in_tier'], + 'next_tier' => ['no', 'price_rub', 'leads_in_tier'], + 'delivered_in_month', + 'runway_days', + 'tiers_preview' => [['tier_no', 'leads_in_tier', 'price_rub']], + 'tariff', + ]); + expect($resp->json('affordable_leads'))->toBe(46); + expect($resp->json('current_tier.no'))->toBe(1); + expect($resp->json('current_tier.leads_left_in_tier'))->toBe(20); +}); + +it('does NOT expose price_monthly or billing_model in tariff (Spec A unification)', function () { + // ... act + expect($resp->json('tariff'))->not->toHaveKey('price_monthly'); + expect($resp->json('tariff'))->not->toHaveKey('billing_model'); +}); +``` + +- [ ] **Step 2: Run test to verify fail** + +Run: `./vendor/bin/pest tests/Feature/Api/BillingControllerWalletTest.php` +Expected: FAIL — `affordable_leads` and `current_tier` don't exist yet. + +- [ ] **Step 3: Update `BillingController::wallet`** + +Replace the method body: + +```php +public function wallet(Request $request): JsonResponse +{ + /** @var User $user */ + $user = $request->user(); + /** @var Tenant $tenant */ + $tenant = Tenant::query()->with('tariff')->findOrFail((int) $user->tenant_id); + + $activeTiers = app(PricingTierRepository::class)->activeAt(Carbon::now('Europe/Moscow')); + $conversion = app(BalanceToLeadsConverter::class) + ->convert((string) $tenant->balance_rub, (int) ($tenant->delivered_in_month ?? 0), $activeTiers); + + $tiersPreview = $activeTiers + ->sortBy('tier_no') + ->values() + ->map(fn ($t) => [ + 'tier_no' => (int) $t->tier_no, + 'leads_in_tier' => $t->leads_in_tier === null ? null : (int) $t->leads_in_tier, + 'price_rub' => bcdiv((string) $t->price_per_lead_kopecks, '100', 2), + ]) + ->all(); + + return response()->json([ + 'balance_rub' => $tenant->balance_rub, + 'affordable_leads' => $conversion['leads'], + 'current_tier' => $conversion['current_tier'], + 'next_tier' => $conversion['next_tier'], + 'delivered_in_month' => (int) ($tenant->delivered_in_month ?? 0), + 'runway_days' => $this->runwayDays($tenant, $conversion['leads']), + 'tiers_preview' => $tiersPreview, + 'tariff' => $tenant->tariff === null ? null : [ + 'code' => $tenant->tariff->code, + 'name' => $tenant->tariff->name, + 'features' => $tenant->tariff->features ?? [], + ], + ]); +} +``` + +Note: signature of `runwayDays` changes — see Task A.7. + +- [ ] **Step 4: Add imports to BillingController** + +```php +use App\Services\Billing\BalanceToLeadsConverter; +use App\Repositories\PricingTierRepository; +use Illuminate\Support\Carbon; +``` + +- [ ] **Step 5: Run test to verify pass** + +Run: `./vendor/bin/pest tests/Feature/Api/BillingControllerWalletTest.php` +Expected: PASS. + +- [ ] **Step 6: Commit** + +``` +git add app/app/Http/Controllers/Api/BillingController.php \ + app/tests/Feature/Api/BillingControllerWalletTest.php +git commit -m "feat(billing-v2): wallet API — affordable_leads + current_tier + tiers_preview" +``` + +--- + +### Task A.7: Rewire `BillingController::runwayDays` to use converter + +**Files:** + +- Modify: `app/app/Http/Controllers/Api/BillingController.php` (runwayDays method) +- Test: extends `BillingControllerWalletTest.php` + +- [ ] **Step 1: Add test case** + +```php +it('returns runway_days based on affordable_leads / avg_per_day', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '5000.00', 'delivered_in_month' => 30]); + seedDefaultTiers(); + // seed 30 lead_charges over last 30 days → avg 1/day + LeadCharge::factory()->count(30)->for($tenant)->create([ + 'charged_at' => now()->subDays(rand(1, 30)), + ]); + $user = User::factory()->for($tenant)->create(); + + $resp = $this->actingAs($user)->getJson('/api/billing/wallet'); + // affordable_leads=46, avg_per_day=1 → runway ≈ 46 days + expect($resp->json('runway_days'))->toBe(46); +}); + +it('returns null runway_days when no charges in last 30 days', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '5000.00']); + seedDefaultTiers(); + $user = User::factory()->for($tenant)->create(); + + $resp = $this->actingAs($user)->getJson('/api/billing/wallet'); + expect($resp->json('runway_days'))->toBeNull(); +}); +``` + +- [ ] **Step 2: Run test to verify fail** + +Expected: FAIL — current runwayDays formula gives different number (it averages amount_rub from balance_transactions, not leads from lead_charges). + +- [ ] **Step 3: Rewrite `runwayDays` method** + +Replace existing `runwayDays` private method: + +```php +private function runwayDays(Tenant $tenant, int $affordableLeads): ?int +{ + if ($affordableLeads <= 0) { + return 0; + } + + $leadsLast30Days = (int) DB::table('lead_charges') + ->where('tenant_id', $tenant->id) + ->where('charged_at', '>=', now()->subDays(30)) + ->count(); + + if ($leadsLast30Days <= 0) { + return null; + } + + $avgPerDay = $leadsLast30Days / 30.0; + return max(0, (int) floor($affordableLeads / $avgPerDay)); +} +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `./vendor/bin/pest tests/Feature/Api/BillingControllerWalletTest.php` +Expected: PASS. + +- [ ] **Step 5: Commit** + +``` +git add app/app/Http/Controllers/Api/BillingController.php \ + app/tests/Feature/Api/BillingControllerWalletTest.php +git commit -m "refactor(billing-v2): runwayDays = affordable_leads ÷ avg-leads-per-day" +``` + +--- + +### Task A.8: `BillingController::transactions` — remove `refund` filter + add `display_amount_rub` + +**Files:** + +- Modify: `app/app/Http/Controllers/Api/BillingController.php` (transactions method) +- Test: `app/tests/Feature/Api/BillingControllerTransactionsTest.php` (existing or new) + +- [ ] **Step 1: Update test — assert refund filter rejected, display_amount_rub present** + +```php +it('rejects ?type=refund filter (Spec A removed refunds)', function () { + $user = User::factory()->create(); + $resp = $this->actingAs($user)->getJson('/api/billing/transactions?type=refund'); + // Filter silently ignored; or 422 if we tighten validation. Choose silent ignore per spec. + $resp->assertOk(); + // refund-type rows would not be in DB anyway since we never create them. +}); + +it('serves display_amount_rub for historic prepaid lead_charge rows', function () { + $tenant = Tenant::factory()->create(); + BalanceTransaction::create([ + 'tenant_id' => $tenant->id, + 'type' => BalanceTransaction::TYPE_LEAD_CHARGE, + 'amount_rub' => '0.00', // historic prepaid → 0 ₽ on transaction + 'amount_leads' => -1, + 'balance_rub_after' => '100.00', + 'balance_leads_after' => 4, + ]); + $user = User::factory()->for($tenant)->create(); + + $resp = $this->actingAs($user)->getJson('/api/billing/transactions'); + expect($resp->json('data.0.display_amount_rub'))->toBe('0.00'); +}); + +it('serves display_amount_rub = amount_rub for new lead_charge rows', function () { + $tenant = Tenant::factory()->create(); + BalanceTransaction::create([ + 'tenant_id' => $tenant->id, + 'type' => BalanceTransaction::TYPE_LEAD_CHARGE, + 'amount_rub' => '-120.00', + 'amount_leads' => null, + 'balance_rub_after' => '880.00', + ]); + $user = User::factory()->for($tenant)->create(); + + $resp = $this->actingAs($user)->getJson('/api/billing/transactions'); + expect($resp->json('data.0.display_amount_rub'))->toBe('-120.00'); +}); +``` + +- [ ] **Step 2: Run to verify fail** + +Expected: FAIL — `display_amount_rub` not in response. + +- [ ] **Step 3: Update `transactions` method** + +In `BillingController.php::transactions`: + +Replace filter line: + +```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)) { + $query->where('type', $type); + } +``` + +Replace data shaping: + +```php +return response()->json([ + 'data' => array_map(static function (BalanceTransaction $tx): array { + $displayAmountRub = $tx->amount_rub; + // Historic prepaid: type=lead_charge AND amount_rub == 0 (was decremented in leads). + if ($tx->type === BalanceTransaction::TYPE_LEAD_CHARGE && bccomp((string) $tx->amount_rub, '0', 2) === 0) { + $displayAmountRub = '0.00'; + } + + return [ + 'id' => $tx->id, + 'code' => 'TX-' . $tx->id, + 'type' => $tx->type, + 'description' => $tx->description, + 'amount_rub' => $tx->amount_rub, + 'amount_leads' => $tx->amount_leads, + 'balance_rub_after' => $tx->balance_rub_after, + 'display_amount_rub' => $displayAmountRub, + 'created_at' => $tx->created_at, + ]; + }, $page->items()), + 'meta' => [...] // unchanged +]); +``` + +- [ ] **Step 4: Run tests** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +``` +git add app/app/Http/Controllers/Api/BillingController.php \ + app/tests/Feature/Api/BillingControllerTransactionsTest.php +git commit -m "feat(billing-v2): transactions API — drop refund filter, add display_amount_rub" +``` + +--- + +### Task A.9: `AdminPricingTiersController::store` — bcmul + decimal validation + +**Files:** + +- Modify: `app/app/Http/Controllers/Api/AdminPricingTiersController.php` (lines ~66-104) +- Test: `app/tests/Feature/Api/AdminPricingTiersControllerTest.php` + +- [ ] **Step 1: Write the failing test for bcmath precision** + +```php +it('stores price 10.10 ₽ as exactly 1010 kopecks (no float drift)', function () { + $tiers = []; + for ($i = 1; $i <= 6; $i++) { + $tiers[] = ['tier_no' => $i, 'leads_in_tier' => 50, 'price_rub' => '10.10']; + } + $tiers[] = ['tier_no' => 7, 'leads_in_tier' => null, 'price_rub' => '10.10']; + + $resp = $this->postJson('/api/admin/pricing-tiers', ['tiers' => $tiers]); + $resp->assertCreated(); + + foreach (PricingTier::all() as $row) { + expect($row->price_per_lead_kopecks)->toBe(1010); + } +}); + +it('rejects malformed price_rub (e.g. "10.123" — too many decimals)', function () { + $tiers = [['tier_no' => 1, 'leads_in_tier' => 50, 'price_rub' => '10.123']]; + // ... pad to 7 + $resp = $this->postJson('/api/admin/pricing-tiers', ['tiers' => $tiers]); + $resp->assertStatus(422); +}); +``` + +- [ ] **Step 2: Run to verify fail** + +Expected: PASS on price 10.10 with current code if PHP float happens to round nicely; or FAIL if float drifts. Goal: enforce determinism — make this test pass via bcmath. + +- [ ] **Step 3: Update validation + computation** + +In `AdminPricingTiersController::store`: + +```diff +- 'tiers.*.price_rub' => ['required', 'numeric', 'min:0'], ++ 'tiers.*.price_rub' => ['required', 'string', 'regex:/^\d+(\.\d{1,2})?$/'], +``` + +And in the INSERT loop: + +```diff +- 'price_per_lead_kopecks' => (int) round(((float) $tier['price_rub']) * 100), ++ 'price_per_lead_kopecks' => (int) bcmul((string) $tier['price_rub'], '100', 0), +``` + +- [ ] **Step 4: Run tests** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +``` +git add app/app/Http/Controllers/Api/AdminPricingTiersController.php \ + app/tests/Feature/Api/AdminPricingTiersControllerTest.php +git commit -m "fix(billing-v2): AdminPricingTiers — bcmul + decimal regex (no float in money)" +``` + +--- + +### Task A.10: `TenantChargesController::export` — fill `balance_rub_after` via JOIN + +**Files:** + +- Modify: `app/app/Http/Controllers/Api/TenantChargesController.php` (lines ~95-109) +- Test: `app/tests/Feature/Api/TenantChargesExportTest.php` + +- [ ] **Step 1: Write/update the test** + +```php +it('fills balance_rub_after in CSV export from balance_transactions JOIN', function () { + $tenant = Tenant::factory()->create(); + $deal = Deal::factory()->for($tenant)->create(); + LeadCharge::create([ + 'tenant_id' => $tenant->id, 'deal_id' => $deal->id, /* ... */ + 'price_per_lead_kopecks' => 12000, 'charged_at' => now(), 'tier_no' => 1, + ]); + BalanceTransaction::create([ + 'tenant_id' => $tenant->id, 'type' => 'lead_charge', + 'amount_rub' => '-120.00', 'balance_rub_after' => '4880.00', + 'related_type' => Deal::class, 'related_id' => $deal->id, + ]); + $user = User::factory()->for($tenant)->create(); + + $resp = $this->actingAs($user)->post('/api/billing/charges/export'); + $csv = $resp->streamedContent(); + expect($csv)->toContain('4880.00'); // balance_rub_after filled +}); +``` + +- [ ] **Step 2: Run to verify fail (column is empty)** + +Expected: FAIL. + +- [ ] **Step 3: Rewrite the chunkById section to JOIN balance_transactions** + +In `TenantChargesController::export`, replace the query to use a join (raw SQL since chunkById gets messy with joins): + +```php +$query = DB::table('lead_charges as lc') + ->select([ + 'lc.charged_at', 'lc.deal_id', 'lc.tier_no', + 'lc.charge_source', 'lc.price_per_lead_kopecks', + 'bt.balance_rub_after', + ]) + ->leftJoin('balance_transactions as bt', function ($j) { + $j->on('bt.related_id', '=', 'lc.deal_id') + ->where('bt.related_type', '=', 'App\\Models\\Deal') + ->where('bt.type', '=', 'lead_charge') + ->whereColumn('bt.tenant_id', 'lc.tenant_id'); + }) + ->where('lc.tenant_id', $tenantId) + ->orderBy('lc.charged_at', 'desc'); + +// ... apply period + source filters as before but with 'lc.' prefix +// ... chunkById replaced by chunk(500) since we're on DB query builder +$query->orderBy('lc.id')->chunk(500, function ($rows) use ($out) { + foreach ($rows as $r) { + fputcsv($out, [ + $r->charged_at, + (string) $r->deal_id, + (string) $r->tier_no, + (string) $r->charge_source, + number_format($r->price_per_lead_kopecks / 100, 2, '.', ''), + $r->balance_rub_after ?? '', + ]); + } +}); +``` + +- [ ] **Step 4: Run tests** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +``` +git add app/app/Http/Controllers/Api/TenantChargesController.php \ + app/tests/Feature/Api/TenantChargesExportTest.php +git commit -m "fix(billing-v2): charges CSV export — fill balance_rub_after via JOIN" +``` + +--- + +### Task A.11: New artisan command `billing:migrate-leads-to-rub` + +**Files:** + +- Create: `app/app/Console/Commands/BillingMigrateLeadsToRubCommand.php` +- Create: `app/tests/Feature/Console/BillingMigrateLeadsToRubTest.php` + +- [ ] **Step 1: Write the failing test** + +```php + 1, 'leads_in_tier' => 50, + 'price_per_lead_kopecks' => 12000, 'is_active' => true, + 'effective_from' => now()->subDay()->toDateString(), + ]); +}); + +it('migrates balance_leads to balance_rub at tier 1 price', function () { + $tenant = Tenant::factory()->create(['balance_leads' => 5, 'balance_rub' => '100.00']); + + $this->artisan('billing:migrate-leads-to-rub')->assertOk(); + + $tenant->refresh(); + expect($tenant->balance_leads)->toBe(0); + expect($tenant->balance_rub)->toBe('700.00'); // 100 + 5×120 = 700 + + $tx = BalanceTransaction::where('tenant_id', $tenant->id) + ->where('type', BalanceTransaction::TYPE_MIGRATION)->first(); + expect($tx)->not->toBeNull(); + expect($tx->amount_leads)->toBe(-5); + expect($tx->amount_rub)->toBe('600.00'); + expect($tx->balance_rub_after)->toBe('700.00'); +}); + +it('is idempotent — second run is no-op', function () { + $tenant = Tenant::factory()->create(['balance_leads' => 5, 'balance_rub' => '100.00']); + $this->artisan('billing:migrate-leads-to-rub')->assertOk(); + $balanceAfterFirst = $tenant->fresh()->balance_rub; + + $this->artisan('billing:migrate-leads-to-rub')->assertOk(); + expect($tenant->fresh()->balance_rub)->toBe($balanceAfterFirst); + expect(BalanceTransaction::where('type', 'migration')->count())->toBe(1); +}); + +it('skips tenants with balance_leads = 0', function () { + Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '500.00']); + $this->artisan('billing:migrate-leads-to-rub')->assertOk(); + expect(BalanceTransaction::where('type', 'migration')->count())->toBe(0); +}); + +it('aborts if no active tier 1 configured', function () { + PricingTier::query()->update(['is_active' => false]); + Tenant::factory()->create(['balance_leads' => 5]); + $this->artisan('billing:migrate-leads-to-rub')->assertFailed(); +}); +``` + +- [ ] **Step 2: Run to verify fail (command doesn't exist)** + +Run: `cd app && ./vendor/bin/pest tests/Feature/Console/BillingMigrateLeadsToRubTest.php` +Expected: FAIL. + +- [ ] **Step 3: Create the command** + +`app/app/Console/Commands/BillingMigrateLeadsToRubCommand.php`: + +```php +where('is_active', true) + ->where('tier_no', 1) + ->where('effective_from', '<=', Carbon::now('Europe/Moscow')->toDateString()) + ->orderBy('effective_from', 'desc') + ->first(); + + if ($tier1 === null) { + $this->error('No active tier 1 found. Aborting.'); + return self::FAILURE; + } + + $count = 0; + Tenant::query() + ->where('balance_leads', '>', 0) + ->chunkById(100, function ($tenants) use ($tier1, &$count) { + foreach ($tenants as $tenant) { + DB::transaction(function () use ($tenant, $tier1, &$count) { + /** @var Tenant $locked */ + $locked = Tenant::query()->whereKey($tenant->id)->lockForUpdate()->first(); + if ($locked === null || $locked->balance_leads <= 0) { + return; // idempotency + } + $migratedKopecks = (int) $locked->balance_leads * (int) $tier1->price_per_lead_kopecks; + $migratedRub = bcdiv((string) $migratedKopecks, '100', 2); + $newBalanceRub = bcadd((string) $locked->balance_rub, $migratedRub, 2); + + DB::table('tenants') + ->where('id', $locked->id) + ->update([ + 'balance_rub' => $newBalanceRub, + 'balance_leads' => 0, + ]); + + BalanceTransaction::create([ + 'tenant_id' => $locked->id, + 'type' => BalanceTransaction::TYPE_MIGRATION, + 'amount_leads' => -(int) $locked->balance_leads, + 'amount_rub' => $migratedRub, + 'balance_leads_after' => 0, + 'balance_rub_after' => $newBalanceRub, + 'description' => 'Конвертация предоплаченных лидов в ₽ (миграция модели биллинга)', + 'created_at' => now(), + ]); + $count++; + }); + } + }); + + $this->info("Migrated {$count} tenant(s)."); + return self::SUCCESS; + } +} +``` + +- [ ] **Step 4: Register the command** + +Laravel 13 with package auto-discovery should pick it up automatically since it's in `app/app/Console/Commands/`. Verify via `php artisan list | grep migrate-leads-to-rub`. + +- [ ] **Step 5: Run tests** + +Expected: PASS (4 tests). + +- [ ] **Step 6: Commit** + +``` +git add app/app/Console/Commands/BillingMigrateLeadsToRubCommand.php \ + app/tests/Feature/Console/BillingMigrateLeadsToRubTest.php +git commit -m "feat(billing-v2): artisan billing:migrate-leads-to-rub (idempotent)" +``` + +--- + +### Task A.12: Clean `Tenant` model references to `balance_leads` + +**Files:** + +- Modify: `app/app/Models/Tenant.php` +- Verify: PHPDoc, `$casts`, `$fillable`, factories, IDE-helper stubs. + +- [ ] **Step 1: Grep for references** + +Run: `grep -n 'balance_leads' app/app/Models/Tenant.php app/database/factories/TenantFactory.php app/_ide_helper_models.php 2>/dev/null || true` + +- [ ] **Step 2: Decision matrix** + +- `$fillable` — KEEP (migration command still uses it via Eloquent `lockForUpdate`). +- `$casts` — KEEP (BalanceTransaction.balance_leads_after still reads it). +- PHPDoc `@property int $balance_leads` — KEEP for Phase A; will remove in Phase B together with column. +- Factory default — KEEP at 0 (no behavior change). + +**Conclusion:** No code changes in Tenant.php during Phase A. The column stays usable through code; only at Phase B we drop it everywhere. + +- [ ] **Step 3: No commit (this is a verification task)** + +Document in a one-line note (skip if not needed). + +--- + +### Task A.13: Seeders cleanup — remove `trial_bonus_leads`/`included_leads`/`billing_model` references + +**Files:** + +- Modify: `app/database/seeders/DemoSeeder.php`, `app/database/seeders/TenantSeeder.php`, and any other seeders that reference removed `tariff_plans` columns. + +- [ ] **Step 1: Grep for references** + +Run: `grep -rn 'trial_bonus_leads\|included_leads\|billing_model\|price_per_lead[^_]\|price_monthly' app/database/seeders app/database/factories` + +Expected: list of files with references. + +- [ ] **Step 2: For each found reference** + +Replace by removing the field from `Model::create([...])` call. If a seeder uses `'trial_bonus_leads' => 100` to give bonus leads to a new tenant — replace with a `BalanceTransaction::create` line that credits the rouble equivalent (`100 × tier_1_price`). + +Example for `DemoSeeder`: + +```diff +- Tenant::create([..., 'balance_leads' => 50, ...]); ++ $tenant = Tenant::create([..., 'balance_rub' => '6000.00', ...]); // 50 × 120₽ ++ BalanceTransaction::create([ ++ 'tenant_id' => $tenant->id, ++ 'type' => BalanceTransaction::TYPE_TOPUP, ++ 'amount_rub' => '6000.00', ++ 'balance_rub_after' => '6000.00', ++ 'description' => 'Демо-баланс (seeder)', ++ ]); +``` + +- [ ] **Step 3: Run seeder tests + `migrate:fresh --seed` on testing env** + +Run: `cd app && php artisan migrate:fresh --env=testing --seed` +Expected: completes without errors. + +- [ ] **Step 4: Run full Pest suite** + +Run: `cd app && ./vendor/bin/pest --parallel` +Expected: PASS (no factories/seeders broken). + +- [ ] **Step 5: Commit** + +``` +git add app/database/seeders app/database/factories +git commit -m "refactor(billing-v2): seeders — replace prepaid-lead concepts with ₽ topups" +``` + +--- + +### Task A.14: Frontend types — update `Wallet` and `BillingTransaction` interfaces + +**Files:** + +- Modify: `app/resources/js/api/billing.ts` + +- [ ] **Step 1: Update Wallet interface** + +```typescript +export interface Wallet { + balance_rub: string; + affordable_leads: number; + current_tier: { no: number; price_rub: string; leads_left_in_tier: number } | null; + 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' | 'trial_bonus' | 'manual_adjustment' | 'historical_import' | 'chargeback_writedown' | 'chargeback_repayment'; + description: string | null; + amount_rub: string; + amount_leads: number | null; + balance_rub_after: string; + display_amount_rub: string; + created_at: string; +} +``` + +Remove: `balance_leads` from Wallet, `price_monthly` and `billing_model` from `tariff`, `'refund'` from `BillingTransaction.type` union. + +- [ ] **Step 2: Run vue-tsc to find broken consumers** + +Run: `cd app && npm run type-check` +Expected: list of compile errors in files referencing removed fields (BalanceCard.vue, BillingView.vue, TransactionsTable.vue, etc) — these are addressed in subsequent tasks. + +- [ ] **Step 3: No commit yet** — frontend types ride along with their first consumer commit (Task A.15). + +--- + +### Task A.15: `BalanceCard.vue` — new layout, drop «(ГЦК)», switch to `affordable_leads` + +**Files:** + +- Modify: `app/resources/js/components/billing/BalanceCard.vue` +- Test: `app/resources/js/components/billing/BalanceCard.spec.ts` + +- [ ] **Step 1: Update the Vitest spec** + +```typescript +import { mount } from '@vue/test-utils'; +import BalanceCard from './BalanceCard.vue'; + +describe('BalanceCard (Billing v2)', () => { + const baseProps = { + walletRub: 5000, + affordableLeads: 46, + currentTierPriceRub: '120.00', + tariffName: 'Стартовый', + tariffFeatures: ['UTM', 'Webhook'], + }; + + it('shows wallet ₽ and «≈ N лидов»', () => { + const w = mount(BalanceCard, { props: baseProps }); + expect(w.text()).toContain('5 000'); + expect(w.text()).toContain('≈ 46'); + expect(w.text()).toContain('лидов'); + }); + + it('does NOT contain «(ГЦК)»', () => { + const w = mount(BalanceCard, { props: baseProps }); + expect(w.text()).not.toContain('ГЦК'); + }); + + it('does NOT contain «округление вниз ₽→лиды»', () => { + const w = mount(BalanceCard, { props: baseProps }); + expect(w.text()).not.toContain('округление вниз'); + }); + + it('tariff card shows name + features but NO «₽/мес»', () => { + const w = mount(BalanceCard, { props: baseProps }); + expect(w.text()).toContain('Стартовый'); + expect(w.text()).not.toContain('₽/мес'); + }); + + it('renders «сейчас по X ₽/лид» subline', () => { + const w = mount(BalanceCard, { props: baseProps }); + expect(w.text()).toContain('120.00 ₽/лид'); + }); +}); +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `cd app && npm run test:vue -- BalanceCard.spec.ts` +Expected: FAIL. + +- [ ] **Step 3: Rewrite BalanceCard.vue** + +Replace ` + + + + +``` + +- [ ] **Step 4: Run Vitest** + +Expected: PASS. + +- [ ] **Step 5: Create Histoire story** + +`TierPricesPanel.story.vue`: + +```vue + + + +``` + +- [ ] **Step 6: Commit** + +``` +git add app/resources/js/components/billing/TierPricesPanel.vue \ + app/resources/js/components/billing/TierPricesPanel.spec.ts \ + app/resources/js/components/billing/TierPricesPanel.story.vue +git commit -m "feat(billing-v2): TierPricesPanel — 7-tier collapsed panel + current highlight" +``` + +--- + +### Task A.18: Embed `TierPricesPanel` into `BillingView` + +**Files:** + +- Modify: `app/resources/js/views/BillingView.vue` + +- [ ] **Step 1: Import and use** + +In BillingView.vue, add: + +```typescript +import TierPricesPanel from '../components/billing/TierPricesPanel.vue'; +const tiersPreview = computed(() => wallet.value?.tiers_preview ?? []); +const currentTierNo = computed(() => wallet.value?.current_tier?.no ?? null); +``` + +In template, between `` and ``: + +```vue + +``` + +- [ ] **Step 2: Update BillingView.spec.ts** + +Add: `it('renders TierPricesPanel collapsed by default', ...)` + +- [ ] **Step 3: Run Vitest** + +Expected: PASS. + +- [ ] **Step 4: Commit** + +``` +git add app/resources/js/views/BillingView.vue \ + app/resources/js/views/BillingView.spec.ts +git commit -m "feat(billing-v2): BillingView — embed TierPricesPanel" +``` + +--- + +### Task A.19: `TransactionsTable.vue` — drop refund tab, use `display_amount_rub`, add year + +**Files:** + +- Modify: `app/resources/js/components/billing/TransactionsTable.vue` +- Test: `app/resources/js/components/billing/TransactionsTable.spec.ts` + +- [ ] **Step 1: Update spec** + +```typescript +it('does NOT render «Возвраты» tab', () => { + const w = mount(TransactionsTable); + expect(w.text()).not.toContain('Возвраты'); +}); + +it('uses display_amount_rub for amount column', async () => { + vi.mocked(getTransactions).mockResolvedValue({ + data: [{ + id: 1, code: 'TX-1', type: 'lead_charge', + amount_rub: '0.00', amount_leads: -1, + display_amount_rub: '0.00', + balance_rub_after: '500.00', + description: 'historic prepaid', created_at: '2026-05-23T10:00:00Z', + }], + meta: { current_page: 1, last_page: 1, total: 1, per_page: 20 }, + }); + const w = mount(TransactionsTable); + await nextTick(); + expect(w.text()).toContain('0'); // shows 0 ₽, not «− 1 лид.» + expect(w.text()).not.toContain('лид.'); +}); + +it('formats date with year', () => { + const w = mount(TransactionsTable); + // hard to assert directly; check that formatWhen output matches /\d{2}\.\d{2}\.\d{2}/ +}); +``` + +- [ ] **Step 2: Run to verify fail** + +Expected: FAIL. + +- [ ] **Step 3: Edit TransactionsTable.vue** + +Remove `{ id: 'refund', ... }` from TABS array. + +Rewrite `txAmountText`: + +```typescript +function txAmountText(tx: BillingTransaction): string { + return formatCost(Number(tx.display_amount_rub)); +} +function txAmountValue(tx: BillingTransaction): number { + return Number(tx.display_amount_rub); +} +``` + +Update `formatWhen`: + +```typescript +function formatWhen(iso: string): string { + return new Date(iso).toLocaleString('ru-RU', { + timeZone: 'Europe/Moscow', + year: '2-digit', day: '2-digit', month: '2-digit', + hour: '2-digit', minute: '2-digit', + }); +} +``` + +- [ ] **Step 4: Run Vitest** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +``` +git add app/resources/js/components/billing/TransactionsTable.vue \ + app/resources/js/components/billing/TransactionsTable.spec.ts +git commit -m "fix(billing-v2): TransactionsTable — drop refund tab, display_amount_rub, year in date" +``` + +--- + +### Task A.20: `InvoicesTable.vue` — add ₽ suffix + +**Files:** + +- Modify: `app/resources/js/components/billing/InvoicesTable.vue` +- Test: `app/resources/js/components/billing/InvoicesTable.spec.ts` + +- [ ] **Step 1: Update spec** + +```typescript +it('renders amount_total with ₽ suffix', async () => { + vi.mocked(getInvoices).mockResolvedValue({ + data: [{ id: 1, invoice_number: 'INV-1', amount_total: '1234.00', status: 'paid', issued_at: '2026-05-23', has_pdf: true }], + }); + const w = mount(InvoicesTable); + await nextTick(); + expect(w.text()).toMatch(/1[\s ]234[\s ]?₽/); +}); +``` + +- [ ] **Step 2: Run to verify fail** + +Expected: FAIL. + +- [ ] **Step 3: Edit InvoicesTable.vue** + +```diff +-{{ formatPlain(Number(inv.amount_total)) }} ++{{ formatPlain(Number(inv.amount_total)) }} ₽ +``` + +- [ ] **Step 4: Run Vitest** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +``` +git add app/resources/js/components/billing/InvoicesTable.vue \ + app/resources/js/components/billing/InvoicesTable.spec.ts +git commit -m "fix(billing-v2): InvoicesTable — append ₽ to amount_total" +``` + +--- + +### Task A.21: `ChargesTab.vue` — remove «Источник» filter + column + +**Files:** + +- Modify: `app/resources/js/views/billing/ChargesTab.vue` +- Test: `app/resources/js/views/billing/ChargesTab.spec.ts` + +- [ ] **Step 1: Update spec** + +```typescript +it('does NOT render «Источник» filter', () => { + const w = mount(ChargesTab); + expect(w.text()).not.toContain('Источник'); +}); + +it('does NOT render «Источник» column in table', () => { + // assert headers array doesn't include charge_source +}); + +it('renders «0 ₽ (из бесплатного)» tooltip for historic prepaid rows', async () => { + // mock /api/billing/charges to return row with price_per_lead_kopecks=0 + charge_source='prepaid' + // assert tooltip wrapper exists +}); +``` + +- [ ] **Step 2: Run to verify fail** + +Expected: FAIL — current code has «Источник». + +- [ ] **Step 3: Edit ChargesTab.vue** + +Remove: + +- `source` ref, `sources` array. +- The `` for «Источник». +- `if (source.value) params.charge_source = source.value;` lines (in `load` and `exportCsv`). +- The «Источник» column from `headers` array. +- The `