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); });