23f81bdaf3
AdminBillingIndexTest: teardown глушит session-триггеры на время очистки. DELETE tenants каскадил в append-only tenant_operations_log, триггер audit_block_mutation давал RAISE EXCEPTION. Плюс ensureRange гарантирует месячные партиции balance_transactions за прошлые 2 месяца под SharesSupplierPdo. AdminIncidentsIndexTest: добавлен трейт SharesSupplierPdo. Контроллер читает через pgsql_supplier, тест писал через дефолтный pgsql под DatabaseTransactions, cross-connection невидимость давала total=0. Verify: оба класса 20 из 20 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
181 lines
7.7 KiB
PHP
181 lines
7.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\Tenant;
|
|
use App\Services\MonthlyPartitionManager;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Tests\Concerns\SharesSupplierPdo;
|
|
|
|
uses(DatabaseTransactions::class);
|
|
// ensureRange() ниже создаёт партиции через pgsql_supplier (DDL_CONNECTION);
|
|
// SharesSupplierPdo держит этот коннект в той же test-транзакции, иначе DDL
|
|
// уйдёт мимо отката (см. MonthlyPartitionManager doc + trait doc).
|
|
uses(SharesSupplierPdo::class);
|
|
|
|
beforeEach(function () {
|
|
// tenants → tenant_operations_log / balance_transactions идут ON DELETE CASCADE,
|
|
// но это append-only таблицы (триггер audit_block_mutation на BEFORE DELETE):
|
|
// каскад от DELETE tenants → DELETE в append-only лог → RAISE EXCEPTION.
|
|
// Глушим session-триггеры на время teardown'а (весь блок внутри отката
|
|
// DatabaseTransactions). Test-роль — superuser (dev/CI postgres), см.
|
|
// config/database.php pgsql_supplier note.
|
|
DB::statement("SET session_replication_role = 'replica'");
|
|
DB::table('balance_transactions')->delete();
|
|
DB::table('tenant_operations_log')->delete();
|
|
DB::table('tenants')->delete();
|
|
DB::statement("SET session_replication_role = 'origin'");
|
|
|
|
// Тесты вставляют balance_transactions за прошлые месяцы (subMonths(2),
|
|
// subDays(45)) — гарантируем месячные партиции на этот диапазон, иначе
|
|
// INSERT падает «не найдена секция» (23514). Идемпотентно; партиции
|
|
// откатываются с тестовой транзакцией.
|
|
app(MonthlyPartitionManager::class)->ensureRange(
|
|
'balance_transactions',
|
|
now()->subMonths(2)->startOfMonth(),
|
|
now()->startOfMonth(),
|
|
);
|
|
|
|
// 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('жив');
|
|
});
|