Files
portal/docs/superpowers/plans/2026-05-15-sprint2c-billing.md
T
Дмитрий 83748e858c docs(plan): Sprint 2 Plan C — Billing E1/E3 (writing-plans)
5-task план реализации audit-эпиков E1 (TopupDialog + POST
/api/billing/topup stub) и E3 (BillingView Overview на real API:
wallet/transactions/invoices). Backend: BillingController +
BillingTopupService + TariffPlan. Frontend: api/billing.ts + 4
компонента биллинга с mock на real API.

Sprint 2 Plan C. Источник: docs/superpowers/specs/2026-05-15-portal-audit-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 06:40:11 +03:00

105 KiB
Raw Blame History

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_txBEFORE INSERTaudit_chain_hash() заполняет log_hash (SHA-256 hash-chain). Прозрачно для приложения — INSERT работает как обычно, ничего делать не нужно.
  • trg_audit_block_mut_balance_txBEFORE UPDATE OR DELETEaudit_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_rubDECIMAL(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.phptariff() relation.
  • app/app/Models/BalanceTransaction.phpHasFactory 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.tsfeatureLabel + 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

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

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

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

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 });), вставить:

// Биллинг тенанта: пополнение/кошелёк/транзакции/счета (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
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

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

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; добавить:
use Illuminate\Database\Eloquent\Relations\BelongsTo;
  1. После метода projects() (перед закрывающей } класса) добавить:

    /** @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; добавить:
use Database\Factories\BalanceTransactionFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
  1. Внутри тела класса BalanceTransaction — между открывающей { и первой строкой public const TYPE_TRIAL_BONUS = 'trial_bonus'; — вставить trait:
    /** @use HasFactory<BalanceTransactionFactory> */
    use HasFactory;

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

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

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 добавить (по алфавиту):
use App\Models\BalanceTransaction;
use App\Models\Tenant;
use Illuminate\Support\Facades\DB;
  1. Перед закрывающей } класса (после метода topup()) добавить:

    /**
     * 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 строки, итог:

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 на BillingOverviewControllerTestcomposer stan -- --generate-baselinecomposer 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
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:

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 — в конец файла добавить:


/** Человекочитаемые лейблы для 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:

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:

<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:

<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

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 — в конец файла добавить:


/** Строка истории транзакций (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:

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:

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:

<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:

<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:

/**
 * Форматтеры биллинга — 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:

/**
 * Мок платежа «в обработке» для 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
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. Заменить первую строку
import { apiClient } from './client';

на

import { apiClient, ensureCsrfCookie } from './client';
  1. В конец файла добавить:

/** Результат 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:

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:

<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) добавить:
defineEmits<{ topup: [] }>();
  1. В кнопке «Пополнить» добавить обработчик клика — заменить
                    <v-btn color="primary" variant="flat" prepend-icon="mdi-plus" size="small">Пополнить</v-btn>

на

                    <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 ...):
import TopupDialog from '../components/billing/TopupDialog.vue';
  1. Сразу после строки const loadError = ref<string | null>(null); добавить 3 ref'а:
const topupOpen = ref(false);
const topupSnackbar = ref(false);
const txTableRef = ref<InstanceType<typeof TransactionsTable> | null>(null);
  1. После функции loadWallet добавить:
async function onTopupSuccess(): Promise<void> {
    topupOpen.value = false;
    topupSnackbar.value = true;
    await loadWallet();
    txTableRef.value?.refresh();
}
  1. Заменить defineExpose({ loadWallet, wallet }); на:
defineExpose({ loadWallet, wallet, topupOpen });
  1. В шапке заменить кнопку
            <v-btn color="primary" variant="flat" prepend-icon="mdi-plus">Пополнить баланс</v-btn>

на

            <v-btn color="primary" variant="flat" prepend-icon="mdi-plus" @click="topupOpen = true"
                >Пополнить баланс</v-btn
            >
  1. В BalanceCard добавить обработчик @topup — заменить
                    <BalanceCard
                        :wallet-rub="walletRub"
                        :leads-balance="leadsBalance"
                        :tariff-name="tariffName"
                        :tariff-price="tariffPrice"
                        :tariff-features="tariffFeatures"
                    />

на

                    <BalanceCard
                        :wallet-rub="walletRub"
                        :leads-balance="leadsBalance"
                        :tariff-name="tariffName"
                        :tariff-price="tariffPrice"
                        :tariff-features="tariffFeatures"
                        @topup="topupOpen = true"
                    />
  1. В TransactionsTable добавить ref — заменить
                    <TransactionsTable />

на

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

        <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 — заменить
                stubs: { TransactionsTable: true, InvoicesTable: true, ChargesTab: true },

на

                stubs: { TransactionsTable: true, InvoicesTable: true, ChargesTab: true, TopupDialog: true },
  1. Перед закрывающей }); блока describe добавить тест:

    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

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, не блокер.