diff --git a/app/app/Jobs/Billing/BalancePreflightSweepJob.php b/app/app/Jobs/Billing/BalancePreflightSweepJob.php index d7e9fa9b..b6dc98be 100644 --- a/app/app/Jobs/Billing/BalancePreflightSweepJob.php +++ b/app/app/Jobs/Billing/BalancePreflightSweepJob.php @@ -71,8 +71,19 @@ final class BalancePreflightSweepJob implements ShouldQueue // Переход active → frozen. if (! $result->passes && ! $isFrozen) { - $tenant->frozen_by_balance_at = now(); + $freezeAt = now(); + $tenant->frozen_by_balance_at = $freezeAt; $tenant->save(); + + // Stage 3 R-13 (spec §4.3.2): помечаем все непаузнутые проекты + // тенанта моментом заморозки. Это даёт SupplierSnapshotGuard + // зацепку (paused_at свежее grace-периода) — клиент не сможет + // удалить/сменить источник пока хвост слепка ещё может прилететь. + DB::connection('pgsql_supplier')->table('projects') + ->where('tenant_id', $tenant->id) + ->whereNull('paused_at') + ->update(['paused_at' => $freezeAt]); + $this->logEvent($tenant, 'frozen', 'cutoff_18msk', $result); Mail::queue(new BalanceFrozenMail($tenant, $result)); $this->dispatchSupplierSyncIfOnline($tenant); @@ -82,8 +93,20 @@ final class BalancePreflightSweepJob implements ShouldQueue // Переход frozen → active. if ($result->passes && $isFrozen) { + // Stage 3 R-13: фиксируем frozen-moment ДО $tenant->save() — нужно + // для фильтра отката paused_at. Очищаем только те проекты, + // у которых paused_at >= frozen_at_was (== поставленные нами на паузу + // в freeze-блоке). Ручные паузы клиента ДО заморозки имеют + // paused_at < frozen_at_was и сохраняются. + $frozenAtWas = $tenant->frozen_by_balance_at; $tenant->frozen_by_balance_at = null; $tenant->save(); + + DB::connection('pgsql_supplier')->table('projects') + ->where('tenant_id', $tenant->id) + ->where('paused_at', '>=', $frozenAtWas) + ->update(['paused_at' => null]); + $this->logEvent($tenant, 'unfrozen', 'cutoff_18msk', $result); Mail::queue(new BalanceUnfrozenMail($tenant, $result)); $this->dispatchSupplierSyncIfOnline($tenant); diff --git a/app/app/Services/Billing/LedgerService.php b/app/app/Services/Billing/LedgerService.php index ea9002ec..20e961b8 100644 --- a/app/app/Services/Billing/LedgerService.php +++ b/app/app/Services/Billing/LedgerService.php @@ -56,6 +56,17 @@ final class LedgerService ); $priceKopecks = (int) $tier->price_per_lead_kopecks; + // R-03 (Stage 3 §4.3.1): frozen tenant must not receive new charges even + // if balance_rub > 0. Throwing here triggers the same auto-pause flow as + // InsufficientBalance — RouteSupplierLeadJob::handleInsufficientBalance + // flips projects.is_active=false and queues ZeroBalancePausedMail rate-limited. + if ($lockedTenant->frozen_by_balance_at !== null) { + throw new InsufficientBalanceException( + priceKopecks: $priceKopecks, + balanceRub: (string) $lockedTenant->balance_rub, + ); + } + // bcmath: balance_rub × 100 ≥ priceKopecks — единственный путь списания. // Billing v2 Spec A: prepaid-лиды убраны, balance_leads НЕ читается и НЕ изменяется. $balanceKopecks = bcmul((string) $lockedTenant->balance_rub, '100', 0); diff --git a/app/app/Services/LeadRouter.php b/app/app/Services/LeadRouter.php index 3da56fa8..a651627d 100644 --- a/app/app/Services/LeadRouter.php +++ b/app/app/Services/LeadRouter.php @@ -76,6 +76,8 @@ class LeadRouter SELECT 1 FROM tenants WHERE tenants.id = snap.tenant_id AND tenants.balance_rub > 0 + -- R-03: frozen tenant must not receive new leads (Stage 3 §4.3.1) + AND tenants.frozen_by_balance_at IS NULL ) ORDER BY snap.tenant_id, (snap.daily_limit - projects.delivered_today) DESC, @@ -110,6 +112,8 @@ class LeadRouter SELECT 1 FROM tenants WHERE tenants.id = snap.tenant_id AND tenants.balance_rub > 0 + -- R-03: frozen tenant must not receive new leads (Stage 3 §4.3.1) + AND tenants.frozen_by_balance_at IS NULL ) ORDER BY snap.tenant_id, (snap.daily_limit - projects.delivered_today) DESC, diff --git a/app/tests/Feature/Billing/BalancePreflightSweepJobTest.php b/app/tests/Feature/Billing/BalancePreflightSweepJobTest.php index 49e537e6..3b65ac38 100644 --- a/app/tests/Feature/Billing/BalancePreflightSweepJobTest.php +++ b/app/tests/Feature/Billing/BalancePreflightSweepJobTest.php @@ -116,3 +116,54 @@ it('dispatches SyncSupplierProjectJob on unfreeze when supplier mode is online', 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(); +}); diff --git a/app/tests/Feature/Billing/LedgerServiceTest.php b/app/tests/Feature/Billing/LedgerServiceTest.php index 5016d6a5..ba288c01 100644 --- a/app/tests/Feature/Billing/LedgerServiceTest.php +++ b/app/tests/Feature/Billing/LedgerServiceTest.php @@ -168,3 +168,29 @@ it('writes supplier_lead_costs (gap-fix: Plan 2/3 не писали в sharing-f expect((int) $cost->supplier_id)->toBe($supplier->id); expect((string) $cost->cost_rub)->toBe($supplier->cost_rub); }); + +// Stage 3 / Task 3.1 — R-03 (spec §4.3.1): a frozen tenant must be rejected at +// charge time even when balance_rub > 0. Guard is BEFORE bcmath arithmetic so +// no balance / charges state is touched on rejection. The same auto-pause flow +// kicks in (InsufficientBalanceException → RouteSupplierLeadJob handler flips +// projects.is_active=false and queues ZeroBalancePausedMail rate-limited). +it('throws InsufficientBalanceException when tenant frozen_by_balance_at is set', function () { + $tenant = Tenant::factory()->create([ + 'balance_rub' => '500.00', + 'frozen_by_balance_at' => now(), + ]); + $deal = makeDealForTenant($tenant); + + expect(function () use ($tenant, $deal) { + DB::transaction(function () use ($tenant, $deal) { + DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); + $locked = Tenant::whereKey($tenant->id)->lockForUpdate()->firstOrFail(); + $this->ledger->chargeForDelivery($locked, $deal); + }); + })->toThrow(InsufficientBalanceException::class); + + // No side effects on frozen reject — balance and charges untouched. + $tenant->refresh(); + expect((string) $tenant->balance_rub)->toBe('500.00'); + expect(LeadCharge::where('tenant_id', $tenant->id)->count())->toBe(0); +}); diff --git a/app/tests/Feature/LeadRouter/FrozenFilterTest.php b/app/tests/Feature/LeadRouter/FrozenFilterTest.php new file mode 100644 index 00000000..615b697d --- /dev/null +++ b/app/tests/Feature/LeadRouter/FrozenFilterTest.php @@ -0,0 +1,152 @@ +create([ + 'balance_rub' => '500.00', + 'frozen_by_balance_at' => now(), // frozen — R-03 + ]); + $project = Project::factory()->for($tenant)->create([ + 'is_active' => true, + 'delivery_days_mask' => 127, + 'daily_limit_target' => 10, + 'delivered_today' => 0, + ]); + $sp = SupplierProject::factory()->create(['platform' => 'B1']); + DB::table('project_supplier_links')->insert([ + 'project_id' => $project->id, + 'supplier_project_id' => $sp->id, + 'platform' => $sp->platform, + 'subject_code' => null, + ]); + DB::table('project_routing_snapshots')->insert([ + 'snapshot_date' => '2026-05-28', + 'project_id' => $project->id, + 'tenant_id' => $tenant->id, + 'daily_limit' => 10, + 'delivery_days_mask' => 127, + 'regions' => '{}', + 'signal_type' => 'call', + 'signal_identifier' => null, + 'sms_senders' => null, + 'sms_keyword' => null, + 'expected_volume' => 10, + 'delivered_count' => 0, + 'created_at' => now(), + ]); + + $matched = app(LeadRouter::class)->matchEligibleProjects($sp); + + expect($matched)->toHaveCount(0); // R-03: frozen tenant must not receive leads +}); + +// --------------------------------------------------------------------------- +// Case 2 — DIRECT-platform: frozen tenant must NOT receive leads +// --------------------------------------------------------------------------- +it('does not match DIRECT-platform project for frozen tenant (frozen_by_balance_at IS NOT NULL)', function () { + $tenant = Tenant::factory()->create([ + 'balance_rub' => '500.00', + 'frozen_by_balance_at' => now(), // frozen — R-03 + ]); + $project = Project::factory()->for($tenant)->create([ + 'is_active' => true, + 'delivery_days_mask' => 127, + 'daily_limit_target' => 10, + 'delivered_today' => 0, + ]); + + // DIRECT supplier_project matches via signal_type + unique_key + $sp = SupplierProject::factory()->create([ + 'platform' => 'DIRECT', + 'signal_type' => 'call', + 'unique_key' => 'direct-test-frozen-001', + ]); + + // Snapshot must carry signal_type + signal_identifier matching sp->unique_key + DB::table('project_routing_snapshots')->insert([ + 'snapshot_date' => '2026-05-28', + 'project_id' => $project->id, + 'tenant_id' => $tenant->id, + 'daily_limit' => 10, + 'delivery_days_mask' => 127, + 'regions' => '{}', + 'signal_type' => 'call', + 'signal_identifier' => 'direct-test-frozen-001', // matches sp->unique_key + 'sms_senders' => null, + 'sms_keyword' => null, + 'expected_volume' => 10, + 'delivered_count' => 0, + 'created_at' => now(), + ]); + + $matched = app(LeadRouter::class)->matchEligibleProjects($sp); + + expect($matched)->toHaveCount(0); // R-03: frozen tenant must not receive leads +}); + +// --------------------------------------------------------------------------- +// Case 3 (control) — B-platform, not frozen: MUST receive leads +// --------------------------------------------------------------------------- +it('matches B-platform project for non-frozen tenant (frozen_by_balance_at IS NULL)', function () { + $tenant = Tenant::factory()->create([ + 'balance_rub' => '500.00', + 'frozen_by_balance_at' => null, // NOT frozen — should match + ]); + $project = Project::factory()->for($tenant)->create([ + 'is_active' => true, + 'delivery_days_mask' => 127, + 'daily_limit_target' => 10, + 'delivered_today' => 0, + ]); + $sp = SupplierProject::factory()->create(['platform' => 'B1']); + DB::table('project_supplier_links')->insert([ + 'project_id' => $project->id, + 'supplier_project_id' => $sp->id, + 'platform' => $sp->platform, + 'subject_code' => null, + ]); + DB::table('project_routing_snapshots')->insert([ + 'snapshot_date' => '2026-05-28', + 'project_id' => $project->id, + 'tenant_id' => $tenant->id, + 'daily_limit' => 10, + 'delivery_days_mask' => 127, + 'regions' => '{}', + 'signal_type' => 'call', + 'signal_identifier' => null, + 'sms_senders' => null, + 'sms_keyword' => null, + 'expected_volume' => 10, + 'delivered_count' => 0, + 'created_at' => now(), + ]); + + $matched = app(LeadRouter::class)->matchEligibleProjects($sp); + + expect($matched)->toHaveCount(1); // control: non-frozen tenant with balance IS eligible +});