# 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 ../<main-checkout>/app/.env app/.env
cp -r ../<main-checkout>/app/storage app/storage
cp ../<main-checkout>/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
<?php

declare(strict_types=1);

use App\Models\BalanceTransaction;
use App\Models\Tenant;

it('accepts type=migration after CHECK constraint extension', function () {
    /** @var Tenant $tenant */
    $tenant = Tenant::factory()->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
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
    public function up(): void
    {
        DB::statement(<<<'SQL'
            ALTER TABLE balance_transactions
            DROP CONSTRAINT IF EXISTS balance_transactions_type_check;
            ALTER TABLE balance_transactions
            ADD CONSTRAINT balance_transactions_type_check
            CHECK (type IN (
                'trial_bonus','topup','lead_charge','refund',
                'manual_adjustment','historical_import',
                'chargeback_writedown','chargeback_repayment',
                'migration'
            ));
        SQL);
    }

    public function down(): void
    {
        DB::statement(<<<'SQL'
            ALTER TABLE balance_transactions
            DROP CONSTRAINT IF EXISTS balance_transactions_type_check;
            ALTER TABLE balance_transactions
            ADD CONSTRAINT balance_transactions_type_check
            CHECK (type IN (
                'trial_bonus','topup','lead_charge','refund',
                'manual_adjustment','historical_import',
                'chargeback_writedown','chargeback_repayment'
            ));
        SQL);
    }
};
```

- [ ] **Step 5: Run migration**

Run from `app/`: `php artisan migrate`
Expected: migration applies, no errors.

- [ ] **Step 6: Run test to verify it passes**

Run: `./vendor/bin/pest tests/Unit/Models/BalanceTransactionMigrationTypeTest.php`
Expected: PASS (2 assertions).

- [ ] **Step 7: Update `db/schema.sql` baseline**

Edit `db/schema.sql` lines 2326-2331 — replace the `CHECK (type IN (...))` clause to include `'migration'`. Update header version (line 7-19 area) — add `v8.NN (23.05.2026 — Billing v2 Spec A Task A.1: balance_transactions.type +migration)`.

- [ ] **Step 8: Add CHANGELOG entry**

In `db/CHANGELOG_schema.md` — add entry under «23.05.2026»: `+CHECK constraint balance_transactions_type extended with 'migration'`.

- [ ] **Step 9: Commit**

```
git add app/app/Models/BalanceTransaction.php \
        app/database/migrations/2026_05_23_100001_extend_balance_transactions_type_for_migration.php \
        app/tests/Unit/Models/BalanceTransactionMigrationTypeTest.php \
        db/schema.sql db/CHANGELOG_schema.md
git commit -m "feat(billing-v2): add BalanceTransaction::TYPE_MIGRATION + extend CHECK"
```

---

### Task A.2: New pure service `BalanceToLeadsConverter`

**Files:**

- Create: `app/app/Services/Billing/BalanceToLeadsConverter.php`
- Create: `app/tests/Unit/Services/Billing/BalanceToLeadsConverterTest.php`

- [ ] **Step 1: Write the failing test (8 cases)**

Create `app/tests/Unit/Services/Billing/BalanceToLeadsConverterTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\PricingTier;
use App\Services\Billing\BalanceToLeadsConverter;
use Illuminate\Database\Eloquent\Collection;

function buildTier(int $no, ?int $cap, int $priceKopecks): PricingTier
{
    $t = new PricingTier();
    $t->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
<?php

declare(strict_types=1);

namespace App\Services\Billing;

use App\Models\PricingTier;
use Illuminate\Database\Eloquent\Collection;

/**
 * Pure: «при балансе ₽ и доставленных в этом месяце N — сколько лидов клиент
 * получит, проходя ступени pricing_tiers».
 *
 * Все мутации денег — bcmath (string-int копейки), без PHP float.
 *
 * Spec: docs/superpowers/specs/2026-05-23-billing-v2-spec-a-balance-rub-design.md §3.3.1
 */
final class BalanceToLeadsConverter
{
    /**
     * @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 $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
<?php

declare(strict_types=1);

use App\Exceptions\Billing\InsufficientBalanceException;

it('exposes priceKopecks and balanceRub but no balanceLeads', function () {
    $e = new InsufficientBalanceException(
        priceKopecks: 12000,
        balanceRub: '50.00',
    );

    expect($e->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
<?php

declare(strict_types=1);

use App\Models\BalanceTransaction;
use App\Models\PricingTier;
use App\Models\Tenant;

beforeEach(function () {
    PricingTier::create([
        'tier_no' => 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
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Models\BalanceTransaction;
use App\Models\PricingTier;
use App\Models\Tenant;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;

/**
 * Идемпотентная миграция: balance_leads → balance_rub по цене ступени 1.
 *
 * Spec: docs/superpowers/specs/2026-05-23-billing-v2-spec-a-balance-rub-design.md §4.4
 * Запускается ОДИН РАЗ в проде в составе Phase A. Повторный запуск — no-op.
 */
final class BillingMigrateLeadsToRubCommand extends Command
{
    protected $signature = 'billing:migrate-leads-to-rub';
    protected $description = 'Convert legacy balance_leads to balance_rub at tier 1 price (idempotent)';

    public function handle(): int
    {
        $tier1 = PricingTier::query()
            ->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 `<script setup>` props block:

```typescript
const props = defineProps<{
  walletRub: number;
  affordableLeads: number;
  currentTierPriceRub: string;
  tariffName: string | null;
  tariffFeatures: string[];
}>();

defineEmits<{ topup: [] }>();

const walletText = computed(() => new Intl.NumberFormat('ru-RU').format(props.walletRub));
```

Replace template middle card:

```vue
<v-col cols="12" md="4">
  <v-card variant="outlined" class="wallet-card pa-4">
    <div class="wallet-h"><span class="wallet-label">≈ Лиды</span></div>
    <div class="wallet-amount mt-2">
      <span class="num">≈ {{ affordableLeads }}</span>
      <span class="ru-text">&nbsp;лидов</span>
    </div>
    <div class="wallet-foot mt-2">сейчас по {{ currentTierPriceRub }} ₽/лид</div>
    <v-tooltip text="Точный расчёт по текущим ценам. Меняется при переходе ступеней.">
      <template #activator="{ props: tipProps }">
        <v-icon v-bind="tipProps" size="14" class="ml-1">mdi-information-outline</v-icon>
      </template>
    </v-tooltip>
  </v-card>
</v-col>
```

Replace first card foot text:

```diff
-<div class="wallet-foot mt-3">мин. пополнение <strong>100 ₽</strong> · округление вниз ₽→лиды</div>
+<div class="wallet-foot mt-3">мин. пополнение <strong>100 ₽</strong></div>
```

Replace tariff card to drop `tariff_price`:

```diff
-<div class="tariff-name mt-1">
-  {{ tariffName }}
-  <span class="tariff-price">· {{ tariffPriceText }}</span>
-</div>
+<div class="tariff-name mt-1">{{ tariffName }}</div>
```

Delete `tariffPriceText` computed and `tariffPrice` prop.

- [ ] **Step 4: Run Vitest**

Expected: PASS.

- [ ] **Step 5: Update Histoire story (if exists)**

Edit `app/resources/js/components/billing/BalanceCard.story.vue` to use new props.

- [ ] **Step 6: Commit**

```
git add app/resources/js/api/billing.ts \
        app/resources/js/components/billing/BalanceCard.vue \
        app/resources/js/components/billing/BalanceCard.spec.ts \
        app/resources/js/components/billing/BalanceCard.story.vue
git commit -m "feat(billing-v2): BalanceCard — ≈ N лидов via affordable_leads, drop (ГЦК)"
```

---

### Task A.16: `BillingView.vue` — remove «N лидов запас» from `page-stats`

**Files:**

- Modify: `app/resources/js/views/BillingView.vue`
- Test: `app/resources/js/views/BillingView.spec.ts`

- [ ] **Step 1: Update spec**

```typescript
it('page-stats does NOT show «N лидов запас»', async () => {
  // ... mount with wallet fixture
  expect(w.text()).not.toContain('лидов запас');
});
```

- [ ] **Step 2: Run to verify fail**

Expected: FAIL — current text contains «лидов запас».

- [ ] **Step 3: Edit BillingView.vue**

Remove lines `78-79` (`<span>...лидов запас</span> <span class="sep">·</span>`).

Also drop `leadsBalance` computed and the `:leads-balance` prop on `<BalanceCard>` (already not in BalanceCard props after Task A.15 — vue-tsc will catch). Replace with the new props:

```vue
<BalanceCard
  :wallet-rub="walletRub"
  :affordable-leads="affordableLeads"
  :current-tier-price-rub="currentTierPriceRub"
  :tariff-name="tariffName"
  :tariff-features="tariffFeatures"
  @topup="topupOpen = true"
/>
```

Add computed:

```typescript
const affordableLeads = computed(() => wallet.value?.affordable_leads ?? 0);
const currentTierPriceRub = computed(() => wallet.value?.current_tier?.price_rub ?? '0.00');
```

Remove `leadsBalance` and `tariffPrice` computed.

- [ ] **Step 4: Run Vitest + vue-tsc**

Run: `npm run test:vue -- BillingView.spec.ts && npm run type-check`
Expected: PASS.

- [ ] **Step 5: Commit**

```
git add app/resources/js/views/BillingView.vue \
        app/resources/js/views/BillingView.spec.ts
git commit -m "fix(billing-v2): BillingView — drop «лидов запас», wire new BalanceCard props"
```

---

### Task A.17: New `TierPricesPanel.vue` + spec + story

**Files:**

- Create: `app/resources/js/components/billing/TierPricesPanel.vue`
- Create: `app/resources/js/components/billing/TierPricesPanel.spec.ts`
- Create: `app/resources/js/components/billing/TierPricesPanel.story.vue`

- [ ] **Step 1: Write spec**

```typescript
import { mount } from '@vue/test-utils';
import TierPricesPanel from './TierPricesPanel.vue';

const tiers = [
  { tier_no: 1, leads_in_tier: 50,   price_rub: '120.00' },
  { tier_no: 2, leads_in_tier: 100,  price_rub: '100.00' },
  { tier_no: 3, leads_in_tier: 200,  price_rub: '80.00'  },
  { tier_no: 4, leads_in_tier: 300,  price_rub: '70.00'  },
  { tier_no: 5, leads_in_tier: 400,  price_rub: '65.00'  },
  { tier_no: 6, leads_in_tier: 500,  price_rub: '62.00'  },
  { tier_no: 7, leads_in_tier: null, price_rub: '60.00'  },
];

describe('TierPricesPanel', () => {
  it('renders all 7 tiers', () => {
    const w = mount(TierPricesPanel, { props: { tiers, currentTierNo: 1 } });
    expect(w.findAll('[data-test="tier-row"]')).toHaveLength(7);
  });

  it('highlights current tier with «вы здесь» chip', () => {
    const w = mount(TierPricesPanel, { props: { tiers, currentTierNo: 3 } });
    const currentRow = w.find('[data-test="tier-row-3"]');
    expect(currentRow.text()).toContain('вы здесь');
  });

  it('renders «1501+» range for tier with leads_in_tier=null', () => {
    const w = mount(TierPricesPanel, { props: { tiers, currentTierNo: 1 } });
    expect(w.find('[data-test="tier-row-7"]').text()).toMatch(/1501\+/);
  });

  it('is collapsed by default', () => {
    const w = mount(TierPricesPanel, { props: { tiers, currentTierNo: 1 } });
    // v-expansion-panel value not set → no .v-expansion-panel--active
    expect(w.find('.v-expansion-panel--active').exists()).toBe(false);
  });
});
```

- [ ] **Step 2: Run to verify fail**

Expected: FAIL — component does not exist.

- [ ] **Step 3: Create component**

```vue
<script setup lang="ts">
interface TierPreview {
  tier_no: number;
  leads_in_tier: number | null;
  price_rub: string;
}

const props = defineProps<{
  tiers: TierPreview[];
  currentTierNo: number | null;
}>();

function rangeText(idx: number): string {
  let start = 1;
  for (let i = 0; i < idx; i++) {
    if (props.tiers[i].leads_in_tier !== null) start += props.tiers[i].leads_in_tier!;
  }
  const cap = props.tiers[idx].leads_in_tier;
  if (cap === null) return `${start}+`;
  return `${start}–${start + cap - 1}`;
}
</script>

<template>
  <v-expansion-panels class="mt-4 tier-prices">
    <v-expansion-panel title="Цены за лид (7 ступеней)">
      <template #text>
        <ul class="tier-list">
          <li
            v-for="(t, i) in tiers"
            :key="t.tier_no"
            :data-test="`tier-row tier-row-${t.tier_no}`"
            class="tier-row"
            :class="{ 'tier-row--current': t.tier_no === currentTierNo }"
          >
            <span class="tier-no num">№{{ t.tier_no }}</span>
            <span class="tier-range">{{ rangeText(i) }} лидов</span>
            <span class="tier-price num">{{ t.price_rub }} ₽</span>
            <v-chip
              v-if="t.tier_no === currentTierNo"
              size="x-small"
              color="primary"
              variant="elevated"
            >вы здесь</v-chip>
          </li>
        </ul>
      </template>
    </v-expansion-panel>
  </v-expansion-panels>
</template>

<style scoped>
.tier-list { list-style: none; padding: 0; margin: 0; }
.tier-row { display: grid; grid-template-columns: 60px 1fr auto auto; gap: 12px; align-items: center; padding: 8px 12px; }
.tier-row--current { background: rgba(15, 110, 86, 0.07); border-left: 3px solid #0F6E56; }
.num { font-family: 'JetBrains Mono', ui-monospace, monospace; font-feature-settings: 'tnum'; }
</style>
```

- [ ] **Step 4: Run Vitest**

Expected: PASS.

- [ ] **Step 5: Create Histoire story**

`TierPricesPanel.story.vue`:

```vue
<script setup lang="ts">
import TierPricesPanel from './TierPricesPanel.vue';
const tiers = [/* same 7-tier fixture as spec */];
</script>

<template>
  <Story title="Billing/TierPricesPanel">
    <Variant title="Текущая ступень: 1"><TierPricesPanel :tiers="tiers" :current-tier-no="1" /></Variant>
    <Variant title="Текущая ступень: 3"><TierPricesPanel :tiers="tiers" :current-tier-no="3" /></Variant>
    <Variant title="Текущая ступень: 7 (всё свыше)"><TierPricesPanel :tiers="tiers" :current-tier-no="7" /></Variant>
  </Story>
</template>
```

- [ ] **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 `<BalanceCard ... />` and `<TransactionsTable .../>`:

```vue
<TierPricesPanel :tiers="tiersPreview" :current-tier-no="currentTierNo" />
```

- [ ] **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
-<span class="inv-amount num">{{ formatPlain(Number(inv.amount_total)) }}</span>
+<span class="inv-amount num">{{ formatPlain(Number(inv.amount_total)) }} ₽</span>
```

- [ ] **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 `<v-select>` for «Источник».
- `if (source.value) params.charge_source = source.value;` lines (in `load` and `exportCsv`).
- The «Источник» column from `headers` array.
- The `<template #[item.charge_source]>` slot.

Update «Цена» slot to handle the historic-prepaid case:

```vue
<template #[`item.price_rub`]="{ item }">
  <span v-if="item.price_per_lead_kopecks === 0" class="text-medium-emphasis">
    0 ₽
    <v-tooltip activator="parent" text="До перехода на новую модель эти лиды списывались из бесплатного остатка." />
    <span class="text-caption ml-1">(из бесплатного)</span>
  </span>
  <span v-else>{{ (item.price_per_lead_kopecks / 100).toFixed(2) }} ₽</span>
</template>
```

- [ ] **Step 4: Run Vitest**

Expected: PASS.

- [ ] **Step 5: Commit**

```
git add app/resources/js/views/billing/ChargesTab.vue \
        app/resources/js/views/billing/ChargesTab.spec.ts
git commit -m "fix(billing-v2): ChargesTab — drop «Источник» filter/column, prepaid tooltip for history"
```

---

### Task A.22: Run `/regression full` — fix any breaks

**Files:** none directly; fix wherever regression points.

- [ ] **Step 1: Run regression**

Run from worktree dir: `/regression full` (via Skill tool)

OR equivalent:

```
cd app && ./vendor/bin/pest --parallel
cd app && composer stan
cd app && composer pint -- --test
npm run test:vue
npm run type-check
npm run lint:vue
npm run build
npm run story:build
npm run a11y    # Pa11y /billing
npm run links
./bin/gitleaks.exe detect --staged
```

- [ ] **Step 2: For each failing check**

- If Pest fails: read test name, fix code or test per spec intent.
- If Larastan complains about `Tenant::balance_leads` access: add `@phpstan-ignore-next-line` with comment «Phase B drops column», or rewrite.
- If type-check fails on a consumer not yet updated: update the consumer.
- If Pa11y reports new violations: fix at the violation site.

- [ ] **Step 3: Iterate until all green**

- [ ] **Step 4: Commit any fixes incrementally**

Each fix = its own commit (`fix(billing-v2): regression — <what>`).

---

### Task A.23: Manual smoke — Playwright on `/billing`

**Files:** none (manual verification).

- [ ] **Step 1: Boot the app**

Run: `cd app && php artisan serve` (in one tab) + `npm run dev` (in another).

- [ ] **Step 2: Playwright MCP — drive /billing**

Through Playwright MCP:

1. Login as `admin@demo.local` / `password`.
2. Navigate to `/billing`.
3. Screenshot the page.
4. Verify visible:
   - Card 1: «Кошелёк ₽» with balance, «мин. пополнение 100 ₽» (no «округление»).
   - Card 2: «≈ N лидов» with tooltip on hover.
   - Card 3: «Что входит» (tariff name + features, no «₽/мес»).
   - «Цены за лид» panel — click to expand, see 7 tiers, current highlighted.
   - History tabs: «Все», «Пополнения», «Списания» — but NOT «Возвраты».
   - Page-stats: «X ₽ кошелёк · хватит на Y дн.» (no «N лидов запас»).
5. Click «Пополнить баланс» → submit 1000 ₽ → see snackbar «Баланс пополнен» + updated balance.
6. Switch to «Списания» tab → see ChargesTab without «Источник» filter.

- [ ] **Step 3: Save screenshots in `docs/superpowers/audits/2026-05-XX-billing-v2-spec-a-smoke/`**

- [ ] **Step 4: If any visual issue → fix + commit**

- [ ] **Step 5: No commit unless code changed**

---

### Task A.24: Push branch + open PR

- [ ] **Step 1: Verify branch is clean**

Run: `git status` → empty.

- [ ] **Step 2: Rebase on latest origin/main**

```
git fetch origin
git rebase origin/main
```

If conflicts: resolve, `git rebase --continue`.

- [ ] **Step 3: Push branch**

```
git push origin feat/billing-v2-spec-a
```

- [ ] **Step 4: Open PR**

Through GitHub MCP or `gh pr create`:

- Title: `feat(billing-v2): Spec A — единый ₽-баланс + унификация tariff_plans`
- Body: link to spec + plan, list closed audit findings (P0=5/P1=6/P2=4), note Phase B follow-up PR in 72h after Phase A in prod.

- [ ] **Step 5: PHASE A PAUSE**

⛔ **Stop here.** Wait for:

- Code review approval.
- Merge to main.
- Deploy to prod.
- Run `php artisan billing:migrate-leads-to-rub` in prod (manual operator step).
- 72h observation of `balance_transactions(type='migration')` audit + smoke metrics.

Only then proceed to Phase B.

---

## Phase B: Schema cleanup (separate PR, ≥72h after Phase A in prod)

### Task B.1: Create migration to DROP columns

**Files:**

- Create: `app/database/migrations/2026_05_XX_100002_drop_balance_leads_and_tariff_columns.php`

- [ ] **Step 1: Verify code-side has no references**

Run: `grep -rn 'balance_leads\|price_per_lead[^_]\|price_monthly\|included_leads\|trial_bonus_leads\|billing_model' app/ --include='*.php' --include='*.vue' --include='*.ts'`

Expected: 0 references (excluding `lead_charges.price_per_lead_kopecks` — different column).

If references found → STOP. Fix code first, then proceed.

- [ ] **Step 2: Create the migration**

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
    public function up(): void
    {
        DB::statement('ALTER TABLE tenants DROP COLUMN IF EXISTS balance_leads');

        foreach (['price_per_lead','price_monthly','included_leads','trial_bonus_leads','billing_model'] as $col) {
            DB::statement("ALTER TABLE tariff_plans DROP COLUMN IF EXISTS {$col}");
        }
    }

    public function down(): void
    {
        DB::statement('ALTER TABLE tenants ADD COLUMN balance_leads INT NOT NULL DEFAULT 0');
        DB::statement('ALTER TABLE tariff_plans ADD COLUMN price_per_lead DECIMAL(10,2)');
        DB::statement('ALTER TABLE tariff_plans ADD COLUMN price_monthly DECIMAL(10,2)');
        DB::statement('ALTER TABLE tariff_plans ADD COLUMN included_leads INT');
        DB::statement('ALTER TABLE tariff_plans ADD COLUMN trial_bonus_leads INT');
        DB::statement("ALTER TABLE tariff_plans ADD COLUMN billing_model VARCHAR(50) DEFAULT 'per_lead'");
    }
};
```

- [ ] **Step 3: Run migration locally**

Run: `cd app && php artisan migrate`
Expected: completes without error.

- [ ] **Step 4: Verify columns gone**

Run: `php artisan tinker --execute='dump(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name=\"tenants\" AND column_name=\"balance_leads\""));'`
Expected: empty result.

- [ ] **Step 5: Run full Pest + Vitest**

Run: `cd app && ./vendor/bin/pest --parallel && cd .. && npm run test:vue`
Expected: PASS.

- [ ] **Step 6: Update Tenant model to remove `@property int $balance_leads`**

Edit `app/app/Models/Tenant.php` — remove the PHPDoc `@property` line for balance_leads.

Also clean `$casts` and `$fillable` if balance_leads is still listed.

Regenerate IDE helper:

```
php artisan ide-helper:generate
php artisan ide-helper:models -W -M -N
```

- [ ] **Step 7: Commit**

```
git add app/database/migrations/2026_05_XX_100002_*.php \
        app/app/Models/Tenant.php \
        app/_ide_helper*.php
git commit -m "feat(billing-v2): Phase B — DROP balance_leads + 5 tariff_plans columns"
```

---

### Task B.2: Update `db/schema.sql` baseline + CHANGELOG

**Files:**

- Modify: `db/schema.sql`
- Modify: `db/CHANGELOG_schema.md`

- [ ] **Step 1: Edit schema.sql**

- Remove the `balance_leads` column from CREATE TABLE tenants.
- Remove the 5 columns from CREATE TABLE tariff_plans.
- Update header version line + add `-- v8.NN (DD.05.2026 — Billing v2 Spec A Phase B: DROP balance_leads + 5 tariff_plans cols)`.

- [ ] **Step 2: Edit CHANGELOG_schema.md**

Add:

```markdown
## v8.NN (DD.05.2026) — Billing v2 Phase B
- DROP COLUMN tenants.balance_leads
- DROP COLUMN tariff_plans.{price_per_lead, price_monthly, included_leads, trial_bonus_leads, billing_model}
```

- [ ] **Step 3: Verify migrate:fresh works on testing DB**

Run: `cd app && php artisan migrate:fresh --env=testing`
Expected: completes; new schema matches `db/schema.sql`.

- [ ] **Step 4: Commit**

```
git add db/schema.sql db/CHANGELOG_schema.md
git commit -m "docs(schema): Phase B — sync schema.sql + CHANGELOG"
```

---

### Task B.3: `/regression full` + PR

- [ ] **Step 1: Run regression**

Same as Task A.22.

- [ ] **Step 2: Push + PR**

```
git push origin feat/billing-v2-spec-a   # Phase B commits append to same branch OR open new branch
```

If using separate branch:

```
git checkout -B feat/billing-v2-phase-b
git push origin feat/billing-v2-phase-b
```

- [ ] **Step 3: Open PR** — title `feat(billing-v2): Phase B — schema cleanup (DROP columns)`.

---

## Spec Coverage Check (self-review)

| Spec requirement | Task | Status |
|---|---|---|
| §2.1.1 `balance_leads` уходит | A.11 (migration) + B.1 (DROP) | ✓ |
| §2.1.2 BalanceToLeadsConverter | A.2 | ✓ |
| §2.1.3 tariff_plans обрезка | B.1 + A.13 (seeders) | ✓ |
| §2.1.4 без возвратов | A.8 + A.19 | ✓ |
| §3.2.3 TYPE_MIGRATION | A.1 | ✓ |
| §3.3.1 BalanceToLeadsConverter | A.2 | ✓ |
| §3.3.2 chargeForDelivery упрощение | A.3, A.4, A.5 | ✓ |
| §3.3.3 wallet API response shape | A.6 | ✓ |
| §3.3.4 transactions filter cleanup | A.8 | ✓ |
| §3.3.5 AdminPricingTiers bcmul | A.9 | ✓ |
| §3.3.6 charges export JOIN | A.10 | ✓ |
| §3.3.7 seeders cleanup | A.13 | ✓ |
| §3.4.1 types update | A.14 | ✓ |
| §3.4.2 BillingView shape | A.16, A.18 | ✓ |
| §3.4.3 BalanceCard rewrite | A.15 | ✓ |
| §3.4.4 TransactionsTable | A.19 | ✓ |
| §3.4.5 InvoicesTable | A.20 | ✓ |
| §3.4.6 ChargesTab | A.21 | ✓ |
| §3.4.7 TopupDialog | — (intentionally not touched; spec C) | ✓ |
| §3.4.8 TierPricesPanel | A.17, A.18 | ✓ |
| §3.5 tests | woven throughout TDD | ✓ |
| §4.1 двухфазный релиз | Phase A pause + Phase B | ✓ |
| §4.2 регрессионные критерии | A.22, B.3 | ✓ |

All 19 findings from §6 reestr → mapped to tasks via spec sections above.

---

## Plan complete

**Сохранено в:** `docs/superpowers/plans/2026-05-23-billing-v2-spec-a-balance-rub-plan.md`

**Две опции выполнения:**

1. **Subagent-Driven (рекомендую)** — я диспатчу свежего субагента (Sonnet или Opus, не Haiku) на каждую задачу, ревьюю между задачами, быстрая итерация. Каждая задача — отдельный коммит.

2. **Inline Execution** — выполняю задачи в этой сессии через executing-plans, пакетное исполнение с чекпоинтами на ревью.

**Какой подход?**
