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