Files
portal/app/tests/Feature/Billing/ExpireInvoicesTest.php
T
Дмитрий 908386b0da fix(billing): invoices:expire не просрочивал счета — saas_invoices читались под RLS-ролью (0 строк)
ExpireInvoicesCommand бил по SaaS-таблице saas_invoices через дефолтное
соединение (crm_app_user, RLS). Планировщик бежит без app.current_tenant_id
→ policy отдаёт 0 строк → команда НИКОГДА не помечала счёт overdue (тот же
класс бага, что SendNewLeadsDigestJob). На бою проверено: роль портала видит
0 счетов, реально 3 (кандидатов на просрочку сейчас 0 — живого вреда нет).

Лечение как у остальных cross-tenant обслуживающих команд (ScrubSoftDeletedDeals,
ReportsCleanupExpired): SaasInvoice::on('pgsql_supplier') (BYPASSRLS).

Тест: +регрессия «просрочивает счёт даже без контекста фирмы (tenant 0)»;
+SharesSupplierPdo. 2/2 зелёные, Larastan 0.

Найдено при аудите-хвосте после дайджест-фикса (01287d08).

Escape: владелец дал явное «коммить пуш и кати» + выбрал доделать в worktree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 14:20:47 +03:00

56 lines
3.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\LegalEntity;
use App\Models\SaasInvoice;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
// Команда просрочивает счета через pgsql_supplier (BYPASSRLS — иначе под RLS-ролью
// планировщика saas_invoices не видны, просрочка = no-op). SharesSupplierPdo делает
// pgsql_supplier общим PDO с pgsql, иначе незакоммиченный тестовый счёт не виден.
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
function expSeed(int $tenantId, int $leId, string $status, string $number, $expiresAt): SaasInvoice
{
return SaasInvoice::create([
'tenant_id' => $tenantId, 'legal_entity_id' => $leId, 'invoice_number' => $number,
'payer_type' => 'legal', 'amount_net' => '100.00', 'amount_total' => '100.00',
'status' => $status, 'issued_at' => now()->subDays(10), 'expires_at' => $expiresAt,
]);
}
it('помечает overdue только просроченные неоплаченные счета', function () {
$t = Tenant::factory()->create();
$le = LegalEntity::create(['code' => 'exp_'.uniqid(), 'name' => 'ИП', 'legal_form' => 'IP', 'inn' => '770000000020']);
$stale = expSeed($t->id, $le->id, 'issued', 'СЧ-2026-02001', now()->subDay());
$fresh = expSeed($t->id, $le->id, 'issued', 'СЧ-2026-02002', now()->addDay());
$paid = expSeed($t->id, $le->id, 'paid', 'СЧ-2026-02003', now()->subDay());
$this->artisan('invoices:expire')->assertExitCode(0);
expect(SaasInvoice::find($stale->id)->status)->toBe('overdue')
->and(SaasInvoice::find($fresh->id)->status)->toBe('issued')
->and(SaasInvoice::find($paid->id)->status)->toBe('paid');
});
it('просрочивает счёт даже без контекста фирмы (планировщик бежит под системным tenant 0)', function () {
// Регрессия на «тихий no-op»: saas_invoices под RLS, планировщик — без
// app.current_tenant_id. Команда обязана просрочить счёт через BYPASSRLS,
// а не остаться пустышкой. Раньше overdue не выставлялся никогда.
$t = Tenant::factory()->create();
$le = LegalEntity::create(['code' => 'exp_'.uniqid(), 'name' => 'ИП', 'legal_form' => 'IP', 'inn' => '770000000021']);
$stale = expSeed($t->id, $le->id, 'issued', 'СЧ-2026-02010', now()->subDay());
// Планировщик реально запускает команду без контекста своей фирмы.
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
$this->artisan('invoices:expire')->assertExitCode(0);
expect(SaasInvoice::on('pgsql_supplier')->find($stale->id)->status)->toBe('overdue');
});