# Sprint 2 Plan C — Billing (E1 + E3) 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.

**Goal:** Подвязать BillingView Overview-таб на реальный API и добавить рабочее пополнение баланса (MVP-stub без платёжного шлюза).

**Architecture:** Новый `BillingController` (4 эндпоинта под `auth:sanctum`+`tenant`) + `BillingTopupService` (append-only ledger-credit на bcmath) + `TariffPlan` модель. Frontend: новый `api/billing.ts`, `BillingView` + 3 sub-компонента переводятся с mock-данных на fetch. Пополнение баланса — `TopupDialog` + кнопки «Пополнить».

**Tech Stack:** Laravel 13 (Sanctum SPA, PostgreSQL 16 RLS), Pest 4; Vue 3 + Vuetify 3 + Pinia, Vitest.

**Источник:** `docs/superpowers/specs/2026-05-15-portal-audit-design.md` (commit `e978b33`) — Sprint 2 эпики **E1** (BillingView Пополнить баланс — TopupDialog + `POST /api/billing/topup` stub) и **E3** (BillingView Overview tab — BalanceCard/TransactionsTable/InvoicesTable на real API).

---

## Контекст и ключевые факты recon (читать перед началом)

**Schema delta = 0.** Plan C НЕ трогает `db/schema.sql` — все нужные таблицы (`balance_transactions`, `tariff_plans`, `saas_invoices`) уже существуют. Записи в `db/CHANGELOG_schema.md` не требуется.

**`balance_transactions` — append-only audit-таблица с 2 триггерами** (`db/schema.sql:2974-2979`):

- `trg_audit_chain_hash_balance_tx` — `BEFORE INSERT` → `audit_chain_hash()` заполняет `log_hash` (SHA-256 hash-chain). **Прозрачно для приложения** — INSERT работает как обычно, ничего делать не нужно.
- `trg_audit_block_mut_balance_tx` — `BEFORE UPDATE OR DELETE` → `audit_block_mutation()` делает `RAISE EXCEPTION`. **Строки неизменяемы.** → Каждый topup = новая INSERT-строка; существующие строки никогда не UPDATE/DELETE. `balance_rub_after` вычисляется на момент INSERT'а и фиксируется навсегда.
- `LedgerService::chargeForDelivery` (`app/app/Services/Billing/LedgerService.php:86`) уже делает `BalanceTransaction::create([...])` тем же путём — паттерн проверен.

**Деньги — только bcmath, не PHP float.** `tenants.balance_rub` / `balance_transactions.amount_rub` — `DECIMAL(12,2)`. Мутации баланса считаются через `bcadd`/`bcsub` (паттерн `LedgerService.php:64-65`). Исключение — оценочный показатель `runway_days` (грубая UX-оценка, не мутация баланса; float допустим, см. Task 2).

**RLS + тесты.** `balance_transactions` и `saas_invoices` имеют RLS-политику `tenant_isolation` (`USING` без `WITH CHECK` → INSERT не фильтруется, SELECT/UPDATE/DELETE фильтруются). Тесты идут под postgres-superuser (BYPASSRLS), поэтому контроллеры обязаны делать **явный `where('tenant_id', $tenantId)`** как defense-in-depth (паттерн `TenantChargesController.php:42`). Это и есть проверяемая в тестах изоляция.

**Тест-паттерн (эталоны `app/tests/Feature/ApiKeyControllerTest.php`, `app/tests/Feature/Billing/TenantChargesControllerTest.php`):**

- `tests/Pest.php` НЕ включает `RefreshDatabase` глобально. Каждый Feature-тест-файл объявляет `uses(DatabaseTransactions::class)` сам.
- `beforeEach`: `Tenant::factory()->create()` → `User::factory()->create(['tenant_id'=>...])` → `$this->actingAs($this->user)`.
- `$this->actingAs($user)` (дефолтный guard) проходит через `auth:sanctum` — проверено в Plan B.
- `auth()->logout()` для 401-теста.
- `tariff_plans` (4 плана start/basic/pro/enterprise) сидится прямо из `db/schema.sql` при `migrate:fresh` — в тестовой БД эти строки ЕСТЬ, фабрика для `TariffPlan` не нужна.

**PHPStan baseline (quirk-паттерн проекта):** каждый новый Feature-тест-файл даёт false-positives PHPStan на `$this`-динамические свойства/методы (`Pest\PendingCalls\TestCall`). После написания тестов — `composer stan`; если ошибки только в новых тест-файлах → `composer stan -- --generate-baseline`, затем `composer stan` снова (0 errors). Quirk #87: создание новой модели + `ide-helper:models -W` регенерирует `_ide_helper_models.php` → устаревшие `ignore`-записи становятся unmatched → `--generate-baseline` их корректно убирает (это безопасно — удаление `ignore` может только вскрыть ошибку, не спрятать).

**API-клиент фронтенда (`app/resources/js/api/client.ts`):** новые api-модули импортируют `{ apiClient, ensureCsrfCookie }` из `./client`. GET-запросы НЕ вызывают `ensureCsrfCookie()`; мутации (POST) вызывают `await ensureCsrfCookie()` первой строкой. `extractErrorMessage(error, fallback?)` / `extractValidationErrors(error)` — функции обработки ошибок.

**Локальный rule окружения:**

- Laravel-приложение в подпапке `app/` — `composer`/`php artisan` запускать из `app/`; `npm` — из корня репозитория (`vitest.config.ts` в корне).
- Рабочий путь содержит кириллицу — quirk #85: после `Write`/`Edit` файла убедиться, что файл реально записан (последующий запуск тестов это поймает).
- `git add` — **только явные пути затронутых файлов.** НЕ `git add -A`, НЕ трогать `app/dev-indices.json` (pre-existing dev-артефакт, uncommitted).
- НЕ `git push`. Hooks bypass (`--no-verify`) запрещён — lefthook pre-commit должен пройти зелёным на каждом коммите.
- Quirk 72: `CleanupInactiveSupplierProjectsJobTest` даёт случайный сбой под `--parallel` (Redis `supplier:session` race), проходит последовательно. Если supplier-dir-тест упал под `--parallel` — перезапустить эту папку последовательно (`php artisan test tests/Feature/Supplier/`) для классификации quirk-vs-регрессия.

**Регрессионный baseline (после закрытия Plan B):** Pest `--parallel` **766 / 763 passed / 3 skipped / 0 failed**; Vitest **94 файла / 787 passed / 3 skipped**; vue-tsc 0, ESLint 0, Pint clean, Larastan 0.

---

## Design decisions (зафиксированы на recon)

1. **E1 topup — MVP-stub.** `POST /api/billing/topup` кредитует `tenants.balance_rub` немедленно и пишет append-only строку `balance_transactions(type='topup')`. Платёжный шлюз (ЮKassa) НЕ интегрируется — реальная оплата зависит от Б-1 (реквизиты ООО). Это явно прописано в audit-spec («stub без реальной оплаты на MVP») и в строке блокеров Б-1.
2. **Отдельный `BillingTopupService`**, не метод `LedgerService`. `LedgerService` документирован как «командный сервис на горячем пути доставки лида» (debit-only, вызывается под `lockForUpdate` внутри job-транзакции). Пополнение — другой путь (HTTP, user-initiated). SRP: отдельный маленький сервис без лишних зависимостей (`PricingTierResolver`/`PricingTierRepository` пополнению не нужны).
3. **`runway_days`** («хватит на N дней») — оценочный UX-показатель: `balance_rub / (рублёвые списания за 30 дней / 30)`. `null`, если списаний не было. Считается float'ом — это грубая оценка для шапки, НЕ мутация баланса; погрешность ±1 день несущественна.
4. **Транзакции real-API не имеют статуса.** `balance_transactions` — append-only ledger; строка появляется только по факту состоявшейся операции. Колонок `status`/`code` в схеме нет. → В real-API модели транзакции нет поля `status`; `code` вычисляется на клиенте как `TX-{id}`. Mock-статусы `pending`/`rejected` относятся к эпику E4 (pending-баннер) — вне scope Plan C.
5. **Invoices — real-but-empty.** `GET /api/billing/invoices` читает `saas_invoices` (RLS-изоляция). На MVP таблица пуста (`legal_entity_id NOT NULL` → требует зарегистрированного юр-лица, блокируется Б-1). Эндпоинт настоящий: при появлении строк отдаёт их; `InvoicesTable` показывает empty-state. УПД (`saas_upd_documents`) — вне scope (отдельная таблица, на MVP тоже пуста; эндпоинта нет).
6. **Pending-баннер остаётся mock.** Эпик E4 («BillingView pending alert — real data») — P2, Sprint 5, вне Plan C. `BillingView` продолжает импортировать `MOCK_PENDING`; `mockBilling.ts` ужимается до `PendingPayment` + `MOCK_PENDING`. Это соответствие утверждённому порядку приоритетов P0→P1→P2.
7. **`TariffPlan` модель создаётся** (доменная сущность каталога тарифов; `Tenant::tariff()` relation). `SaasInvoice` модель НЕ создаётся — invoices читаются `DB::table('saas_invoices')` (read-only выборка, паттерн `AdminBillingController`); на MVP всегда пусто, полноценная модель+фабрика были бы YAGNI.
8. **Sub-компоненты сами тянут данные.** `TransactionsTable` и `InvoicesTable` переписываются на self-fetching (паттерн `ChargesTab.vue`), `BillingView` тянет только `wallet`. Новые компоненты используют `apiClient` через `api/billing.ts` (НЕ raw axios — у `ChargesTab` raw axios это legacy).

## Структура файлов

**Backend (создаются):**

- `app/app/Services/Billing/BillingTopupService.php` — append-only ledger-credit.
- `app/app/Http/Controllers/Api/BillingController.php` — 4 эндпоинта.
- `app/app/Models/TariffPlan.php` — каталог тарифов.
- `app/database/factories/BalanceTransactionFactory.php` — для тестов transactions-эндпоинта.
- `app/tests/Feature/Billing/BillingTopupServiceTest.php`, `TopupControllerTest.php`, `BillingOverviewControllerTest.php`.

**Backend (модифицируются):**

- `app/routes/web.php` — группа `/api/billing` (topup/wallet/transactions/invoices).
- `app/app/Models/Tenant.php` — `tariff()` relation.
- `app/app/Models/BalanceTransaction.php` — `HasFactory` trait.
- `app/phpstan-baseline.neon` — Pest false-positives новых тест-файлов.

**Frontend (создаются):**

- `app/resources/js/api/billing.ts` — api-модуль биллинга.
- `app/resources/js/components/billing/TopupDialog.vue` — диалог пополнения.
- `app/tests/Frontend/TransactionsTable.spec.ts`, `InvoicesTable.spec.ts`, `TopupDialog.spec.ts`.

**Frontend (модифицируются):**

- `app/resources/js/views/BillingView.vue` — wallet-fetch + topup-wiring.
- `app/resources/js/components/billing/BalanceCard.vue` — real props + nullable tariff + topup-emit.
- `app/resources/js/components/billing/TransactionsTable.vue` — self-fetching.
- `app/resources/js/components/billing/InvoicesTable.vue` — self-fetching.
- `app/resources/js/composables/billingFormatters.ts` — `featureLabel` + retype `txAmountClass`, drop status/format-функций.
- `app/resources/js/composables/mockBilling.ts` — ужать до pending-баннера.
- `app/tests/Frontend/BillingView.spec.ts` — переписать на mock api.

**Маппинг tasks → audit ID → коммиты (5 атомарных коммитов):**

| Task | Audit | Коммит |
|---|---|---|
| 1 | E1 backend | `feat(billing): topup ledger service + POST /api/billing/topup stub (E1)` |
| 2 | E3 backend | `feat(billing): wallet/transactions/invoices read API (E3)` |
| 3 | E3 frontend pt1 | `feat(billing): BillingView wallet + BalanceCard real API (E3)` |
| 4 | E3 frontend pt2 | `feat(billing): TransactionsTable + InvoicesTable real API (E3)` |
| 5 | E1 frontend | `feat(billing): TopupDialog + Пополнить wiring (E1)` |

---

## Task 1: E1 backend — BillingTopupService + POST /api/billing/topup

**Files:**

- Create: `app/app/Services/Billing/BillingTopupService.php`
- Create: `app/app/Http/Controllers/Api/BillingController.php`
- Modify: `app/routes/web.php` (после группы `/api/billing/charges`, ~строка 126)
- Create: `app/tests/Feature/Billing/BillingTopupServiceTest.php`
- Create: `app/tests/Feature/Billing/TopupControllerTest.php`
- Modify: `app/phpstan-baseline.neon` (при необходимости — Pest false-positives)

- [ ] **Step 1: Написать failing-тест сервиса**

Создать `app/tests/Feature/Billing/BillingTopupServiceTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\BalanceTransaction;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Billing\BillingTopupService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;

uses(DatabaseTransactions::class);

test('topup кредитует balance_rub и пишет append-only строку topup', function () {
    $tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'balance_leads' => 7]);

    $tx = app(BillingTopupService::class)->topup($tenant->id, '250.00', null);

    expect($tx->type)->toBe('topup')
        ->and($tx->amount_rub)->toBe('250.00')
        ->and($tx->amount_leads)->toBe(0)
        ->and($tx->balance_rub_after)->toBe('750.00')
        ->and($tx->balance_leads_after)->toBe(7);

    expect((string) $tenant->fresh()->balance_rub)->toBe('750.00');
});

test('topup использует bcmath — нет float-дрейфа на 0.1 + 0.2', function () {
    $tenant = Tenant::factory()->create(['balance_rub' => '0.10']);

    app(BillingTopupService::class)->topup($tenant->id, '0.20', null);

    expect((string) $tenant->fresh()->balance_rub)->toBe('0.30');
});

test('topup фиксирует user_id инициатора', function () {
    $tenant = Tenant::factory()->create(['balance_rub' => '0.00']);
    $user = User::factory()->create(['tenant_id' => $tenant->id]);

    $tx = app(BillingTopupService::class)->topup($tenant->id, '100.00', $user->id);

    expect($tx->user_id)->toBe($user->id);
});

test('topup-строка получает log_hash через append-only hash-chain триггер', function () {
    $tenant = Tenant::factory()->create(['balance_rub' => '0.00']);

    $tx = app(BillingTopupService::class)->topup($tenant->id, '100.00', null);

    $hasHash = DB::table('balance_transactions')
        ->where('id', $tx->id)
        ->whereNotNull('log_hash')
        ->exists();
    expect($hasHash)->toBeTrue();
});
```

- [ ] **Step 2: Запустить тест — убедиться, что падает**

Из `app/`: `php artisan test tests/Feature/Billing/BillingTopupServiceTest.php`
Expected: FAIL — `Class "App\Services\Billing\BillingTopupService" not found`.

- [ ] **Step 3: Реализовать `BillingTopupService`**

Создать `app/app/Services/Billing/BillingTopupService.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Billing;

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

/**
 * Сервис пополнения рублёвого баланса тенанта (audit E1).
 *
 * MVP-stub: кредитует tenants.balance_rub немедленно и пишет строку
 * balance_transactions(type='topup'). Реальная оплата через платёжный
 * шлюз — post-Б-1 (требует реквизитов ООО), здесь НЕ интегрирована.
 *
 * Контракт: вызывается ВНУТРИ транзакции (middleware `tenant` оборачивает
 * HTTP-запрос в DB-транзакцию). lockForUpdate на строке tenant защищает от
 * lost-update при конкурентных topup/charge.
 *
 * balance_transactions защищена hash-chain триггером (BEFORE INSERT
 * audit_chain_hash) — log_hash заполняется автоматически. UPDATE/DELETE
 * на таблице запрещены триггером audit_block_mutation, поэтому каждое
 * пополнение — отдельная append-only строка; существующие не меняются.
 */
final class BillingTopupService
{
    /**
     * Пополнить рублёвый баланс тенанта.
     *
     * @param  int  $tenantId  ID тенанта.
     * @param  string  $amountRub  Сумма пополнения, DECIMAL-строка («100.00»).
     * @param  int|null  $userId  Кто инициировал (NULL — системное).
     * @return BalanceTransaction Созданная append-only строка ledger'а.
     */
    public function topup(int $tenantId, string $amountRub, ?int $userId): BalanceTransaction
    {
        /** @var Tenant $tenant */
        $tenant = Tenant::query()->lockForUpdate()->findOrFail($tenantId);

        // bcadd — DECIMAL-точность, НЕ PHP float (паттерн LedgerService).
        $newBalanceRub = bcadd((string) $tenant->balance_rub, $amountRub, 2);

        $tenant->balance_rub = $newBalanceRub;
        $tenant->save();

        return BalanceTransaction::create([
            'tenant_id' => $tenant->id,
            'type' => BalanceTransaction::TYPE_TOPUP,
            'amount_rub' => $amountRub,
            'amount_leads' => 0,
            'balance_rub_after' => $newBalanceRub,
            'balance_leads_after' => (int) $tenant->balance_leads,
            'description' => 'Пополнение баланса',
            'user_id' => $userId,
            'created_at' => now(),
        ]);
    }
}
```

- [ ] **Step 4: Запустить тест — убедиться, что прошёл**

Из `app/`: `php artisan test tests/Feature/Billing/BillingTopupServiceTest.php`
Expected: PASS — 4 passed.

- [ ] **Step 5: Написать failing-тест контроллера**

Создать `app/tests/Feature/Billing/TopupControllerTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;

uses(DatabaseTransactions::class);

beforeEach(function () {
    $this->tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'balance_leads' => 12]);
    $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
    $this->actingAs($this->user);
});

test('POST /api/billing/topup кредитует баланс и возвращает 201', function () {
    $response = $this->postJson('/api/billing/topup', ['amount_rub' => 250]);

    $response->assertStatus(201)
        ->assertJsonPath('balance_rub', '750.00')
        ->assertJsonPath('transaction.type', 'topup')
        ->assertJsonPath('transaction.amount_rub', '250.00');

    expect((string) $this->tenant->fresh()->balance_rub)->toBe('750.00');
});

test('POST /api/billing/topup пишет строку balance_transactions с user_id', function () {
    $this->postJson('/api/billing/topup', ['amount_rub' => 100])->assertStatus(201);

    $this->assertDatabaseHas('balance_transactions', [
        'tenant_id' => $this->tenant->id,
        'user_id' => $this->user->id,
        'type' => 'topup',
        'amount_rub' => '100.00',
    ]);
});

test('POST /api/billing/topup использует bcmath-точность', function () {
    $this->tenant->update(['balance_rub' => '0.10']);

    $this->postJson('/api/billing/topup', ['amount_rub' => 100.20])->assertStatus(201);

    expect((string) $this->tenant->fresh()->balance_rub)->toBe('100.30');
});

test('POST /api/billing/topup отклоняет сумму ниже минимума 100 ₽', function () {
    $this->postJson('/api/billing/topup', ['amount_rub' => 50])
        ->assertStatus(422)
        ->assertJsonValidationErrors('amount_rub');
});

test('POST /api/billing/topup отклоняет отсутствующую сумму', function () {
    $this->postJson('/api/billing/topup', [])
        ->assertStatus(422)
        ->assertJsonValidationErrors('amount_rub');
});

test('POST /api/billing/topup отклоняет более 2 знаков после запятой', function () {
    $this->postJson('/api/billing/topup', ['amount_rub' => 100.123])
        ->assertStatus(422)
        ->assertJsonValidationErrors('amount_rub');
});

test('POST /api/billing/topup без auth: 401', function () {
    auth()->logout();
    $this->postJson('/api/billing/topup', ['amount_rub' => 100])->assertStatus(401);
});
```

- [ ] **Step 6: Запустить — убедиться, что падает**

Из `app/`: `php artisan test tests/Feature/Billing/TopupControllerTest.php`
Expected: FAIL — route `/api/billing/topup` не существует (404, не 201/422).

- [ ] **Step 7: Реализовать `BillingController`**

Создать `app/app/Http/Controllers/Api/BillingController.php`:

```php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Billing\BillingTopupService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

/**
 * Биллинг тенанта — кошелёк, транзакции, счета, пополнение (audit E1/E3).
 *
 * Все эндпоинты под middleware [auth:sanctum, tenant] (RLS-контекст).
 * Отдельно от TenantChargesController (lead_charges ledger) и
 * AdminBillingController (SaaS-уровневые агрегаты).
 *
 * E1: POST /api/billing/topup — MVP-stub пополнения (без платёжного шлюза).
 * E3: GET wallet/transactions/invoices — данные для BillingView Overview.
 */
class BillingController extends Controller
{
    public function __construct(
        private readonly BillingTopupService $topupService,
    ) {}

    /**
     * POST /api/billing/topup — пополнить рублёвый баланс.
     *
     * MVP-stub: кредитует баланс немедленно (без ЮKassa — реальная оплата
     * post-Б-1). Записывает append-only строку balance_transactions(topup).
     */
    public function topup(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'amount_rub' => ['required', 'numeric', 'min:100', 'max:1000000', 'decimal:0,2'],
        ]);

        /** @var User $user */
        $user = $request->user();

        // Нормализуем в DECIMAL-строку scale 2 для bcmath (НЕ float).
        $amountRub = bcadd((string) $validated['amount_rub'], '0', 2);

        $tx = $this->topupService->topup((int) $user->tenant_id, $amountRub, (int) $user->id);

        return response()->json([
            'transaction' => [
                'id' => $tx->id,
                'type' => $tx->type,
                'amount_rub' => $tx->amount_rub,
                'balance_rub_after' => $tx->balance_rub_after,
                'created_at' => $tx->created_at,
            ],
            'balance_rub' => $tx->balance_rub_after,
        ], 201);
    }
}
```

- [ ] **Step 8: Добавить route**

В `app/routes/web.php` — после группы `/api/billing/charges` (заканчивается на строке ~126 `});`), вставить:

```php
// Биллинг тенанта: пополнение/кошелёк/транзакции/счета (audit E1/E3).
// RLS на balance_transactions / saas_invoices требует tenant middleware.
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing')->group(function () {
    Route::post('/topup', 'App\Http\Controllers\Api\BillingController@topup');
});
```

- [ ] **Step 9: Запустить тесты контроллера + сервиса**

Из `app/`: `php artisan test tests/Feature/Billing/TopupControllerTest.php tests/Feature/Billing/BillingTopupServiceTest.php`
Expected: PASS — 11 passed (4 + 7).

- [ ] **Step 10: Pint + PHPStan**

Из `app/`:

- `composer pint` — формат чистый.
- `composer stan` — если ошибки только в новых тест-файлах (Pest `TestCall` false-positives) → `composer stan -- --generate-baseline`, затем `composer stan` снова (Expected: 0 errors). Проверить `git diff app/phpstan-baseline.neon` — добавляются только записи для `BillingTopupServiceTest`/`TopupControllerTest` (возможно удаляются устаревшие unrelated `ignore` — это безопасно, quirk #87).

- [ ] **Step 11: Регрессионный sweep**

Из `app/`: `composer test:parallel` — Expected: 770/767/3sk/0 (+4 относительно baseline 766/763; новые сервис+контроллер тесты, минус нет). Если supplier-dir тест дал случайный сбой под `--parallel` — перезапустить `php artisan test tests/Feature/Supplier/` последовательно для классификации (quirk 72).

- [ ] **Step 12: Commit**

```bash
git add app/app/Services/Billing/BillingTopupService.php app/app/Http/Controllers/Api/BillingController.php app/routes/web.php app/tests/Feature/Billing/BillingTopupServiceTest.php app/tests/Feature/Billing/TopupControllerTest.php app/phpstan-baseline.neon
git commit -m "$(cat <<'EOF'
feat(billing): topup ledger service + POST /api/billing/topup stub (E1)

BillingTopupService кредитует tenants.balance_rub (bcmath) и пишет
append-only строку balance_transactions(type='topup'). BillingController
+ route POST /api/billing/topup под [auth:sanctum, tenant]. MVP-stub:
без платёжного шлюза (ЮKassa — post-Б-1).

Sprint 2 Plan C, audit E1 (backend).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 2: E3 backend — TariffPlan + wallet/transactions/invoices

**Files:**

- Create: `app/app/Models/TariffPlan.php`
- Modify: `app/app/Models/Tenant.php` (relation `tariff()`)
- Modify: `app/app/Models/BalanceTransaction.php` (trait `HasFactory`)
- Create: `app/database/factories/BalanceTransactionFactory.php`
- Modify: `app/app/Http/Controllers/Api/BillingController.php` (3 метода)
- Modify: `app/routes/web.php` (3 GET-роута в группу `/api/billing`)
- Create: `app/tests/Feature/Billing/BillingOverviewControllerTest.php`
- Modify: `app/phpstan-baseline.neon`

- [ ] **Step 1: Написать failing-тест трёх эндпоинтов**

Создать `app/tests/Feature/Billing/BillingOverviewControllerTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\BalanceTransaction;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;

uses(DatabaseTransactions::class);

beforeEach(function () {
    $this->tenant = Tenant::factory()->create([
        'balance_rub' => '14250.00',
        'balance_leads' => 285,
    ]);
    $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
    $this->actingAs($this->user);
});

// ---- wallet ----

test('GET /api/billing/wallet возвращает баланс тенанта', function () {
    $this->getJson('/api/billing/wallet')
        ->assertOk()
        ->assertJsonPath('balance_rub', '14250.00')
        ->assertJsonPath('balance_leads', 285);
});

test('GET /api/billing/wallet возвращает тариф, если он назначен', function () {
    $tariffId = DB::table('tariff_plans')->where('code', 'pro')->value('id');
    $this->tenant->update(['current_tariff_id' => $tariffId]);

    $response = $this->getJson('/api/billing/wallet');

    $response->assertOk()
        ->assertJsonPath('tariff.code', 'pro')
        ->assertJsonPath('tariff.name', 'Про');
    expect($response->json('tariff.features'))->toBeArray();
});

test('GET /api/billing/wallet возвращает tariff=null без назначенного тарифа', function () {
    $this->getJson('/api/billing/wallet')
        ->assertOk()
        ->assertJsonPath('tariff', null);
});

test('GET /api/billing/wallet: runway_days = null без списаний', function () {
    $this->getJson('/api/billing/wallet')
        ->assertOk()
        ->assertJsonPath('runway_days', null);
});

test('GET /api/billing/wallet: runway_days рассчитан при наличии списаний', function () {
    BalanceTransaction::factory()->create([
        'tenant_id' => $this->tenant->id,
        'type' => 'lead_charge',
        'amount_rub' => '-3000.00',
        'created_at' => now()->subDays(10),
    ]);

    // 3000 ₽ / 30 дн = 100 ₽/день; баланс 14250 → floor(142.5) = 142.
    expect($this->getJson('/api/billing/wallet')->json('runway_days'))->toBe(142);
});

test('GET /api/billing/wallet без auth: 401', function () {
    auth()->logout();
    $this->getJson('/api/billing/wallet')->assertStatus(401);
});

// ---- transactions ----

test('GET /api/billing/transactions возвращает транзакции тенанта', function () {
    BalanceTransaction::factory()->count(3)->create(['tenant_id' => $this->tenant->id]);

    $response = $this->getJson('/api/billing/transactions');

    $response->assertOk();
    expect($response->json('data'))->toHaveCount(3);
    expect($response->json('meta.total'))->toBe(3);
    expect($response->json('data.0'))->toHaveKeys(['id', 'code', 'type', 'amount_rub', 'created_at']);
});

test('GET /api/billing/transactions изолирован по тенанту', function () {
    BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id]);
    $other = Tenant::factory()->create();
    BalanceTransaction::factory()->create(['tenant_id' => $other->id]);

    expect($this->getJson('/api/billing/transactions')->json('data'))->toHaveCount(1);
});

test('GET /api/billing/transactions фильтрует по type', function () {
    BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id, 'type' => 'topup']);
    BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id, 'type' => 'lead_charge', 'amount_rub' => '-50.00']);
    BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id, 'type' => 'refund', 'amount_rub' => '10.00']);

    $this->getJson('/api/billing/transactions?type=topup')->assertJsonCount(1, 'data');
    $this->getJson('/api/billing/transactions?type=lead_charge')->assertJsonCount(1, 'data');
    $this->getJson('/api/billing/transactions?type=refund')->assertJsonCount(1, 'data');
});

test('GET /api/billing/transactions: пагинация 20/страница', function () {
    BalanceTransaction::factory()->count(25)->create(['tenant_id' => $this->tenant->id]);

    expect($this->getJson('/api/billing/transactions?page=1')->json('data'))->toHaveCount(20);
    expect($this->getJson('/api/billing/transactions?page=2')->json('data'))->toHaveCount(5);
});

test('GET /api/billing/transactions без auth: 401', function () {
    auth()->logout();
    $this->getJson('/api/billing/transactions')->assertStatus(401);
});

// ---- invoices ----

test('GET /api/billing/invoices возвращает пустой список без счетов', function () {
    $this->getJson('/api/billing/invoices')
        ->assertOk()
        ->assertJsonCount(0, 'data');
});

test('GET /api/billing/invoices возвращает счета тенанта и изолирует чужие', function () {
    $leId = DB::table('legal_entities')->insertGetId([
        'code' => 'ooo_test_'.uniqid(),
        'name' => 'ООО Тест',
        'legal_form' => 'OOO',
        'inn' => '7700000000',
        'created_at' => now(),
    ]);
    DB::table('saas_invoices')->insert([
        'tenant_id' => $this->tenant->id,
        'legal_entity_id' => $leId,
        'invoice_number' => 'СЧ-2026-00001',
        'payer_type' => 'legal',
        'amount_net' => '990.00',
        'amount_total' => '990.00',
        'status' => 'issued',
        'issued_at' => now(),
        'expires_at' => now()->addDays(5),
    ]);
    $other = Tenant::factory()->create();
    DB::table('saas_invoices')->insert([
        'tenant_id' => $other->id,
        'legal_entity_id' => $leId,
        'invoice_number' => 'СЧ-2026-00002',
        'payer_type' => 'legal',
        'amount_net' => '500.00',
        'amount_total' => '500.00',
        'status' => 'issued',
        'issued_at' => now(),
        'expires_at' => now()->addDays(5),
    ]);

    $response = $this->getJson('/api/billing/invoices');

    $response->assertOk();
    expect($response->json('data'))->toHaveCount(1);
    expect($response->json('data.0.invoice_number'))->toBe('СЧ-2026-00001');
});

test('GET /api/billing/invoices без auth: 401', function () {
    auth()->logout();
    $this->getJson('/api/billing/invoices')->assertStatus(401);
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Из `app/`: `php artisan test tests/Feature/Billing/BillingOverviewControllerTest.php`
Expected: FAIL — `BalanceTransaction::factory()` не существует / роуты 404.

- [ ] **Step 3: Создать модель `TariffPlan`**

Создать `app/app/Models/TariffPlan.php`:

```php
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

/**
 * Тарифный план SaaS-портала (каталог tariff_plans).
 *
 * Сидится из db/schema.sql (4 стартовых плана: start/basic/pro/enterprise).
 * Read-mostly: редактируется только админкой SaaS. Tenant ссылается через
 * tenants.current_tariff_id (см. Tenant::tariff()).
 *
 * Источник: db/schema.sql §20.2.1, table `tariff_plans`.
 *
 * @mixin IdeHelperTariffPlan
 */
class TariffPlan extends Model
{
    protected $fillable = [
        'code',
        'name',
        'description',
        'billing_model',
        'price_per_lead',
        'price_monthly',
        'included_leads',
        'limits',
        'features',
        'trial_bonus_leads',
        'is_active',
        'is_public',
        'sort_order',
    ];

    protected function casts(): array
    {
        return [
            'price_per_lead' => 'decimal:2',
            'price_monthly' => 'decimal:2',
            'included_leads' => 'integer',
            'limits' => 'array',
            'features' => 'array',
            'trial_bonus_leads' => 'integer',
            'is_active' => 'boolean',
            'is_public' => 'boolean',
            'sort_order' => 'integer',
            'created_at' => 'datetime',
            'updated_at' => 'datetime',
        ];
    }
}
```

- [ ] **Step 4: Добавить relation `tariff()` в `Tenant`**

В `app/app/Models/Tenant.php`:

1. После `use Illuminate\Database\Eloquent\Relations\HasMany;` добавить:

```php
use Illuminate\Database\Eloquent\Relations\BelongsTo;
```

1. После метода `projects()` (перед закрывающей `}` класса) добавить:

```php

    /** @return BelongsTo<TariffPlan, $this> */
    public function tariff(): BelongsTo
    {
        return $this->belongsTo(TariffPlan::class, 'current_tariff_id');
    }
```

- [ ] **Step 5: Добавить `HasFactory` в `BalanceTransaction`**

В `app/app/Models/BalanceTransaction.php`:

1. После `use Illuminate\Database\Eloquent\Model;` добавить:

```php
use Database\Factories\BalanceTransactionFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
```

1. Внутри тела класса `BalanceTransaction` — между открывающей `{` и первой строкой `public const TYPE_TRIAL_BONUS = 'trial_bonus';` — вставить trait:

```php
    /** @use HasFactory<BalanceTransactionFactory> */
    use HasFactory;

```

Результат — начало класса выглядит так:

```php
class BalanceTransaction extends Model
{
    /** @use HasFactory<BalanceTransactionFactory> */
    use HasFactory;

    public const TYPE_TRIAL_BONUS = 'trial_bonus';
```

- [ ] **Step 6: Создать `BalanceTransactionFactory`**

Создать `app/database/factories/BalanceTransactionFactory.php`:

```php
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\BalanceTransaction;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<BalanceTransaction>
 */
class BalanceTransactionFactory extends Factory
{
    protected $model = BalanceTransaction::class;

    /**
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'tenant_id' => Tenant::factory(),
            'type' => BalanceTransaction::TYPE_TOPUP,
            'amount_rub' => '100.00',
            'amount_leads' => 0,
            'balance_rub_after' => '100.00',
            'balance_leads_after' => 0,
            'description' => 'Тестовая транзакция',
            'created_at' => now(),
        ];
    }
}
```

- [ ] **Step 7: Добавить 3 метода в `BillingController`**

В `app/app/Http/Controllers/Api/BillingController.php`:

1. В блок `use` добавить (по алфавиту):

```php
use App\Models\BalanceTransaction;
use App\Models\Tenant;
use Illuminate\Support\Facades\DB;
```

1. Перед закрывающей `}` класса (после метода `topup()`) добавить:

```php

    /**
     * GET /api/billing/wallet — балансы тенанта + текущий тариф + runway.
     */
    public function wallet(Request $request): JsonResponse
    {
        /** @var User $user */
        $user = $request->user();
        /** @var Tenant $tenant */
        $tenant = Tenant::query()->with('tariff')->findOrFail($user->tenant_id);

        return response()->json([
            'balance_rub' => $tenant->balance_rub,
            'balance_leads' => $tenant->balance_leads,
            'runway_days' => $this->runwayDays($tenant),
            'tariff' => $tenant->tariff === null ? null : [
                'code' => $tenant->tariff->code,
                'name' => $tenant->tariff->name,
                'price_monthly' => $tenant->tariff->price_monthly,
                'billing_model' => $tenant->tariff->billing_model,
                'features' => $tenant->tariff->features ?? [],
            ],
        ]);
    }

    /**
     * GET /api/billing/transactions?type=topup|lead_charge|refund&page=N
     * — пагинированная история balance_transactions тенанта (20/страница).
     */
    public function transactions(Request $request): JsonResponse
    {
        /** @var User $user */
        $user = $request->user();
        $tenantId = (int) $user->tenant_id;

        // Явный tenant_id фильтр — defense-in-depth поверх RLS (тесты идут
        // под superuser BYPASSRLS; паттерн TenantChargesController).
        $query = BalanceTransaction::query()
            ->where('tenant_id', $tenantId)
            ->orderBy('created_at', 'desc')
            ->orderBy('id', 'desc');

        $type = $request->query('type');
        if (is_string($type) && in_array($type, ['topup', 'lead_charge', 'refund'], true)) {
            $query->where('type', $type);
        }

        $page = $query->paginate(20);

        return response()->json([
            'data' => array_map(static fn (BalanceTransaction $tx): array => [
                '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,
                'created_at' => $tx->created_at,
            ], $page->items()),
            'meta' => [
                'current_page' => $page->currentPage(),
                'last_page' => $page->lastPage(),
                'total' => $page->total(),
                'per_page' => $page->perPage(),
            ],
        ]);
    }

    /**
     * GET /api/billing/invoices — счета тенанта (saas_invoices).
     *
     * Real-but-empty на MVP: saas_invoices.legal_entity_id NOT NULL требует
     * зарегистрированного юр-лица (блокируется Б-1). Read-only выборка через
     * DB::table — без Eloquent-модели (паттерн AdminBillingController).
     */
    public function invoices(Request $request): JsonResponse
    {
        /** @var User $user */
        $user = $request->user();
        $tenantId = (int) $user->tenant_id;

        $rows = DB::table('saas_invoices')
            ->where('tenant_id', $tenantId)
            ->orderBy('issued_at', 'desc')
            ->get(['id', 'invoice_number', 'amount_total', 'status', 'issued_at', 'pdf_path']);

        return response()->json([
            'data' => $rows->map(static fn (\stdClass $r): array => [
                'id' => $r->id,
                'invoice_number' => $r->invoice_number,
                'amount_total' => $r->amount_total,
                'status' => $r->status,
                'issued_at' => $r->issued_at,
                'has_pdf' => $r->pdf_path !== null,
            ])->all(),
        ]);
    }

    /**
     * Прогноз «на сколько дней хватит баланса» — оценочный UX-показатель.
     *
     * = balance_rub / (рублёвые списания за 30 дней / 30). NULL, если списаний
     * не было. Float здесь допустим: грубая оценка для шапки, НЕ мутация
     * баланса (мутации баланса — строго bcmath, см. BillingTopupService).
     */
    private function runwayDays(Tenant $tenant): ?int
    {
        $spent = abs((float) DB::table('balance_transactions')
            ->where('tenant_id', $tenant->id)
            ->where('type', BalanceTransaction::TYPE_LEAD_CHARGE)
            ->where('created_at', '>=', now()->subDays(30))
            ->sum('amount_rub'));

        if ($spent <= 0.0) {
            return null;
        }

        $perDay = $spent / 30.0;

        return (int) floor((float) $tenant->balance_rub / $perDay);
    }
```

- [ ] **Step 8: Добавить 3 GET-роута**

В `app/routes/web.php` — в группу `/api/billing` (созданную в Task 1) добавить 3 строки, итог:

```php
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing')->group(function () {
    Route::post('/topup', 'App\Http\Controllers\Api\BillingController@topup');
    Route::get('/wallet', 'App\Http\Controllers\Api\BillingController@wallet');
    Route::get('/transactions', 'App\Http\Controllers\Api\BillingController@transactions');
    Route::get('/invoices', 'App\Http\Controllers\Api\BillingController@invoices');
});
```

- [ ] **Step 9: ide-helper для новой модели**

Из `app/`: `php artisan ide-helper:models -W -M -N` — регенерирует `_ide_helper_models.php` (добавляет `IdeHelperTariffPlan`). Если команда модифицирует другие файлы — не страшно, это служебный stub-файл.

- [ ] **Step 10: Запустить тесты — убедиться, что прошли**

Из `app/`: `php artisan test tests/Feature/Billing/BillingOverviewControllerTest.php`
Expected: PASS — 14 passed.

- [ ] **Step 11: Pint + PHPStan**

Из `app/`:

- `composer pint`
- `composer stan` — при false-positives на `BillingOverviewControllerTest` → `composer stan -- --generate-baseline` → `composer stan` (0 errors). Проверить `git diff app/phpstan-baseline.neon` (additive + возможный drop устаревших — quirk #87, безопасно).

- [ ] **Step 12: Регрессионный sweep**

Из `app/`: `composer test:parallel` — Expected: 784/781/3sk/0 (+14). При случайном сбое supplier-теста под `--parallel` — перезапустить папку последовательно.

- [ ] **Step 13: Commit**

```bash
git add app/app/Models/TariffPlan.php app/app/Models/Tenant.php app/app/Models/BalanceTransaction.php app/database/factories/BalanceTransactionFactory.php app/app/Http/Controllers/Api/BillingController.php app/routes/web.php app/tests/Feature/Billing/BillingOverviewControllerTest.php app/phpstan-baseline.neon app/_ide_helper_models.php
git commit -m "$(cat <<'EOF'
feat(billing): wallet/transactions/invoices read API (E3)

GET /api/billing/wallet (баланс + тариф + runway), /transactions
(пагинированный balance_transactions с фильтром type), /invoices
(saas_invoices, real-but-empty до Б-1). TariffPlan модель +
Tenant::tariff() relation + BalanceTransactionFactory.

Sprint 2 Plan C, audit E3 (backend).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

Примечание: если `php artisan ide-helper:models` не изменил `_ide_helper_models.php` (или изменил несущественно) — включить/исключить файл из `git add` по факту `git status`.

---

## Task 3: E3 frontend pt1 — api/billing.ts + BillingView wallet + BalanceCard

**Files:**

- Create: `app/resources/js/api/billing.ts`
- Modify: `app/resources/js/composables/billingFormatters.ts` (добавить `featureLabel`)
- Modify: `app/resources/js/views/BillingView.vue` (wallet-fetch)
- Modify: `app/resources/js/components/billing/BalanceCard.vue` (real props + nullable tariff)
- Modify: `app/tests/Frontend/BillingView.spec.ts` (переписать на mock api)

- [ ] **Step 1: Создать `api/billing.ts`**

Создать `app/resources/js/api/billing.ts`:

```ts
import { apiClient } from './client';

/**
 * API-модуль биллинга (Sprint 2 Plan C).
 *
 * Эндпоинты под [auth:sanctum, tenant]: GET wallet/transactions/invoices
 * (E3), POST topup (E1 — добавляется в Task 5). GET'ы не требуют CSRF-cookie.
 */

/** Тариф в составе ответа GET /api/billing/wallet. */
export interface WalletTariff {
    code: string;
    name: string;
    price_monthly: string | null;
    billing_model: string;
    features: string[];
}

/** Ответ GET /api/billing/wallet — кошелёк тенанта. */
export interface Wallet {
    balance_rub: string;
    balance_leads: number;
    runway_days: number | null;
    tariff: WalletTariff | null;
}

/** GET /api/billing/wallet — балансы + текущий тариф + runway. */
export async function getWallet(): Promise<Wallet> {
    const { data } = await apiClient.get<Wallet>('/api/billing/wallet');
    return data;
}
```

- [ ] **Step 2: Добавить `featureLabel` в `billingFormatters.ts`**

В `app/resources/js/composables/billingFormatters.ts` — в конец файла добавить:

```ts

/** Человекочитаемые лейблы для feature-слагов tariff_plans.features. */
export const FEATURE_LABELS: Record<string, string> = {
    webhook: 'Webhook',
    kanban: 'Канбан',
    basic_analytics: 'Базовая аналитика',
    advanced_analytics: 'Расширенная аналитика',
    api: 'API',
    '2fa': 'Двухфакторная аутентификация',
    custom_domain: 'Свой домен',
};

/** Лейбл feature-слага; неизвестный слаг возвращается как есть. */
export function featureLabel(slug: string): string {
    return FEATURE_LABELS[slug] ?? slug;
}
```

(Status/format-функции `txAmountClass`/`statusChipColor`/`statusLabel`/`formatLabel`/`formatIcon` в Task 3 НЕ трогаем — их ещё использует старая `TransactionsTable`/`InvoicesTable`. Чистка — в Task 4.)

- [ ] **Step 3: Написать failing-тест `BillingView.spec.ts`**

Полностью заменить содержимое `app/tests/Frontend/BillingView.spec.ts`:

```ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import BillingView from '../../resources/js/views/BillingView.vue';
import * as billingApi from '../../resources/js/api/billing';
import type { Wallet } from '../../resources/js/api/billing';

vi.mock('../../resources/js/api/billing');

const vuetify = createVuetify();

function makeWallet(): Wallet {
    return {
        balance_rub: '14250.00',
        balance_leads: 285,
        runway_days: 142,
        tariff: {
            code: 'pro',
            name: 'Про',
            price_monthly: '990.00',
            billing_model: 'hybrid',
            features: ['webhook', 'kanban', 'api'],
        },
    };
}

describe('BillingView.vue', () => {
    beforeEach(() => {
        vi.mocked(billingApi.getWallet).mockResolvedValue(makeWallet());
    });

    const factory = () =>
        mount(BillingView, {
            global: {
                plugins: [vuetify],
                stubs: { TransactionsTable: true, InvoicesTable: true, ChargesTab: true },
            },
        });

    it('монтируется с заголовком «Биллинг и тарифы»', async () => {
        const wrapper = factory();
        await flushPromises();
        expect(wrapper.find('h1').text()).toBe('Биллинг и тарифы');
    });

    it('загружает кошелёк и показывает баланс в шапке', async () => {
        const wrapper = factory();
        await flushPromises();
        expect(billingApi.getWallet).toHaveBeenCalled();
        const text = wrapper.text();
        expect(text).toMatch(/14\s+250\s*₽/);
        expect(text).toContain('285');
    });

    it('показывает тариф из API в BalanceCard', async () => {
        const wrapper = factory();
        await flushPromises();
        const text = wrapper.text();
        expect(text).toContain('Про');
        expect(text).toContain('Канбан');
        expect(text).toContain('Webhook');
    });

    it('показывает «Тариф не выбран» при tariff=null', async () => {
        vi.mocked(billingApi.getWallet).mockResolvedValue({
            balance_rub: '0.00',
            balance_leads: 0,
            runway_days: null,
            tariff: null,
        });
        const wrapper = factory();
        await flushPromises();
        expect(wrapper.text()).toContain('Тариф не выбран');
    });

    it('показывает error-alert при сбое загрузки кошелька', async () => {
        vi.mocked(billingApi.getWallet).mockRejectedValue(new Error('network'));
        const wrapper = factory();
        await flushPromises();
        expect(wrapper.text()).toContain('Не удалось загрузить');
    });

    it('содержит табы Обзор / Списания', async () => {
        const wrapper = factory();
        await flushPromises();
        const text = wrapper.text();
        expect(text).toContain('Обзор');
        expect(text).toContain('Списания');
    });
});
```

- [ ] **Step 4: Запустить — убедиться, что падает**

Из корня репозитория: `npm run test:vue -- BillingView`
Expected: FAIL — `BillingView` ещё импортирует mock, нет `getWallet`-вызова.

- [ ] **Step 5: Переписать `BalanceCard.vue`**

Полностью заменить `app/resources/js/components/billing/BalanceCard.vue`:

```vue
<script setup lang="ts">
/**
 * BalanceCard — 3 wallet-cards в одной строке: Кошелёк ₽ (dark) +
 * Баланс лидов + Тариф. Данные — из GET /api/billing/wallet (E3).
 * tariff* допускают null (тенант без назначенного тарифа — trial).
 */
import { computed } from 'vue';

const props = defineProps<{
    walletRub: number;
    leadsBalance: number;
    tariffName: string | null;
    tariffPrice: string | null;
    tariffFeatures: string[];
}>();

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

const tariffPriceText = computed(() => {
    if (props.tariffPrice === null) return 'по запросу';
    return new Intl.NumberFormat('ru-RU').format(Number(props.tariffPrice)) + ' ₽/мес';
});
</script>

<template>
    <v-row dense class="wallet-row mt-4">
        <v-col cols="12" md="4">
            <v-card variant="flat" color="secondary" class="wallet-card primary pa-4">
                <div class="wallet-h">
                    <span class="wallet-label">Кошелёк ₽</span>
                    <v-chip size="x-small" color="primary" variant="elevated">LIVE</v-chip>
                </div>
                <div class="wallet-amount mt-2">
                    <span class="num">{{ walletText }}</span>
                    <span class="ru">&nbsp;₽</span>
                </div>
                <div class="wallet-foot mt-3">мин. пополнение <strong>100 ₽</strong> · округление вниз ₽→лиды</div>
                <div class="wallet-actions mt-3">
                    <v-btn color="primary" variant="flat" prepend-icon="mdi-plus" size="small">Пополнить</v-btn>
                    <v-btn variant="outlined" prepend-icon="mdi-autorenew" size="small"> Автопополнение </v-btn>
                </div>
            </v-card>
        </v-col>

        <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">{{ leadsBalance }}</span>
                    <span class="ru-text">&nbsp;лидов</span>
                </div>
            </v-card>
        </v-col>

        <v-col cols="12" md="4">
            <v-card variant="outlined" class="wallet-card pa-4 d-flex flex-column">
                <span class="wallet-label">Тариф</span>
                <template v-if="tariffName">
                    <div class="tariff-name mt-1">
                        {{ tariffName }}
                        <span class="tariff-price">· {{ tariffPriceText }}</span>
                    </div>
                    <ul v-if="tariffFeatures.length" class="tariff-feats mt-3">
                        <li v-for="f in tariffFeatures" :key="f">
                            <v-icon size="14" color="success" class="mr-1">mdi-check</v-icon>{{ f }}
                        </li>
                    </ul>
                </template>
                <div v-else class="tariff-empty mt-2">Тариф не выбран</div>
                <v-btn variant="outlined" size="small" class="mt-auto">Сменить тариф →</v-btn>
            </v-card>
        </v-col>
    </v-row>
</template>

<style scoped>
.num {
    font-family: 'JetBrains Mono', ui-monospace, monospace;
    font-feature-settings: 'tnum';
    font-weight: 500;
}

.wallet-card {
    height: 100%;
    background: #fff;
}
.wallet-card.primary {
    color: #fff;
    background: #012019 !important;
}
.wallet-h {
    display: flex;
    align-items: center;
    justify-content: space-between;
}
.wallet-label {
    font-size: 12px;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: #7a8c87;
    font-family: 'JetBrains Mono', ui-monospace, monospace;
}
.wallet-card:not(.primary) .wallet-label {
    color: #66635c;
}
.wallet-amount {
    font-size: 32px;
    font-weight: 600;
    line-height: 1.1;
}
.wallet-amount .num {
    color: inherit;
    letter-spacing: -0.01em;
}
.wallet-amount .ru,
.wallet-amount .ru-text {
    color: #66635c;
    font-weight: 500;
    font-size: 18px;
}
.wallet-card.primary .wallet-amount .ru {
    color: #7a8c87;
}
.wallet-foot {
    color: inherit;
    opacity: 0.7;
    font-size: 12px;
}
.wallet-card.primary .wallet-foot {
    color: #b1c2bd;
    opacity: 1;
}
.wallet-actions {
    display: flex;
    gap: 8px;
}

.tariff-name {
    font-weight: 600;
    font-size: 17px;
    color: #081319;
}
.tariff-price {
    font-family: 'JetBrains Mono', ui-monospace, monospace;
    color: #0f6e56;
    font-size: 13px;
    font-weight: 500;
    margin-left: 4px;
}
.tariff-empty {
    color: #66635c;
    font-size: 14px;
}
.tariff-feats {
    list-style: none;
    padding: 0;
    margin: 0;
    display: flex;
    flex-direction: column;
    gap: 6px;
    flex: 1;
}
.tariff-feats li {
    font-size: 13px;
    color: #343c41;
    display: flex;
    align-items: center;
}
</style>
```

- [ ] **Step 6: Переписать `BillingView.vue`**

Полностью заменить `app/resources/js/views/BillingView.vue`:

```vue
<script setup lang="ts">
/**
 * Биллинг и тарифы — финансовый экран. Кошелёк ₽, баланс лидов,
 * текущий тариф, история транзакций и счета.
 *
 * Sprint 2 Plan C (E3): Overview-таб подвязан на real API
 * (GET /api/billing/wallet → BalanceCard + шапка; TransactionsTable и
 * InvoicesTable тянут данные сами). Списания — ChargesTab (Plan 4).
 *
 * Pending-баннер остаётся mock (MOCK_PENDING) — это отдельный эпик E4
 * (Sprint 5). TopupDialog «Пополнить баланс» — Task 5 (E1).
 */
import { ref, computed, onMounted } from 'vue';
import BalanceCard from '../components/billing/BalanceCard.vue';
import TransactionsTable from '../components/billing/TransactionsTable.vue';
import InvoicesTable from '../components/billing/InvoicesTable.vue';
import ChargesTab from './billing/ChargesTab.vue';
import { MOCK_PENDING } from '../composables/mockBilling';
import { formatPlain, featureLabel } from '../composables/billingFormatters';
import { getWallet, type Wallet } from '../api/billing';
import { extractErrorMessage } from '../api/client';

const activeView = ref<'overview' | 'charges'>('overview');

const wallet = ref<Wallet | null>(null);
const loading = ref(true);
const loadError = ref<string | null>(null);

const walletRub = computed(() => Number(wallet.value?.balance_rub ?? 0));
const leadsBalance = computed(() => wallet.value?.balance_leads ?? 0);
const runwayDays = computed(() => wallet.value?.runway_days ?? null);
const tariffName = computed(() => wallet.value?.tariff?.name ?? null);
const tariffPrice = computed(() => wallet.value?.tariff?.price_monthly ?? null);
const tariffFeatures = computed<string[]>(() => (wallet.value?.tariff?.features ?? []).map(featureLabel));

async function loadWallet(): Promise<void> {
    loading.value = true;
    loadError.value = null;
    try {
        wallet.value = await getWallet();
    } catch (e) {
        loadError.value = extractErrorMessage(e, 'Не удалось загрузить данные биллинга.');
    } finally {
        loading.value = false;
    }
}

onMounted(loadWallet);

defineExpose({ loadWallet, wallet });
</script>

<template>
    <v-container fluid class="billing pa-6">
        <header class="page-head">
            <div>
                <h1 class="text-h4 mb-2 page-title">Биллинг и тарифы</h1>
                <div v-if="wallet" class="page-stats text-body-2 text-medium-emphasis">
                    <span
                        ><span class="num text-primary">{{ formatPlain(walletRub) }}</span> кошелёк</span
                    >
                    <span class="sep">·</span>
                    <span
                        ><span class="num">{{ leadsBalance }}</span> лидов запас</span
                    >
                    <template v-if="runwayDays !== null">
                        <span class="sep">·</span>
                        <span
                            >хватит на <span class="num">{{ runwayDays }}</span> дн.</span
                        >
                    </template>
                </div>
            </div>
            <v-btn color="primary" variant="flat" prepend-icon="mdi-plus">Пополнить баланс</v-btn>
        </header>

        <v-tabs v-model="activeView" color="primary" class="mt-4">
            <v-tab value="overview">Обзор</v-tab>
            <v-tab value="charges">Списания</v-tab>
        </v-tabs>

        <v-tabs-window v-model="activeView">
            <v-tabs-window-item value="overview">
                <div v-if="loading" class="py-12 d-flex justify-center">
                    <v-progress-circular indeterminate color="primary" />
                </div>

                <v-alert v-else-if="loadError" type="error" variant="tonal" class="mt-4" role="alert">
                    {{ loadError }}
                    <template #append>
                        <v-btn size="small" variant="text" @click="loadWallet">Повторить</v-btn>
                    </template>
                </v-alert>

                <template v-else-if="wallet">
                    <v-alert
                        v-if="MOCK_PENDING"
                        type="info"
                        variant="tonal"
                        density="compact"
                        class="mt-4"
                        role="status"
                    >
                        <strong>1 платёж в обработке</strong> — {{ formatPlain(MOCK_PENDING.amount) }} от
                        {{ MOCK_PENDING.method }}, начат {{ MOCK_PENDING.startedAt }}. Авто-восстановление в
                        {{ MOCK_PENDING.autoCancelAt }} ({{ MOCK_PENDING.timeoutMinutes }} мин).
                    </v-alert>

                    <BalanceCard
                        :wallet-rub="walletRub"
                        :leads-balance="leadsBalance"
                        :tariff-name="tariffName"
                        :tariff-price="tariffPrice"
                        :tariff-features="tariffFeatures"
                    />

                    <TransactionsTable />

                    <InvoicesTable />
                </template>
            </v-tabs-window-item>

            <v-tabs-window-item value="charges">
                <ChargesTab />
            </v-tabs-window-item>
        </v-tabs-window>
    </v-container>
</template>

<style scoped>
.billing {
    max-width: 1440px;
}

.page-head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: 16px;
}
.page-title {
    font-variation-settings: 'opsz' 28;
    letter-spacing: -0.018em;
}
.page-stats {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
    align-items: center;
}
.page-stats .sep {
    /* WCAG2AA 4.5:1: #6b6356 → 5.33:1 on ivory. */
    color: #6b6356;
}

.num {
    font-family: 'JetBrains Mono', ui-monospace, monospace;
    font-feature-settings: 'tnum';
    font-weight: 500;
}
</style>
```

Примечание: в Task 3 `BillingView` всё ещё рендерит СТАРЫЕ `TransactionsTable`/`InvoicesTable` (mock-версии) — они переписываются в Task 4. Тест `BillingView.spec.ts` стабит их (`stubs`), поэтому Task 3 проходит независимо.

- [ ] **Step 7: Запустить тест `BillingView.spec.ts` — убедиться, что прошёл**

Из корня: `npm run test:vue -- BillingView`
Expected: PASS — 6 passed.

- [ ] **Step 8: type-check + lint + полный Vitest**

Из корня:

- `npm run type-check` — Expected: 0 errors.
- `npm run lint:vue` — Expected: 0 errors.
- `npm run test:vue` — Expected: ~93 файла / ~782 passed / 3 skipped (старый `BillingView.spec.ts` имел 11 тестов → стало 6, −5; функционально это переписанный набор). Зафиксировать фактические числа.

- [ ] **Step 9: Commit**

```bash
git add app/resources/js/api/billing.ts app/resources/js/composables/billingFormatters.ts app/resources/js/views/BillingView.vue app/resources/js/components/billing/BalanceCard.vue app/tests/Frontend/BillingView.spec.ts
git commit -m "$(cat <<'EOF'
feat(billing): BillingView wallet + BalanceCard real API (E3)

api/billing.ts (getWallet) + BillingView тянет GET /api/billing/wallet
на mount (шапка + BalanceCard, loading/error-state). BalanceCard на
реальные props с nullable-тарифом. featureLabel для feature-слагов.

Sprint 2 Plan C, audit E3 (frontend pt1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 4: E3 frontend pt2 — TransactionsTable + InvoicesTable real API

**Files:**

- Modify: `app/resources/js/api/billing.ts` (добавить `getTransactions`/`getInvoices`)
- Modify: `app/resources/js/components/billing/TransactionsTable.vue` (self-fetching)
- Modify: `app/resources/js/components/billing/InvoicesTable.vue` (self-fetching)
- Modify: `app/resources/js/composables/billingFormatters.ts` (retype `txAmountClass`, drop status/format-функций)
- Modify: `app/resources/js/composables/mockBilling.ts` (ужать до pending-баннера)
- Create: `app/tests/Frontend/TransactionsTable.spec.ts`
- Create: `app/tests/Frontend/InvoicesTable.spec.ts`

- [ ] **Step 1: Расширить `api/billing.ts`**

В `app/resources/js/api/billing.ts` — в конец файла добавить:

```ts

/** Строка истории транзакций (GET /api/billing/transactions). */
export interface BillingTransaction {
    id: number;
    code: string;
    type: string;
    description: string | null;
    amount_rub: string;
    amount_leads: number;
    balance_rub_after: string | null;
    created_at: string;
}

/** Пагинированный ответ GET /api/billing/transactions. */
export interface TransactionsPage {
    data: BillingTransaction[];
    meta: { current_page: number; last_page: number; total: number; per_page: number };
}

/** Счёт тенанта (GET /api/billing/invoices). */
export interface BillingInvoice {
    id: number;
    invoice_number: string;
    amount_total: string;
    status: string;
    issued_at: string;
    has_pdf: boolean;
}

/** GET /api/billing/transactions — пагинированная история транзакций. */
export async function getTransactions(params: { page?: number; type?: string }): Promise<TransactionsPage> {
    const { data } = await apiClient.get<TransactionsPage>('/api/billing/transactions', { params });
    return data;
}

/** GET /api/billing/invoices — счета тенанта (real-but-empty до Б-1). */
export async function getInvoices(): Promise<{ data: BillingInvoice[] }> {
    const { data } = await apiClient.get<{ data: BillingInvoice[] }>('/api/billing/invoices');
    return data;
}
```

- [ ] **Step 2: Написать failing-тест `TransactionsTable.spec.ts`**

Создать `app/tests/Frontend/TransactionsTable.spec.ts`:

```ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import TransactionsTable from '../../resources/js/components/billing/TransactionsTable.vue';
import * as billingApi from '../../resources/js/api/billing';
import type { BillingTransaction, TransactionsPage } from '../../resources/js/api/billing';

vi.mock('../../resources/js/api/billing');

const vuetify = createVuetify();

function txn(over: Partial<BillingTransaction> = {}): BillingTransaction {
    return {
        id: 1,
        code: 'TX-1',
        type: 'topup',
        description: 'Пополнение баланса',
        amount_rub: '5000.00',
        amount_leads: 0,
        balance_rub_after: '5000.00',
        created_at: '2026-05-10T14:21:00Z',
        ...over,
    };
}

function makePage(txns: BillingTransaction[]): TransactionsPage {
    return { data: txns, meta: { current_page: 1, last_page: 1, total: txns.length, per_page: 20 } };
}

describe('TransactionsTable.vue', () => {
    beforeEach(() => {
        vi.mocked(billingApi.getTransactions).mockResolvedValue(makePage([txn()]));
    });

    it('загружает транзакции при монтировании', async () => {
        const wrapper = mount(TransactionsTable, { global: { plugins: [vuetify] } });
        await flushPromises();
        expect(billingApi.getTransactions).toHaveBeenCalled();
        expect((wrapper.vm as unknown as { total: number }).total).toBe(1);
    });

    it('смена таба «Пополнения» шлёт type=topup', async () => {
        const wrapper = mount(TransactionsTable, { global: { plugins: [vuetify] } });
        await flushPromises();
        await (wrapper.vm as unknown as { changeTab: (id: string) => Promise<void> }).changeTab('topup');
        expect(billingApi.getTransactions).toHaveBeenLastCalledWith(
            expect.objectContaining({ type: 'topup' }),
        );
    });

    it('таб «Все» не шлёт type', async () => {
        const wrapper = mount(TransactionsTable, { global: { plugins: [vuetify] } });
        await flushPromises();
        await (wrapper.vm as unknown as { changeTab: (id: string) => Promise<void> }).changeTab('all');
        const lastCall = vi.mocked(billingApi.getTransactions).mock.calls.at(-1)?.[0];
        expect(lastCall).not.toHaveProperty('type');
    });

    it('показывает error-alert при сбое', async () => {
        vi.mocked(billingApi.getTransactions).mockRejectedValue(new Error('fail'));
        const wrapper = mount(TransactionsTable, { global: { plugins: [vuetify] } });
        await flushPromises();
        expect(wrapper.text()).toContain('Не удалось загрузить транзакции');
    });
});
```

- [ ] **Step 3: Написать failing-тест `InvoicesTable.spec.ts`**

Создать `app/tests/Frontend/InvoicesTable.spec.ts`:

```ts
import { describe, it, expect, vi } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import InvoicesTable from '../../resources/js/components/billing/InvoicesTable.vue';
import * as billingApi from '../../resources/js/api/billing';
import type { BillingInvoice } from '../../resources/js/api/billing';

vi.mock('../../resources/js/api/billing');

const vuetify = createVuetify();

describe('InvoicesTable.vue', () => {
    it('показывает empty-state без счетов', async () => {
        vi.mocked(billingApi.getInvoices).mockResolvedValue({ data: [] });
        const wrapper = mount(InvoicesTable, { global: { plugins: [vuetify] } });
        await flushPromises();
        expect(wrapper.text()).toContain('Счета появятся');
    });

    it('рендерит строки счетов из API', async () => {
        const inv: BillingInvoice = {
            id: 1,
            invoice_number: 'СЧ-2026-00001',
            amount_total: '990.00',
            status: 'issued',
            issued_at: '2026-05-07T00:00:00Z',
            has_pdf: true,
        };
        vi.mocked(billingApi.getInvoices).mockResolvedValue({ data: [inv] });
        const wrapper = mount(InvoicesTable, { global: { plugins: [vuetify] } });
        await flushPromises();
        const text = wrapper.text();
        expect(text).toContain('СЧ-2026-00001');
        expect(text).toContain('Выставлен');
    });

    it('показывает error-alert при сбое', async () => {
        vi.mocked(billingApi.getInvoices).mockRejectedValue(new Error('fail'));
        const wrapper = mount(InvoicesTable, { global: { plugins: [vuetify] } });
        await flushPromises();
        expect(wrapper.text()).toContain('Не удалось загрузить счета');
    });
});
```

- [ ] **Step 4: Запустить — убедиться, что падает**

Из корня: `npm run test:vue -- TransactionsTable InvoicesTable`
Expected: FAIL — компоненты ещё на mock-данных.

- [ ] **Step 5: Переписать `TransactionsTable.vue`**

Полностью заменить `app/resources/js/components/billing/TransactionsTable.vue`:

```vue
<script setup lang="ts">
/**
 * TransactionsTable — server-driven история транзакций с табами
 * (Все / Пополнения / Списания / Возвраты). Данные — GET
 * /api/billing/transactions (E3). Паттерн self-fetching из ChargesTab.
 */
import { ref, onMounted } from 'vue';
import { getTransactions, type BillingTransaction } from '../../api/billing';
import { formatCost, txAmountClass } from '../../composables/billingFormatters';

interface Tab {
    id: string;
    label: string;
    type: string | null;
}

const TABS: Tab[] = [
    { id: 'all', label: 'Все', type: null },
    { id: 'topup', label: 'Пополнения', type: 'topup' },
    { id: 'lead_charge', label: 'Списания', type: 'lead_charge' },
    { id: 'refund', label: 'Возвраты', type: 'refund' },
];

const activeTab = ref<string>('all');
const rows = ref<BillingTransaction[]>([]);
const total = ref(0);
const loading = ref(false);
const loadError = ref<string | null>(null);
const page = ref(1);

const headers = [
    { title: 'Дата', key: 'created_at', sortable: false },
    { title: 'Операция', key: 'description', sortable: false },
    { title: 'ID', key: 'code', sortable: false, width: 120 },
    { title: 'Сумма', key: 'amount_rub', align: 'end' as const, sortable: false, width: 140 },
];

function formatWhen(iso: string): string {
    return new Date(iso).toLocaleString('ru-RU', {
        timeZone: 'Europe/Moscow',
        day: '2-digit',
        month: '2-digit',
        hour: '2-digit',
        minute: '2-digit',
    });
}

/** Числовое значение движения: рубли приоритетно, иначе лиды. */
function txAmountValue(tx: BillingTransaction): number {
    const rub = Number(tx.amount_rub);
    return rub !== 0 ? rub : tx.amount_leads;
}

/** Текст суммы: «+ 5 000 ₽» / «− 1 лид.» / «0 ₽». */
function txAmountText(tx: BillingTransaction): string {
    const rub = Number(tx.amount_rub);
    if (rub !== 0) return formatCost(rub);
    if (tx.amount_leads !== 0) {
        const sign = tx.amount_leads > 0 ? '+ ' : '− ';
        return sign + Math.abs(tx.amount_leads) + ' лид.';
    }
    return '0 ₽';
}

async function load(): Promise<void> {
    loading.value = true;
    loadError.value = null;
    try {
        const tab = TABS.find((t) => t.id === activeTab.value);
        const params: { page: number; type?: string } = { page: page.value };
        if (tab?.type) params.type = tab.type;
        const res = await getTransactions(params);
        rows.value = res.data;
        total.value = res.meta.total;
    } catch {
        loadError.value = 'Не удалось загрузить транзакции.';
        rows.value = [];
        total.value = 0;
    } finally {
        loading.value = false;
    }
}

async function changeTab(id: string): Promise<void> {
    activeTab.value = id;
    page.value = 1;
    await load();
}

async function loadOptions(opts: { page: number }): Promise<void> {
    page.value = opts.page;
    await load();
}

async function refresh(): Promise<void> {
    page.value = 1;
    await load();
}

onMounted(load);

defineExpose({ load, refresh, changeTab, activeTab, total, rows });
</script>

<template>
    <v-card variant="outlined" class="mt-4 panel">
        <div class="panel-h pa-4">
            <h2 class="text-h6 panel-title ma-0">История транзакций</h2>
            <v-btn-toggle
                :model-value="activeTab"
                mandatory
                color="primary"
                density="comfortable"
                variant="text"
            >
                <v-btn
                    v-for="tab in TABS"
                    :key="tab.id"
                    :value="tab.id"
                    size="small"
                    @click="changeTab(tab.id)"
                >
                    {{ tab.label }}
                </v-btn>
            </v-btn-toggle>
        </div>

        <v-alert v-if="loadError" type="error" variant="tonal" density="compact" class="mx-4 mb-4" role="alert">
            {{ loadError }}
        </v-alert>

        <v-data-table-server
            :headers="headers"
            :items="rows"
            :items-length="total"
            :loading="loading"
            :items-per-page="20"
            density="comfortable"
            @update:options="loadOptions"
        >
            <template #[`item.created_at`]="{ item }">
                <span class="tx-when num">{{ formatWhen(item.created_at) }}</span>
            </template>
            <template #[`item.code`]="{ item }">
                <span class="tx-id">#{{ item.code }}</span>
            </template>
            <template #[`item.amount_rub`]="{ item }">
                <span class="num" :class="txAmountClass(txAmountValue(item))">
                    {{ txAmountText(item) }}
                </span>
            </template>
        </v-data-table-server>
    </v-card>
</template>

<style scoped>
.num {
    font-family: 'JetBrains Mono', ui-monospace, monospace;
    font-feature-settings: 'tnum';
    font-weight: 500;
}

.panel {
    background: #fff;
}
.panel-h {
    display: flex;
    justify-content: space-between;
    align-items: center;
    flex-wrap: wrap;
    gap: 12px;
}
.panel-title {
    font-variation-settings: 'opsz' 18;
    letter-spacing: -0.01em;
}

.tx-when {
    font-size: 12px;
    color: #66635c;
}
.tx-id {
    font-family: 'JetBrains Mono', ui-monospace, monospace;
    font-size: 12px;
    color: #66635c;
}
.tx-amount-up {
    color: #1b6e3b;
}
.tx-amount-down {
    color: #b83a3a;
}
.tx-amount-neutral {
    color: #66635c;
}
</style>
```

- [ ] **Step 6: Переписать `InvoicesTable.vue`**

Полностью заменить `app/resources/js/components/billing/InvoicesTable.vue`:

```vue
<script setup lang="ts">
/**
 * InvoicesTable — список счетов тенанта. Данные — GET /api/billing/invoices
 * (E3). Real-but-empty до Б-1: на MVP saas_invoices пуста (нужно
 * зарегистрированное юр-лицо), компонент показывает empty-state.
 */
import { ref, onMounted } from 'vue';
import { getInvoices, type BillingInvoice } from '../../api/billing';
import { formatPlain } from '../../composables/billingFormatters';

const invoices = ref<BillingInvoice[]>([]);
const loading = ref(true);
const loadError = ref<string | null>(null);

const STATUS_LABELS: Record<string, string> = {
    draft: 'Черновик',
    issued: 'Выставлен',
    paid: 'Оплачен',
    overdue: 'Просрочен',
    cancelled: 'Отменён',
};

function statusLabel(status: string): string {
    return STATUS_LABELS[status] ?? status;
}

function formatDate(iso: string): string {
    return new Date(iso).toLocaleDateString('ru-RU', { timeZone: 'Europe/Moscow' });
}

async function load(): Promise<void> {
    loading.value = true;
    loadError.value = null;
    try {
        invoices.value = (await getInvoices()).data;
    } catch {
        loadError.value = 'Не удалось загрузить счета.';
    } finally {
        loading.value = false;
    }
}

onMounted(load);

defineExpose({ load, invoices });
</script>

<template>
    <v-card variant="outlined" class="mt-4 panel">
        <div class="panel-h pa-4">
            <h2 class="text-h6 panel-title ma-0">Счета</h2>
        </div>
        <v-divider />

        <div v-if="loading" class="py-8 d-flex justify-center">
            <v-progress-circular indeterminate color="primary" size="28" />
        </div>

        <v-alert v-else-if="loadError" type="error" variant="tonal" density="compact" class="ma-4" role="alert">
            {{ loadError }}
        </v-alert>

        <div v-else-if="invoices.length === 0" class="empty pa-8 text-center text-medium-emphasis">
            Счета появятся после первой оплаты.
        </div>

        <ul v-else class="invoices-list pa-2 ma-0">
            <li v-for="inv in invoices" :key="inv.id" class="inv-row">
                <span class="inv-when num">{{ formatDate(inv.issued_at) }}</span>
                <span class="inv-name">
                    {{ inv.invoice_number }}
                    <span class="sub">{{ statusLabel(inv.status) }}</span>
                </span>
                <span class="inv-amount num">{{ formatPlain(Number(inv.amount_total)) }}</span>
                <v-btn
                    variant="text"
                    size="small"
                    prepend-icon="mdi-file-pdf-box"
                    :disabled="!inv.has_pdf"
                >
                    PDF
                </v-btn>
            </li>
        </ul>
    </v-card>
</template>

<style scoped>
.num {
    font-family: 'JetBrains Mono', ui-monospace, monospace;
    font-feature-settings: 'tnum';
    font-weight: 500;
}

.panel {
    background: #fff;
}
.panel-h {
    display: flex;
    justify-content: space-between;
    align-items: center;
    flex-wrap: wrap;
    gap: 12px;
}
.panel-title {
    font-variation-settings: 'opsz' 18;
    letter-spacing: -0.01em;
}

.empty {
    font-size: 14px;
}

.invoices-list {
    list-style: none;
    padding: 0;
    margin: 0;
}
.inv-row {
    display: grid;
    grid-template-columns: 110px 1fr auto auto;
    align-items: center;
    gap: 12px;
    padding: 12px 16px;
    border-bottom: 1px solid #f0ede4;
}
.inv-row:last-child {
    border-bottom: none;
}
.inv-when {
    font-size: 12px;
    color: #66635c;
}
.inv-name {
    display: flex;
    flex-direction: column;
    font-weight: 500;
    color: #081319;
}
.inv-name .sub {
    font-weight: 400;
    color: #66635c;
    font-size: 12px;
}
.inv-amount {
    font-weight: 500;
    color: #081319;
}
</style>
```

- [ ] **Step 7: Почистить `billingFormatters.ts`**

Полностью заменить `app/resources/js/composables/billingFormatters.ts`:

```ts
/**
 * Форматтеры биллинга — BillingView + TransactionsTable.
 *
 * Sprint 2 Plan C: status/format-функции (statusChipColor/statusLabel/
 * formatLabel/formatIcon) удалены — real-API транзакции не имеют статуса
 * (append-only ledger), счета — отдельный формат. txAmountClass
 * перетипизирован под знак суммы.
 */

/** «5000» → «5 000 ₽» (без знака). */
export function formatPlain(cost: number): string {
    return new Intl.NumberFormat('ru-RU').format(cost) + ' ₽';
}

/** Знаковый формат: «+ 5 000 ₽» / «− 6 600 ₽» / «0 ₽». */
export function formatCost(cost: number): string {
    const sign = cost > 0 ? '+ ' : cost < 0 ? '− ' : '';
    return sign + new Intl.NumberFormat('ru-RU').format(Math.abs(cost)) + ' ₽';
}

/** CSS-класс суммы транзакции по знаку. */
export function txAmountClass(amount: number): string {
    if (amount > 0) return 'tx-amount-up';
    if (amount < 0) return 'tx-amount-down';
    return 'tx-amount-neutral';
}

/** Человекочитаемые лейблы для feature-слагов tariff_plans.features. */
export const FEATURE_LABELS: Record<string, string> = {
    webhook: 'Webhook',
    kanban: 'Канбан',
    basic_analytics: 'Базовая аналитика',
    advanced_analytics: 'Расширенная аналитика',
    api: 'API',
    '2fa': 'Двухфакторная аутентификация',
    custom_domain: 'Свой домен',
};

/** Лейбл feature-слага; неизвестный слаг возвращается как есть. */
export function featureLabel(slug: string): string {
    return FEATURE_LABELS[slug] ?? slug;
}
```

- [ ] **Step 8: Ужать `mockBilling.ts`**

Полностью заменить `app/resources/js/composables/mockBilling.ts`:

```ts
/**
 * Мок платежа «в обработке» для pending-баннера BillingView.
 *
 * Кошелёк / транзакции / счета подключены к real API (api/billing.ts) в
 * Sprint 2 Plan C (E3). Pending-баннер — отдельный эпик E4 (Sprint 5);
 * до его реализации остаётся mock.
 */
export interface PendingPayment {
    code: string;
    amount: number;
    method: string;
    startedAt: string;
    autoCancelAt: string;
    timeoutMinutes: number;
}

export const MOCK_PENDING: PendingPayment | null = {
    code: 'TX-89421',
    amount: 5000,
    method: 'ЮKassa',
    startedAt: '14:21',
    autoCancelAt: '14:51',
    timeoutMinutes: 30,
};
```

- [ ] **Step 9: Запустить тесты компонентов — убедиться, что прошли**

Из корня: `npm run test:vue -- TransactionsTable InvoicesTable BillingView`
Expected: PASS — TransactionsTable 4, InvoicesTable 3, BillingView 6.

- [ ] **Step 10: type-check + lint + полный Vitest**

Из корня:

- `npm run type-check` — Expected: 0 errors. (Проверить, что нигде не осталось импортов удалённых `BillingTransaction`/`TxType`/`TxStatus`/`Invoice`/`InvoiceFormat`/`BILLING_TABS`/`MOCK_TRANSACTIONS`/`MOCK_INVOICES` из `mockBilling.ts` или удалённых функций из `billingFormatters.ts`.)
- `npm run lint:vue` — Expected: 0 errors.
- `npm run test:vue` — зафиксировать фактические числа (≈95 файлов / ≈789 passed / 3 skipped).

- [ ] **Step 11: Histoire smoke**

Из корня: `npm run story` (build-проверка) — `BillingView.story.vue` остаётся; в Histoire `getWallet()` не отвечает (нет API) → BillingView покажет error/loading-state. Это известный косметический эффект Histoire (без API-mock'а), не блокер. Если `npm run story` build падает с ошибкой компиляции — это регрессия, чинить; если просто рендерит error-state — ОК.

- [ ] **Step 12: Commit**

```bash
git add app/resources/js/api/billing.ts app/resources/js/components/billing/TransactionsTable.vue app/resources/js/components/billing/InvoicesTable.vue app/resources/js/composables/billingFormatters.ts app/resources/js/composables/mockBilling.ts app/tests/Frontend/TransactionsTable.spec.ts app/tests/Frontend/InvoicesTable.spec.ts
git commit -m "$(cat <<'EOF'
feat(billing): TransactionsTable + InvoicesTable real API (E3)

TransactionsTable — server-driven история транзакций (GET
/api/billing/transactions, табы → фильтр type). InvoicesTable —
GET /api/billing/invoices с empty-state (real-but-empty до Б-1).
billingFormatters почищен (drop status/format-функций), mockBilling
ужат до pending-баннера (E4).

Sprint 2 Plan C, audit E3 (frontend pt2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 5: E1 frontend — TopupDialog + Пополнить wiring

**Files:**

- Create: `app/resources/js/components/billing/TopupDialog.vue`
- Modify: `app/resources/js/api/billing.ts` (добавить `topup`)
- Modify: `app/resources/js/views/BillingView.vue` (wiring диалога + snackbar)
- Modify: `app/resources/js/components/billing/BalanceCard.vue` (emit `topup`)
- Create: `app/tests/Frontend/TopupDialog.spec.ts`
- Modify: `app/tests/Frontend/BillingView.spec.ts` (тест кнопки)

- [ ] **Step 1: Добавить `topup` в `api/billing.ts`**

В `app/resources/js/api/billing.ts`:

1. Заменить первую строку

```ts
import { apiClient } from './client';
```

на

```ts
import { apiClient, ensureCsrfCookie } from './client';
```

1. В конец файла добавить:

```ts

/** Результат POST /api/billing/topup. */
export interface TopupResult {
    transaction: {
        id: number;
        type: string;
        amount_rub: string;
        balance_rub_after: string | null;
        created_at: string;
    };
    balance_rub: string;
}

/** POST /api/billing/topup — пополнить рублёвый баланс (MVP-stub). */
export async function topup(amountRub: number): Promise<TopupResult> {
    await ensureCsrfCookie();
    const { data } = await apiClient.post<TopupResult>('/api/billing/topup', { amount_rub: amountRub });
    return data;
}
```

- [ ] **Step 2: Написать failing-тест `TopupDialog.spec.ts`**

Создать `app/tests/Frontend/TopupDialog.spec.ts`:

```ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import TopupDialog from '../../resources/js/components/billing/TopupDialog.vue';
import * as billingApi from '../../resources/js/api/billing';

vi.mock('../../resources/js/api/billing');

const vuetify = createVuetify();

const factory = () =>
    mount(TopupDialog, {
        global: { plugins: [vuetify] },
        props: { modelValue: true },
    });

describe('TopupDialog.vue', () => {
    beforeEach(() => {
        vi.mocked(billingApi.topup).mockResolvedValue({
            transaction: {
                id: 1,
                type: 'topup',
                amount_rub: '5000.00',
                balance_rub_after: '5000.00',
                created_at: '2026-05-16T00:00:00Z',
            },
            balance_rub: '5000.00',
        });
    });

    it('блокирует submit при сумме ниже 100 ₽', async () => {
        const wrapper = factory();
        (wrapper.vm as unknown as { amount: number | null }).amount = 50;
        await flushPromises();
        expect((wrapper.vm as unknown as { canSubmit: boolean }).canSubmit).toBe(false);
    });

    it('разрешает submit при валидной сумме', async () => {
        const wrapper = factory();
        (wrapper.vm as unknown as { amount: number | null }).amount = 5000;
        await flushPromises();
        expect((wrapper.vm as unknown as { canSubmit: boolean }).canSubmit).toBe(true);
    });

    it('submit вызывает topup и эмитит success с новым балансом', async () => {
        const wrapper = factory();
        (wrapper.vm as unknown as { amount: number | null }).amount = 5000;
        await (wrapper.vm as unknown as { submit: () => Promise<void> }).submit();
        expect(billingApi.topup).toHaveBeenCalledWith(5000);
        expect(wrapper.emitted('success')?.[0]).toEqual(['5000.00']);
    });

    it('показывает ошибку при отказе backend', async () => {
        vi.mocked(billingApi.topup).mockRejectedValue(new Error('fail'));
        const wrapper = factory();
        (wrapper.vm as unknown as { amount: number | null }).amount = 5000;
        await (wrapper.vm as unknown as { submit: () => Promise<void> }).submit();
        await flushPromises();
        expect((wrapper.vm as unknown as { errorMsg: string | null }).errorMsg).not.toBeNull();
    });
});
```

- [ ] **Step 3: Запустить — убедиться, что падает**

Из корня: `npm run test:vue -- TopupDialog`
Expected: FAIL — `TopupDialog.vue` не существует.

- [ ] **Step 4: Создать `TopupDialog.vue`**

Создать `app/resources/js/components/billing/TopupDialog.vue`:

```vue
<script setup lang="ts">
/**
 * TopupDialog — диалог пополнения рублёвого баланса (audit E1).
 *
 * MVP-stub: POST /api/billing/topup кредитует баланс немедленно (без
 * платёжного шлюза — реальная оплата post-Б-1). При успехе эмитит
 * `success` с новым балансом и закрывается.
 */
import { ref, computed } from 'vue';
import { topup } from '../../api/billing';
import { extractErrorMessage, extractValidationErrors } from '../../api/client';

const model = defineModel<boolean>({ required: true });
const emit = defineEmits<{ success: [balanceRub: string] }>();

const PRESETS = [1000, 5000, 10000, 25000];

const amount = ref<number | null>(null);
const submitting = ref(false);
const errorMsg = ref<string | null>(null);

const amountError = computed<string | null>(() => {
    if (amount.value === null) return null;
    if (amount.value < 100) return 'Минимум 100 ₽';
    if (amount.value > 1000000) return 'Максимум 1 000 000 ₽';
    return null;
});

const canSubmit = computed(
    () => amount.value !== null && amountError.value === null && !submitting.value,
);

function setPreset(value: number): void {
    amount.value = value;
}

async function submit(): Promise<void> {
    if (!canSubmit.value || amount.value === null) return;
    submitting.value = true;
    errorMsg.value = null;
    try {
        const res = await topup(amount.value);
        emit('success', res.balance_rub);
        model.value = false;
        amount.value = null;
    } catch (e) {
        const validation = extractValidationErrors(e);
        errorMsg.value = validation?.amount_rub?.[0] ?? extractErrorMessage(e);
    } finally {
        submitting.value = false;
    }
}

function close(): void {
    if (submitting.value) return;
    model.value = false;
    errorMsg.value = null;
}

defineExpose({ amount, submit, canSubmit, errorMsg });
</script>

<template>
    <v-dialog v-model="model" max-width="460" @after-leave="errorMsg = null">
        <v-card>
            <v-card-title class="text-h6">Пополнить баланс</v-card-title>
            <v-card-text>
                <v-text-field
                    v-model.number="amount"
                    type="number"
                    label="Сумма пополнения"
                    suffix="₽"
                    density="comfortable"
                    :error-messages="amountError ?? undefined"
                    autofocus
                />

                <div class="presets mb-2">
                    <v-chip
                        v-for="p in PRESETS"
                        :key="p"
                        size="small"
                        variant="outlined"
                        @click="setPreset(p)"
                    >
                        {{ new Intl.NumberFormat('ru-RU').format(p) }} ₽
                    </v-chip>
                </div>

                <v-alert type="info" variant="tonal" density="compact" class="mt-2">
                    Платёжный шлюз подключается после регистрации юр. лица — на текущем этапе баланс
                    пополняется сразу.
                </v-alert>

                <v-alert v-if="errorMsg" type="error" variant="tonal" density="compact" class="mt-3" role="alert">
                    {{ errorMsg }}
                </v-alert>
            </v-card-text>
            <v-card-actions>
                <v-spacer />
                <v-btn variant="text" :disabled="submitting" @click="close">Отмена</v-btn>
                <v-btn color="primary" variant="flat" :loading="submitting" :disabled="!canSubmit" @click="submit">
                    Пополнить
                </v-btn>
            </v-card-actions>
        </v-card>
    </v-dialog>
</template>

<style scoped>
.presets {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
}
</style>
```

- [ ] **Step 5: Запустить тест `TopupDialog.spec.ts` — убедиться, что прошёл**

Из корня: `npm run test:vue -- TopupDialog`
Expected: PASS — 4 passed.

- [ ] **Step 6: Подвязать `BalanceCard` «Пополнить» на emit**

В `app/resources/js/components/billing/BalanceCard.vue`:

1. После `const props = defineProps<{...}>();` (перед `const walletText`) добавить:

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

1. В кнопке «Пополнить» добавить обработчик клика — заменить

```vue
                    <v-btn color="primary" variant="flat" prepend-icon="mdi-plus" size="small">Пополнить</v-btn>
```

на

```vue
                    <v-btn
                        color="primary"
                        variant="flat"
                        prepend-icon="mdi-plus"
                        size="small"
                        @click="$emit('topup')"
                        >Пополнить</v-btn
                    >
```

- [ ] **Step 7: Подвязать `BillingView` — диалог + snackbar + refresh**

В `app/resources/js/views/BillingView.vue`:

1. В блок импортов компонентов добавить (после `import InvoicesTable ...`):

```ts
import TopupDialog from '../components/billing/TopupDialog.vue';
```

1. Сразу после строки `const loadError = ref<string | null>(null);` добавить 3 ref'а:

```ts
const topupOpen = ref(false);
const topupSnackbar = ref(false);
const txTableRef = ref<InstanceType<typeof TransactionsTable> | null>(null);
```

1. После функции `loadWallet` добавить:

```ts
async function onTopupSuccess(): Promise<void> {
    topupOpen.value = false;
    topupSnackbar.value = true;
    await loadWallet();
    txTableRef.value?.refresh();
}
```

1. Заменить `defineExpose({ loadWallet, wallet });` на:

```ts
defineExpose({ loadWallet, wallet, topupOpen });
```

1. В шапке заменить кнопку

```vue
            <v-btn color="primary" variant="flat" prepend-icon="mdi-plus">Пополнить баланс</v-btn>
```

на

```vue
            <v-btn color="primary" variant="flat" prepend-icon="mdi-plus" @click="topupOpen = true"
                >Пополнить баланс</v-btn
            >
```

1. В `BalanceCard` добавить обработчик `@topup` — заменить

```vue
                    <BalanceCard
                        :wallet-rub="walletRub"
                        :leads-balance="leadsBalance"
                        :tariff-name="tariffName"
                        :tariff-price="tariffPrice"
                        :tariff-features="tariffFeatures"
                    />
```

на

```vue
                    <BalanceCard
                        :wallet-rub="walletRub"
                        :leads-balance="leadsBalance"
                        :tariff-name="tariffName"
                        :tariff-price="tariffPrice"
                        :tariff-features="tariffFeatures"
                        @topup="topupOpen = true"
                    />
```

1. В `TransactionsTable` добавить ref — заменить

```vue
                    <TransactionsTable />
```

на

```vue
                    <TransactionsTable ref="txTableRef" />
```

1. Перед закрывающим `</v-container>` (после `</v-tabs-window>`) добавить:

```vue

        <TopupDialog v-model="topupOpen" @success="onTopupSuccess" />

        <v-snackbar v-model="topupSnackbar" color="success" :timeout="4000">
            Баланс пополнен.
        </v-snackbar>
```

- [ ] **Step 8: Добавить тест кнопки в `BillingView.spec.ts`**

В `app/tests/Frontend/BillingView.spec.ts`:

1. В `factory()` дополнить stubs — заменить

```ts
                stubs: { TransactionsTable: true, InvoicesTable: true, ChargesTab: true },
```

на

```ts
                stubs: { TransactionsTable: true, InvoicesTable: true, ChargesTab: true, TopupDialog: true },
```

1. Перед закрывающей `});` блока `describe` добавить тест:

```ts

    it('кнопка «Пополнить баланс» открывает TopupDialog', async () => {
        const wrapper = factory();
        await flushPromises();
        const btn = wrapper.findAll('button').find((b) => b.text().includes('Пополнить баланс'));
        expect(btn).toBeDefined();
        await btn!.trigger('click');
        expect((wrapper.vm as unknown as { topupOpen: boolean }).topupOpen).toBe(true);
    });
```

- [ ] **Step 9: Запустить тесты — убедиться, что прошли**

Из корня: `npm run test:vue -- TopupDialog BillingView`
Expected: PASS — TopupDialog 4, BillingView 7.

- [ ] **Step 10: type-check + lint + полный Vitest**

Из корня:

- `npm run type-check` — Expected: 0 errors.
- `npm run lint:vue` — Expected: 0 errors.
- `npm run test:vue` — зафиксировать фактические числа.

- [ ] **Step 11: Commit**

```bash
git add app/resources/js/components/billing/TopupDialog.vue app/resources/js/api/billing.ts app/resources/js/views/BillingView.vue app/resources/js/components/billing/BalanceCard.vue app/tests/Frontend/TopupDialog.spec.ts app/tests/Frontend/BillingView.spec.ts
git commit -m "$(cat <<'EOF'
feat(billing): TopupDialog + Пополнить wiring (E1)

TopupDialog (сумма + пресеты + min 100 ₽ валидация) → POST
/api/billing/topup. Кнопки «Пополнить баланс» (шапка) и «Пополнить»
(BalanceCard) открывают диалог; при успехе — refresh кошелька +
транзакций + snackbar.

Sprint 2 Plan C, audit E1 (frontend).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Final Sprint 2 Plan C Acceptance

После всех 5 тасков выполнить (использовать `superpowers:verification-before-completion`):

- [ ] **Step 1: Полный backend-sweep.** Из `app/`: `composer test:parallel` — все billing-тесты зелёные, 0 регрессий относительно baseline 766/763/3sk/0 (ожидаемо +25: Task1 +11, Task2 +14). При случайном сбое supplier-теста под `--parallel` — перезапустить `php artisan test tests/Feature/Supplier/` последовательно для классификации (quirk 72), не считать регрессией если последовательно зелено.
- [ ] **Step 2: PHPStan + Pint.** Из `app/`: `composer stan` (0 errors), `composer pint --test` (clean).
- [ ] **Step 3: Полный frontend-sweep.** Из корня: `npm run test:vue` (0 failed), `npm run type-check` (0), `npm run lint:vue` (0).
- [ ] **Step 4: Эпики E1 + E3 — проверка покрытия по audit-spec.**
  - E1: `POST /api/billing/topup` работает (stub), TopupDialog + 2 кнопки «Пополнить» подвязаны, баланс/транзакции обновляются после пополнения. ✓
  - E3: `GET /api/billing/wallet|transactions|invoices` работают; BillingView Overview-таб (BalanceCard + TransactionsTable + InvoicesTable) на real API; loading/error/empty-state присутствуют. ✓
- [ ] **Step 5: git log.** 5 атомарных коммитов на top'е плана; каждое сообщение conventional + ссылка на audit ID; в diff нет `app/dev-indices.json`, нет посторонних файлов.
- [ ] **Step 6: Честный отчёт.** Зафиксировать фактические числа Pest/Vitest/tsc/ESLint/Pint/Larastan. Любой неверифицированный пункт — явно в раздел ограничений. НЕ push (пользователь пушит сам).

**Out of scope (документированный carry-forward):**

- E2 (BalanceCard «Автопополнение» / «Сменить тариф») — P2, Sprint 5. Кнопки остаются без обработчика.
- E4 (pending-баннер real data) — P2, Sprint 5. `MOCK_PENDING` остаётся.
- УПД (`saas_upd_documents`) — отдельная таблица, эндпоинта нет; на MVP пуста.
- Реальный платёжный шлюз ЮKassa — блокируется Б-1 (реквизиты ООО).
- `BillingView.story.vue` в Histoire показывает error/loading-state (нет API-mock'а) — косметика Histoire, не блокер.
