create(['tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 5000, 'is_active' => true, 'effective_from' => now()]); }); it('freezes tenant whose balance no longer covers daily limit', function () { Mail::fake(); // 500₽ / 50₽ = 10 лидов; проекты хотят 25 → заморозка. $tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]); Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]); (new BalancePreflightSweepJob)->handle(); expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull(); Mail::assertQueued(BalanceFrozenMail::class); }); it('unfreezes tenant whose balance now covers daily limit', function () { Mail::fake(); // 2000₽ / 50₽ = 40 лидов; хотят 25 → разморозка. $tenant = Tenant::factory()->create(['balance_rub' => '2000.00', 'frozen_by_balance_at' => now()->subDay()]); Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]); (new BalancePreflightSweepJob)->handle(); expect($tenant->fresh()->frozen_by_balance_at)->toBeNull(); }); it('is idempotent — does not re-freeze already frozen tenant', function () { Mail::fake(); $frozenAt = now()->subDay(); $tenant = Tenant::factory()->create(['balance_rub' => '0.00', 'frozen_by_balance_at' => $frozenAt]); Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]); (new BalancePreflightSweepJob)->handle(); // Дата заморозки не перезаписана; для ЭТОГО tenant повторного письма нет. // NB: per-tenant фильтр, т.к. liderra_testing persistent (DemoSeeder тенанты // могут попасть в sweep и тоже получить BalanceFrozenMail — не наш ответ). expect($tenant->fresh()->frozen_by_balance_at->timestamp)->toBe($frozenAt->timestamp); Mail::assertNotQueued(BalanceFrozenMail::class, fn ($mail) => $mail->tenant->id === $tenant->id); }); // Spec C extension (26.05.2026): freeze/unfreeze дёргают supplier sync в режиме 'online'. // Привязка к существующему админ-переключателю SupplierExportMode (system_settings.supplier_export_mode). // Online нужен сейчас для отладки (моментальный sync с поставщиком); batch будет рабочим режимом // при росте числа клиентов (накопленные изменения уезжают одним cut-off-cron'ом в 18:00 MSK). it('dispatches SyncSupplierProjectJob for each active project on freeze when supplier mode is online', function () { Mail::fake(); Queue::fake(); DB::table('system_settings')->updateOrInsert(['key' => 'supplier_export_mode'], ['value' => 'online']); // 500₽ / 50₽ = 10 лидов; 2 проекта по 15 = 30 → заморозка. $tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]); $p1 = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 15]); $p2 = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 15]); (new BalancePreflightSweepJob)->handle(); expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull(); Queue::assertPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === $p1->id); Queue::assertPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === $p2->id); }); it('does NOT dispatch SyncSupplierProjectJob on freeze when supplier mode is batch', function () { Mail::fake(); Queue::fake(); DB::table('system_settings')->updateOrInsert(['key' => 'supplier_export_mode'], ['value' => 'batch']); $tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]); Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]); (new BalancePreflightSweepJob)->handle(); expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull(); // batch-режим: sync с поставщиком отложен до cut-off 18:00 MSK через SyncSupplierProjectsJob (множественный). Queue::assertNotPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === Project::query()->where('tenant_id', $tenant->id)->value('id')); }); it('dispatches SyncSupplierProjectJob on unfreeze when supplier mode is online', function () { Mail::fake(); Queue::fake(); DB::table('system_settings')->updateOrInsert(['key' => 'supplier_export_mode'], ['value' => 'online']); // 2000₽ / 50₽ = 40 лидов; хотят 25 → разморозка. $tenant = Tenant::factory()->create(['balance_rub' => '2000.00', 'frozen_by_balance_at' => now()->subDay()]); $project = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]); (new BalancePreflightSweepJob)->handle(); expect($tenant->fresh()->frozen_by_balance_at)->toBeNull(); Queue::assertPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === $project->id); }); // Stage 3 / Task 3.2 — R-13 (spec §4.3.2): freeze/unfreeze sync paused_at on tenant projects. // SupplierSnapshotGuard блокирует delete/change_source когда paused_at свежее grace-периода. // Без этой синхронизации frozen-тенант остаётся «голым» для guard'а — клиент мог бы удалить // проект во время заморозки и пропустить хвост слепка поставщика. it('sets paused_at on tenant projects without paused_at when freezing', function () { Mail::fake(); // 500₽ / 50₽ = 10 лидов; проект хочет 25 → заморозка. $tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]); $project = Project::factory()->for($tenant)->create([ 'is_active' => true, 'daily_limit_target' => 25, 'paused_at' => null, ]); (new BalancePreflightSweepJob)->handle(); $fresh = $project->fresh(); expect($fresh->paused_at)->not->toBeNull(); // freeze-moment должен совпадать с tenant.frozen_by_balance_at для последующего unfreeze-matcher'а. expect($fresh->paused_at->timestamp)->toBe($tenant->fresh()->frozen_by_balance_at->timestamp); }); it('clears paused_at on auto-paused projects when unfreezing, preserves manual pauses', function () { Mail::fake(); // Frozen вчера в 12:00; пауза до этого момента = ручная, после = авто. $frozenAt = now()->subDay(); $tenant = Tenant::factory()->create([ 'balance_rub' => '2000.00', 'frozen_by_balance_at' => $frozenAt, ]); // Auto-paused в момент freeze (timestamp == frozenAt → попадает в >= filter). $autoPaused = Project::factory()->for($tenant)->create([ 'is_active' => false, 'daily_limit_target' => 5, 'paused_at' => $frozenAt, ]); // Manual-paused за 2 дня до freeze (timestamp < frozenAt → НЕ попадает в >= filter). $manualPaused = Project::factory()->for($tenant)->create([ 'is_active' => false, 'daily_limit_target' => 5, 'paused_at' => now()->subDays(2), ]); (new BalancePreflightSweepJob)->handle(); expect($tenant->fresh()->frozen_by_balance_at)->toBeNull(); expect($autoPaused->fresh()->paused_at)->toBeNull(); expect($manualPaused->fresh()->paused_at)->not->toBeNull(); });