293 lines
12 KiB
PHP
293 lines
12 KiB
PHP
<?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);
|
||
});
|