Files
portal/app/tests/Feature/Billing/BillingOverviewControllerTest.php
T

293 lines
12 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
use App\Models\BalanceTransaction;
use App\Models\LeadCharge;
use App\Models\Tenant;
use App\Models\User;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->seed(PricingTierSeeder::class);
$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');
});
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 рассчитан как affordable_leads / avg leads-per-day', function () {
// Seed 30 historical lead_charges over the last 30 days — avg 1 lead/day.
LeadCharge::factory()->count(30)->create([
'tenant_id' => $this->tenant->id,
'charged_at' => now()->subDays(rand(1, 30)),
]);
// Wallet has 14250 ₽. PricingTierSeeder tier 1: 100 leads @ 500₽.
// delivered_in_month=0 → 100 slots left in tier 1. afford = bcdiv(1425000, 50000, 0) = 28 leads.
// take = min(100, 28) = 28 → affordable_leads = 28.
// avg = 30/30 = 1 lead/day. runway = floor(28 / 1) = 28.
expect($this->getJson('/api/billing/wallet')->json('runway_days'))->toBe(28);
});
test('GET /api/billing/wallet: runway_days = 0 при отрицательном балансе', function () {
$this->tenant->update(['balance_rub' => '-500.00']);
BalanceTransaction::factory()->create([
'tenant_id' => $this->tenant->id,
'type' => 'lead_charge',
'amount_rub' => '-3000.00',
'created_at' => now()->subDays(10),
]);
// Баланс уже отрицательный → runway не может быть отрицательным, клампится в 0.
expect($this->getJson('/api/billing/wallet')->json('runway_days'))->toBe(0);
});
test('GET /api/billing/wallet без auth: 401', function () {
auth()->logout();
$this->getJson('/api/billing/wallet')->assertStatus(401);
});
test('GET /api/billing/wallet возвращает affordable_leads + current_tier + next_tier + tiers_preview', function () {
$this->tenant->update([
'balance_rub' => '5000.00',
'delivered_in_month' => 30,
]);
$resp = $this->getJson('/api/billing/wallet');
$resp->assertOk()->assertJsonStructure([
'balance_rub',
'affordable_leads',
'current_tier' => ['no', 'price_rub', 'leads_left_in_tier'],
'next_tier' => ['no', 'price_rub', 'leads_in_tier'],
'delivered_in_month',
'runway_days',
'tiers_preview' => [['tier_no', 'leads_in_tier', 'price_rub']],
'tariff',
]);
// Recomputed against real PricingTierSeeder:
// tier 1: 100 leads × 500₽; delivered=30 → 70 slots left; balance 5000 → afford 10 in tier 1.
expect($resp->json('affordable_leads'))->toBe(10);
expect($resp->json('current_tier.no'))->toBe(1);
expect($resp->json('current_tier.price_rub'))->toBe('500.00');
expect($resp->json('current_tier.leads_left_in_tier'))->toBe(70);
expect($resp->json('next_tier.no'))->toBe(2);
expect($resp->json('next_tier.price_rub'))->toBe('450.00');
expect($resp->json('next_tier.leads_in_tier'))->toBe(200);
expect($resp->json('delivered_in_month'))->toBe(30);
expect($resp->json('tiers_preview'))->toHaveCount(7);
expect($resp->json('tiers_preview.0'))->toMatchArray([
'tier_no' => 1, 'leads_in_tier' => 100, 'price_rub' => '500.00',
]);
expect($resp->json('tiers_preview.6'))->toMatchArray([
'tier_no' => 7, 'leads_in_tier' => null, 'price_rub' => '250.00',
]);
});
test('GET /api/billing/wallet НЕ возвращает balance_leads (Billing v2 Spec A)', function () {
$resp = $this->getJson('/api/billing/wallet');
$resp->assertOk();
expect($resp->json())->not->toHaveKey('balance_leads');
});
test('GET /api/billing/wallet: tariff НЕ содержит price_monthly или billing_model (Spec A унификация)', function () {
$tariffId = DB::table('tariff_plans')->where('code', 'pro')->value('id');
$this->tenant->update(['current_tariff_id' => $tariffId]);
$tariff = $this->getJson('/api/billing/wallet')->json('tariff');
expect($tariff)->not->toBeNull();
expect($tariff)->not->toHaveKey('price_monthly');
expect($tariff)->not->toHaveKey('billing_model');
expect($tariff)->toHaveKeys(['code', 'name', 'features']);
});
// ---- 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')->assertJsonPath('data.0.type', 'topup');
$this->getJson('/api/billing/transactions?type=lead_charge')
->assertJsonCount(1, 'data')->assertJsonPath('data.0.type', 'lead_charge');
// Billing v2 Spec A: 'refund' убран из whitelist — фильтр игнорируется, возвращает все 3 строки.
$this->getJson('/api/billing/transactions?type=refund')
->assertJsonCount(3, '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);
});
test('GET /api/billing/transactions?type=refund — фильтр игнорируется (Spec A удалил возвраты)', function () {
BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id, 'type' => 'topup']);
BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id, 'type' => 'lead_charge']);
// ?type=refund must NOT narrow the filter — falls through to "no filter"
$resp = $this->getJson('/api/billing/transactions?type=refund');
$resp->assertOk();
expect($resp->json('meta.total'))->toBe(2);
});
test('GET /api/billing/transactions?type=migration — фильтр работает (новый тип из Spec A)', function () {
BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id, 'type' => 'migration']);
BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id, 'type' => 'topup']);
$resp = $this->getJson('/api/billing/transactions?type=migration');
$resp->assertOk();
expect($resp->json('meta.total'))->toBe(1);
expect($resp->json('data.0.type'))->toBe('migration');
});
test('GET /api/billing/transactions: display_amount_rub = "0.00" для исторических prepaid lead_charge', function () {
// Historic prepaid: type=lead_charge, amount_rub='0.00' (deduction was в leads, не в rub)
BalanceTransaction::create([
'tenant_id' => $this->tenant->id,
'type' => 'lead_charge',
'amount_rub' => '0.00',
'amount_leads' => -1,
'balance_rub_after' => '14250.00',
'balance_leads_after' => 284,
]);
$resp = $this->getJson('/api/billing/transactions');
$resp->assertOk();
expect($resp->json('data.0.display_amount_rub'))->toBe('0.00');
expect($resp->json('data.0.amount_rub'))->toBe('0.00');
});
test('GET /api/billing/transactions: display_amount_rub = amount_rub для новых rub-списаний', function () {
BalanceTransaction::create([
'tenant_id' => $this->tenant->id,
'type' => 'lead_charge',
'amount_rub' => '-500.00',
'amount_leads' => null,
'balance_rub_after' => '13750.00',
]);
$resp = $this->getJson('/api/billing/transactions');
$resp->assertOk();
expect($resp->json('data.0.display_amount_rub'))->toBe('-500.00');
});
// ---- 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);
});