168 lines
6.0 KiB
PHP
168 lines
6.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\BalanceTransaction;
|
|
use App\Models\Deal;
|
|
use App\Models\LeadCharge;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use Database\Seeders\PricingTierSeeder;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
|
|
/**
|
|
* Tenant-scoped read-only charges ledger (Plan 4 Task 11).
|
|
*
|
|
* GET /api/billing/charges — paginated 20/page (sorted by charged_at desc).
|
|
* POST /api/billing/charges/export — StreamedResponse CSV (BOM + chunkById 500).
|
|
* Filters: period (current_month / last_month / 90d) + charge_source (prepaid / rub).
|
|
*
|
|
* RLS изоляция через SetTenantContext middleware (auth:sanctum + tenant). Тесты
|
|
* используют postgres superuser BYPASSRLS, поэтому RLS-isolation проверяется
|
|
* через `auth()->user()->tenant_id` фильтр — другие tenant'ы видны только если
|
|
* мы сами их INSERT'нули, но фильтрация теста на $this->tenant->id всё равно
|
|
* остаётся защитой.
|
|
*
|
|
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §6.3
|
|
*/
|
|
uses(DatabaseTransactions::class);
|
|
|
|
beforeEach(function () {
|
|
// PricingTierSeeder идемпотентен (updateOrCreate); seed безопасно.
|
|
$this->seed(PricingTierSeeder::class);
|
|
|
|
$this->tenant = Tenant::factory()->create();
|
|
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
|
$this->actingAs($this->user);
|
|
});
|
|
|
|
function makeChargeFor(Tenant $tenant, array $overrides = []): LeadCharge
|
|
{
|
|
$deal = Deal::factory()->create([
|
|
'tenant_id' => $tenant->id,
|
|
'received_at' => now(),
|
|
]);
|
|
|
|
return LeadCharge::factory()->create(array_merge([
|
|
'tenant_id' => $tenant->id,
|
|
'deal_id' => $deal->id,
|
|
'deal_received_at' => $deal->received_at,
|
|
'charged_at' => now(),
|
|
], $overrides));
|
|
}
|
|
|
|
it('GET /api/billing/charges returns paginated list for current tenant only (RLS)', function () {
|
|
makeChargeFor($this->tenant);
|
|
makeChargeFor($this->tenant);
|
|
|
|
$otherTenant = Tenant::factory()->create();
|
|
makeChargeFor($otherTenant);
|
|
|
|
$response = $this->getJson('/api/billing/charges');
|
|
$response->assertOk();
|
|
expect($response->json('data'))->toHaveCount(2);
|
|
});
|
|
|
|
it('filters by charge_source=prepaid', function () {
|
|
makeChargeFor($this->tenant, ['charge_source' => 'rub', 'price_per_lead_kopecks' => 50000]);
|
|
makeChargeFor($this->tenant, ['charge_source' => 'prepaid', 'price_per_lead_kopecks' => 0]);
|
|
makeChargeFor($this->tenant, ['charge_source' => 'prepaid', 'price_per_lead_kopecks' => 0]);
|
|
|
|
$response = $this->getJson('/api/billing/charges?charge_source=prepaid');
|
|
expect($response->json('data'))->toHaveCount(2);
|
|
});
|
|
|
|
it('filters by period=current_month / last_month / 90d', function () {
|
|
makeChargeFor($this->tenant, ['charged_at' => now()]);
|
|
makeChargeFor($this->tenant, ['charged_at' => now()->subMonth()]);
|
|
makeChargeFor($this->tenant, ['charged_at' => now()->subDays(60)]);
|
|
makeChargeFor($this->tenant, ['charged_at' => now()->subDays(120)]);
|
|
|
|
$this->getJson('/api/billing/charges?period=current_month')->assertJsonCount(1, 'data');
|
|
$this->getJson('/api/billing/charges?period=last_month')->assertJsonCount(1, 'data');
|
|
$this->getJson('/api/billing/charges?period=90d')->assertJsonCount(3, 'data');
|
|
});
|
|
|
|
it('returns 401 без auth', function () {
|
|
auth()->logout();
|
|
$this->getJson('/api/billing/charges')->assertStatus(401);
|
|
});
|
|
|
|
it('pagination: ?page=2 returns next slice', function () {
|
|
for ($i = 0; $i < 30; $i++) {
|
|
makeChargeFor($this->tenant);
|
|
}
|
|
|
|
$page1 = $this->getJson('/api/billing/charges?page=1');
|
|
$page2 = $this->getJson('/api/billing/charges?page=2');
|
|
|
|
expect($page1->json('data'))->toHaveCount(20);
|
|
expect($page2->json('data'))->toHaveCount(10);
|
|
});
|
|
|
|
it('POST /export streams CSV via StreamedResponse', function () {
|
|
makeChargeFor($this->tenant);
|
|
|
|
$response = $this->postJson('/api/billing/charges/export', ['period' => '90d']);
|
|
$response->assertOk();
|
|
$response->assertHeader('Content-Type', 'text/csv; charset=UTF-8');
|
|
|
|
$body = $response->streamedContent();
|
|
expect($body)->toContain('charged_at,deal_id,tier_no,charge_source,price_rub,balance_rub_after');
|
|
});
|
|
|
|
it('export заполняет balance_rub_after из balance_transactions JOIN', function () {
|
|
$deal = Deal::factory()->create([
|
|
'tenant_id' => $this->tenant->id,
|
|
'received_at' => now(),
|
|
]);
|
|
|
|
LeadCharge::factory()->create([
|
|
'tenant_id' => $this->tenant->id,
|
|
'deal_id' => $deal->id,
|
|
'deal_received_at' => $deal->received_at,
|
|
'tier_no' => 1,
|
|
'price_per_lead_kopecks' => 50000,
|
|
'charge_source' => 'rub',
|
|
'charged_at' => now(),
|
|
]);
|
|
|
|
BalanceTransaction::create([
|
|
'tenant_id' => $this->tenant->id,
|
|
'type' => BalanceTransaction::TYPE_LEAD_CHARGE,
|
|
'amount_rub' => '-500.00',
|
|
'amount_leads' => null,
|
|
'balance_rub_after' => '4500.00',
|
|
'related_type' => Deal::class,
|
|
'related_id' => $deal->id,
|
|
'created_at' => now(),
|
|
]);
|
|
|
|
$response = $this->postJson('/api/billing/charges/export');
|
|
$response->assertOk();
|
|
|
|
$csv = $response->streamedContent();
|
|
expect($csv)->toContain('4500.00');
|
|
});
|
|
|
|
test('TenantChargesController::export emits charged_at in ISO-8601 format (regression A.10 fix)', function () {
|
|
$deal = Deal::factory()->create([
|
|
'tenant_id' => $this->tenant->id,
|
|
'received_at' => now(),
|
|
]);
|
|
LeadCharge::create([
|
|
'tenant_id' => $this->tenant->id,
|
|
'deal_id' => $deal->id,
|
|
'deal_received_at' => $deal->received_at,
|
|
'tier_no' => 1,
|
|
'price_per_lead_kopecks' => 50000,
|
|
'charge_source' => 'rub',
|
|
'charged_at' => now(),
|
|
]);
|
|
|
|
$body = $this->post('/api/billing/charges/export')->streamedContent();
|
|
|
|
// ISO-8601 marker: "T" between date and time, and trailing "+" or "Z" timezone.
|
|
expect($body)->toMatch('/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}([+-]\d{2}:\d{2}|Z)/');
|
|
});
|