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>
105 KiB
Sprint 2 Plan C — Billing (E1 + E3) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Подвязать BillingView Overview-таб на реальный API и добавить рабочее пополнение баланса (MVP-stub без платёжного шлюза).
Architecture: Новый BillingController (4 эндпоинта под auth:sanctum+tenant) + BillingTopupService (append-only ledger-credit на bcmath) + TariffPlan модель. Frontend: новый api/billing.ts, BillingView + 3 sub-компонента переводятся с mock-данных на fetch. Пополнение баланса — TopupDialog + кнопки «Пополнить».
Tech Stack: Laravel 13 (Sanctum SPA, PostgreSQL 16 RLS), Pest 4; Vue 3 + Vuetify 3 + Pinia, Vitest.
Источник: docs/superpowers/specs/2026-05-15-portal-audit-design.md (commit e978b33) — Sprint 2 эпики E1 (BillingView Пополнить баланс — TopupDialog + POST /api/billing/topup stub) и E3 (BillingView Overview tab — BalanceCard/TransactionsTable/InvoicesTable на real API).
Контекст и ключевые факты recon (читать перед началом)
Schema delta = 0. Plan C НЕ трогает db/schema.sql — все нужные таблицы (balance_transactions, tariff_plans, saas_invoices) уже существуют. Записи в db/CHANGELOG_schema.md не требуется.
balance_transactions — append-only audit-таблица с 2 триггерами (db/schema.sql:2974-2979):
trg_audit_chain_hash_balance_tx—BEFORE INSERT→audit_chain_hash()заполняетlog_hash(SHA-256 hash-chain). Прозрачно для приложения — INSERT работает как обычно, ничего делать не нужно.trg_audit_block_mut_balance_tx—BEFORE UPDATE OR DELETE→audit_block_mutation()делаетRAISE EXCEPTION. Строки неизменяемы. → Каждый topup = новая INSERT-строка; существующие строки никогда не UPDATE/DELETE.balance_rub_afterвычисляется на момент INSERT'а и фиксируется навсегда.LedgerService::chargeForDelivery(app/app/Services/Billing/LedgerService.php:86) уже делаетBalanceTransaction::create([...])тем же путём — паттерн проверен.
Деньги — только bcmath, не PHP float. tenants.balance_rub / balance_transactions.amount_rub — DECIMAL(12,2). Мутации баланса считаются через bcadd/bcsub (паттерн LedgerService.php:64-65). Исключение — оценочный показатель runway_days (грубая UX-оценка, не мутация баланса; float допустим, см. Task 2).
RLS + тесты. balance_transactions и saas_invoices имеют RLS-политику tenant_isolation (USING без WITH CHECK → INSERT не фильтруется, SELECT/UPDATE/DELETE фильтруются). Тесты идут под postgres-superuser (BYPASSRLS), поэтому контроллеры обязаны делать явный where('tenant_id', $tenantId) как defense-in-depth (паттерн TenantChargesController.php:42). Это и есть проверяемая в тестах изоляция.
Тест-паттерн (эталоны app/tests/Feature/ApiKeyControllerTest.php, app/tests/Feature/Billing/TenantChargesControllerTest.php):
tests/Pest.phpНЕ включаетRefreshDatabaseглобально. Каждый Feature-тест-файл объявляетuses(DatabaseTransactions::class)сам.beforeEach:Tenant::factory()->create()→User::factory()->create(['tenant_id'=>...])→$this->actingAs($this->user).$this->actingAs($user)(дефолтный guard) проходит черезauth:sanctum— проверено в Plan B.auth()->logout()для 401-теста.tariff_plans(4 плана start/basic/pro/enterprise) сидится прямо изdb/schema.sqlприmigrate:fresh— в тестовой БД эти строки ЕСТЬ, фабрика дляTariffPlanне нужна.
PHPStan baseline (quirk-паттерн проекта): каждый новый Feature-тест-файл даёт false-positives PHPStan на $this-динамические свойства/методы (Pest\PendingCalls\TestCall). После написания тестов — composer stan; если ошибки только в новых тест-файлах → composer stan -- --generate-baseline, затем composer stan снова (0 errors). Quirk #87: создание новой модели + ide-helper:models -W регенерирует _ide_helper_models.php → устаревшие ignore-записи становятся unmatched → --generate-baseline их корректно убирает (это безопасно — удаление ignore может только вскрыть ошибку, не спрятать).
API-клиент фронтенда (app/resources/js/api/client.ts): новые api-модули импортируют { apiClient, ensureCsrfCookie } из ./client. GET-запросы НЕ вызывают ensureCsrfCookie(); мутации (POST) вызывают await ensureCsrfCookie() первой строкой. extractErrorMessage(error, fallback?) / extractValidationErrors(error) — функции обработки ошибок.
Локальный rule окружения:
- Laravel-приложение в подпапке
app/—composer/php artisanзапускать изapp/;npm— из корня репозитория (vitest.config.tsв корне). - Рабочий путь содержит кириллицу — quirk #85: после
Write/Editфайла убедиться, что файл реально записан (последующий запуск тестов это поймает). git add— только явные пути затронутых файлов. НЕgit add -A, НЕ трогатьapp/dev-indices.json(pre-existing dev-артефакт, uncommitted).- НЕ
git push. Hooks bypass (--no-verify) запрещён — lefthook pre-commit должен пройти зелёным на каждом коммите. - Quirk 72:
CleanupInactiveSupplierProjectsJobTestдаёт случайный сбой под--parallel(Redissupplier:sessionrace), проходит последовательно. Если 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)
- E1 topup — MVP-stub.
POST /api/billing/topupкредитуетtenants.balance_rubнемедленно и пишет append-only строкуbalance_transactions(type='topup'). Платёжный шлюз (ЮKassa) НЕ интегрируется — реальная оплата зависит от Б-1 (реквизиты ООО). Это явно прописано в audit-spec («stub без реальной оплаты на MVP») и в строке блокеров Б-1. - Отдельный
BillingTopupService, не методLedgerService.LedgerServiceдокументирован как «командный сервис на горячем пути доставки лида» (debit-only, вызывается подlockForUpdateвнутри job-транзакции). Пополнение — другой путь (HTTP, user-initiated). SRP: отдельный маленький сервис без лишних зависимостей (PricingTierResolver/PricingTierRepositoryпополнению не нужны). runway_days(«хватит на N дней») — оценочный UX-показатель:balance_rub / (рублёвые списания за 30 дней / 30).null, если списаний не было. Считается float'ом — это грубая оценка для шапки, НЕ мутация баланса; погрешность ±1 день несущественна.- Транзакции real-API не имеют статуса.
balance_transactions— append-only ledger; строка появляется только по факту состоявшейся операции. Колонокstatus/codeв схеме нет. → В real-API модели транзакции нет поляstatus;codeвычисляется на клиенте какTX-{id}. Mock-статусыpending/rejectedотносятся к эпику E4 (pending-баннер) — вне scope Plan C. - 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 тоже пуста; эндпоинта нет). - Pending-баннер остаётся mock. Эпик E4 («BillingView pending alert — real data») — P2, Sprint 5, вне Plan C.
BillingViewпродолжает импортироватьMOCK_PENDING;mockBilling.tsужимается доPendingPayment+MOCK_PENDING. Это соответствие утверждённому порядку приоритетов P0→P1→P2. TariffPlanмодель создаётся (доменная сущность каталога тарифов;Tenant::tariff()relation).SaasInvoiceмодель НЕ создаётся — invoices читаютсяDB::table('saas_invoices')(read-only выборка, паттернAdminBillingController); на MVP всегда пусто, полноценная модель+фабрика были бы YAGNI.- Sub-компоненты сами тянут данные.
TransactionsTableиInvoicesTableпереписываются на self-fetching (паттернChargesTab.vue),BillingViewтянет толькоwallet. Новые компоненты используютapiClientчерезapi/billing.ts(НЕ raw axios — уChargesTabraw axios это legacy).
Структура файлов
Backend (создаются):
app/app/Services/Billing/BillingTopupService.php— append-only ledger-credit.app/app/Http/Controllers/Api/BillingController.php— 4 эндпоинта.app/app/Models/TariffPlan.php— каталог тарифов.app/database/factories/BalanceTransactionFactory.php— для тестов transactions-эндпоинта.app/tests/Feature/Billing/BillingTopupServiceTest.php,TopupControllerTest.php,BillingOverviewControllerTest.php.
Backend (модифицируются):
app/routes/web.php— группа/api/billing(topup/wallet/transactions/invoices).app/app/Models/Tenant.php—tariff()relation.app/app/Models/BalanceTransaction.php—HasFactorytrait.app/phpstan-baseline.neon— Pest false-positives новых тест-файлов.
Frontend (создаются):
app/resources/js/api/billing.ts— api-модуль биллинга.app/resources/js/components/billing/TopupDialog.vue— диалог пополнения.app/tests/Frontend/TransactionsTable.spec.ts,InvoicesTable.spec.ts,TopupDialog.spec.ts.
Frontend (модифицируются):
app/resources/js/views/BillingView.vue— wallet-fetch + topup-wiring.app/resources/js/components/billing/BalanceCard.vue— real props + nullable tariff + topup-emit.app/resources/js/components/billing/TransactionsTable.vue— self-fetching.app/resources/js/components/billing/InvoicesTable.vue— self-fetching.app/resources/js/composables/billingFormatters.ts—featureLabel+ retypetxAmountClass, 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— если ошибки только в новых тест-файлах (PestTestCallfalse-positives) →composer stan -- --generate-baseline, затемcomposer stanснова (Expected: 0 errors). Проверитьgit diff app/phpstan-baseline.neon— добавляются только записи дляBillingTopupServiceTest/TopupControllerTest(возможно удаляются устаревшие unrelatedignore— это безопасно, 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(relationtariff()) -
Modify:
app/app/Models/BalanceTransaction.php(traitHasFactory) -
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:
- После
use Illuminate\Database\Eloquent\Relations\HasMany;добавить:
use Illuminate\Database\Eloquent\Relations\BelongsTo;
- После метода
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:
- После
use Illuminate\Database\Eloquent\Model;добавить:
use Database\Factories\BalanceTransactionFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
- Внутри тела класса
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:
- В блок
useдобавить (по алфавиту):
use App\Models\BalanceTransaction;
use App\Models\Tenant;
use Illuminate\Support\Facades\DB;
- Перед закрывающей
}класса (после метода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 наBillingOverviewControllerTest→composer stan -- --generate-baseline→composer stan(0 errors). Проверитьgit diff app/phpstan-baseline.neon(additive + возможный drop устаревших — quirk #87, безопасно). -
Step 12: Регрессионный sweep
Из app/: composer test:parallel — Expected: 784/781/3sk/0 (+14). При случайном сбое supplier-теста под --parallel — перезапустить папку последовательно.
- Step 13: Commit
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"> ₽</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"> лидов</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(retypetxAmountClass, 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(emittopup) -
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:
- Заменить первую строку
import { apiClient } from './client';
на
import { apiClient, ensureCsrfCookie } from './client';
- В конец файла добавить:
/** Результат 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:
- После
const props = defineProps<{...}>();(передconst walletText) добавить:
defineEmits<{ topup: [] }>();
- В кнопке «Пополнить» добавить обработчик клика — заменить
<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:
- В блок импортов компонентов добавить (после
import InvoicesTable ...):
import TopupDialog from '../components/billing/TopupDialog.vue';
- Сразу после строки
const loadError = ref<string | null>(null);добавить 3 ref'а:
const topupOpen = ref(false);
const topupSnackbar = ref(false);
const txTableRef = ref<InstanceType<typeof TransactionsTable> | null>(null);
- После функции
loadWalletдобавить:
async function onTopupSuccess(): Promise<void> {
topupOpen.value = false;
topupSnackbar.value = true;
await loadWallet();
txTableRef.value?.refresh();
}
- Заменить
defineExpose({ loadWallet, wallet });на:
defineExpose({ loadWallet, wallet, topupOpen });
- В шапке заменить кнопку
<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
>
- В
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"
/>
- В
TransactionsTableдобавить ref — заменить
<TransactionsTable />
на
<TransactionsTable ref="txTableRef" />
- Перед закрывающим
</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:
- В
factory()дополнить stubs — заменить
stubs: { TransactionsTable: true, InvoicesTable: true, ChargesTab: true },
на
stubs: { TransactionsTable: true, InvoicesTable: true, ChargesTab: true, TopupDialog: true },
- Перед закрывающей
});блока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 присутствуют. ✓
- E1:
- 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, не блокер.