Files
portal/app/tests/Feature/AdminBillingIndexTest.php
T
Дмитрий 4532b95d64 phase2(admin-billing): GET /api/admin/billing + AdminBillingView API (этап 3/5)
Aggregates пополнений/списаний за текущий месяц по balance_transactions
+ summary с MRR/revenue/overdue/refunds_30d.

Backend (AdminBillingController::index):
- GET /api/admin/billing?search=. Per-tenant SUM с CASE WHEN type IN
  ('topup','lead_charge') GROUP BY tenant_id; ABS для charges.
- Row: id/subdomain/organization_name/contact_email/status/balance_rub/
  tariff_id/tariff_name/mrr_rub (=tariff.price_monthly если не-trial)/
  monthly_topups_rub/monthly_charges_rub/last_payment_at/
  chargeback_unrecovered_rub.
- summary: total_mrr_rub (SUM не-trial), monthly_revenue_rub (SUM topup),
  overdue_count (balance<0 || chargeback>0), refunds_count_30d.
- Quirk: schema-колонка tariff_plans.price_monthly (НЕ price_rub_monthly)
  — обнаружено первым прогоном Pest, исправлено сразу.

Pest +9 (AdminBillingIndexTest):
- пустой / поля+tariff JOIN / aggregates за месяц / прошлый месяц не
  попадает / overdue / refunds_30d (старые исключены) / total_mrr_rub
  (trial исключаются) / search ILIKE / soft-deleted скрыт.

Frontend:
- api/admin.ts::listAdminBilling — типизированный helper.
- AdminBillingView: reactive rowsState+summary default = MOCK,
  loadBilling() async на onMounted парсит API-строки → numbers + derive
  status (suspended/balance<0||chargeback>0→overdue/active). На fail —
  fetchError + warning alert + MOCK fallback. Reload-btn.
- tariffLabel/statusInfo обобщены с fallback'ами на новые slug'и.

Vitest +4:
- listAdminBilling на mount / replace rowsState+summary + string→number
  + status derive / reject → fetchError+alert+fallback / reload-btn x2.

PHPStan baseline регенерирован.

Регресс:
- Lint+type-check+format passed.
- Vitest 300/300 за 18.41 сек (+4 от 296).
- Vite build 925 ms.
- Pint + PHPStan passed.
- Pest 237/237 за 27.69 сек (+9 от 228, 926 assertions).

Реестр v1.66→v1.67 / CLAUDE.md v1.57→v1.58.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:28:49 +03:00

155 lines
6.0 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function () {
DB::table('balance_transactions')->delete();
DB::table('tenants')->delete();
// tariff_plans оставляем (seeded).
});
function attachTariff(int $tenantId, string $name = 'Команда', float $monthly = 990.00): int
{
$tariffId = (int) DB::table('tariff_plans')->insertGetId([
'code' => 'tp_'.bin2hex(random_bytes(4)),
'name' => $name,
'billing_model' => 'monthly',
'price_monthly' => $monthly,
'price_per_lead' => 10.00,
'included_leads' => 100,
'is_active' => true,
'is_public' => true,
'sort_order' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('tenants')->where('id', $tenantId)->update(['current_tariff_id' => $tariffId]);
return $tariffId;
}
function makeBalanceTx(int $tenantId, string $type, float $amount, ?string $createdAt = null): void
{
DB::table('balance_transactions')->insert([
'tenant_id' => $tenantId,
'type' => $type,
'amount_rub' => $amount,
'amount_leads' => 0,
'created_at' => $createdAt ?? now(),
]);
}
test('GET /api/admin/billing 200 + пустой', function () {
$r = $this->getJson('/api/admin/billing');
$r->assertStatus(200);
expect($r->json('tenants'))->toBe([]);
expect($r->json('summary.total_mrr_rub'))->toBe('0');
expect($r->json('summary.overdue_count'))->toBe(0);
expect($r->json('summary.refunds_count_30d'))->toBe(0);
});
test('GET /api/admin/billing возвращает поля + tariff JOIN', function () {
$tenant = Tenant::factory()->create([
'organization_name' => 'Окна Москва',
'subdomain' => 'okna',
'balance_rub' => '14250.00',
'is_trial' => false,
]);
attachTariff($tenant->id, 'Команда', 990.00);
$r = $this->getJson('/api/admin/billing');
$row = $r->json('tenants.0');
expect($row['organization_name'])->toBe('Окна Москва');
expect($row['balance_rub'])->toBe('14250.00');
expect($row['tariff_name'])->toBe('Команда');
expect($row['mrr_rub'])->toBe('990.00');
});
test('GET /api/admin/billing аггрегирует topups + charges за текущий месяц', function () {
$tenant = Tenant::factory()->create();
attachTariff($tenant->id);
makeBalanceTx($tenant->id, 'topup', 5000.00); // ✓ in month
makeBalanceTx($tenant->id, 'topup', 3000.00); // ✓ in month
makeBalanceTx($tenant->id, 'lead_charge', -1850.00); // ABS = 1850
makeBalanceTx($tenant->id, 'lead_charge', -2400.00);
$r = $this->getJson('/api/admin/billing');
$row = $r->json('tenants.0');
expect((float) $row['monthly_topups_rub'])->toBe(8000.0);
expect((float) $row['monthly_charges_rub'])->toBe(4250.0);
expect($row['last_payment_at'])->toBeString();
});
test('GET /api/admin/billing НЕ включает транзакции прошлого месяца в monthly aggregates', function () {
$tenant = Tenant::factory()->create();
attachTariff($tenant->id);
makeBalanceTx($tenant->id, 'topup', 5000.00); // в этом месяце
makeBalanceTx($tenant->id, 'topup', 99999.00, now()->subMonths(2)->toDateTimeString()); // 2 месяца назад
$r = $this->getJson('/api/admin/billing');
expect((float) $r->json('tenants.0.monthly_topups_rub'))->toBe(5000.0);
});
test('GET /api/admin/billing summary считает overdue (balance<0 OR chargeback>0)', function () {
Tenant::factory()->create(['balance_rub' => '100.00', 'chargeback_unrecovered_rub' => '0.00']);
Tenant::factory()->create(['balance_rub' => '-200.00']); // overdue 1
Tenant::factory()->create(['balance_rub' => '500.00', 'chargeback_unrecovered_rub' => '300.00']); // overdue 2
$r = $this->getJson('/api/admin/billing');
expect($r->json('summary.overdue_count'))->toBe(2);
});
test('GET /api/admin/billing summary считает refunds за 30 дней', function () {
$tenant = Tenant::factory()->create();
makeBalanceTx($tenant->id, 'refund', -100.00); // ✓ <30 days
makeBalanceTx($tenant->id, 'refund', -200.00); // ✓
makeBalanceTx($tenant->id, 'refund', -1000.00, now()->subDays(45)->toDateTimeString()); // > 30 days
makeBalanceTx($tenant->id, 'topup', 500.00); // не refund
$r = $this->getJson('/api/admin/billing');
expect($r->json('summary.refunds_count_30d'))->toBe(2);
});
test('GET /api/admin/billing summary total_mrr суммирует tariff цен для не-trial тенантов', function () {
$a = Tenant::factory()->create(['is_trial' => false]);
attachTariff($a->id, 'Команда', 990.00);
$b = Tenant::factory()->create(['is_trial' => false]);
attachTariff($b->id, 'Pro', 4990.00);
$c = Tenant::factory()->create(['is_trial' => true]);
attachTariff($c->id, 'Команда', 990.00); // trial — не считается
$r = $this->getJson('/api/admin/billing');
expect((float) $r->json('summary.total_mrr_rub'))->toBe(5980.0); // 990 + 4990
});
test('GET /api/admin/billing search ILIKE по name + subdomain', function () {
Tenant::factory()->create(['organization_name' => 'Окна Москва', 'subdomain' => 'okna']);
Tenant::factory()->create(['organization_name' => 'Двери СПб', 'subdomain' => 'dveri']);
expect(count($this->getJson('/api/admin/billing?search=окна')->json('tenants')))->toBe(1);
expect(count($this->getJson('/api/admin/billing?search=DVERI')->json('tenants')))->toBe(1);
});
test('GET /api/admin/billing soft-deleted tenant скрыт', function () {
$t = Tenant::factory()->create(['organization_name' => 'удалён']);
$t->delete();
Tenant::factory()->create(['organization_name' => 'жив']);
$r = $this->getJson('/api/admin/billing');
expect(count($r->json('tenants')))->toBe(1);
expect($r->json('tenants.0.organization_name'))->toBe('жив');
});