= snap.daily_limit. * Client1 delivered_today == its snapshot daily_limit → ineligible; Client2 gets the lead. * * Subject codes: порядковые 1..89. Москва = 82. Confirmed via RussianRegions::CODE_TO_NAME. * Tier 1 price = 50 000 kopecks = 500 RUB (PricingTierSeeder). * * Task 9 — Phase 1 Portal Client Imitation. * Plan: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md §Task 9 * Spec: docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md §6.2 E1/E2/F */ // ── Shared constants ────────────────────────────────────────────────────────── /** Москва subject code (порядковый, НЕ ГИБДД). */ const EF_MOSCOW_CODE = 82; /** Domain for B1 site signal shared across all three scenarios. */ const EF_DOMAIN = 'scenario-ef-test.ru'; /** Platform prefix. */ const EF_PLATFORM = 'B1'; /** Deterministic seed — makes weighted lottery pick reproducible. */ const EF_SEED = 99; /** Tier-1 price in RUB (first 100 leads of month): 500 RUB. */ const EF_TIER1_PRICE_RUB = '500.00'; /** Daily limit used for healthy clients — large enough to never block. */ const EF_HEALTHY_LIMIT = 50; // ── Shared beforeEach setup ─────────────────────────────────────────────────── beforeEach(function (): void { // Seed pricing tiers (tier 1: first 100 leads → 50 000 kopecks = 500 RUB/lead). $this->seed(PricingTierSeeder::class); // Allow cross-tenant reads during seeding (shared helper pattern). DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); // DaData config — required by LeadRegionResolver even when FakeDaDataPhoneClient is used. config([ 'services.dadata.enabled' => true, 'services.dadata.api_key' => 'fake-key', 'services.dadata.secret' => 'fake-secret', 'services.dadata.daily_cap_rub' => 1_000_000, ]); // Deterministic LeadRouter: Mt19937 seed so weighted pick is reproducible. app()->instance(LeadRouter::class, new LeadRouter(new Randomizer(new Mt19937(EF_SEED)))); }); // ───────────────────────────────────────────────────────────────────────────── // E1 — Auto-pause on insufficient balance // ───────────────────────────────────────────────────────────────────────────── it('E1: project is paused and mail sent when balance below tier price; lead goes to healthy client', function (): void { Mail::fake(); // ── ARRANGE ────────────────────────────────────────────────────────────── // One shared supplier project (B1 site signal). $supplier = SupplierProject::factory()->create([ 'platform' => EF_PLATFORM, 'signal_type' => 'site', 'unique_key' => EF_DOMAIN, ]); // Client1: balance BELOW tier-1 price (500 RUB). Even 1 kopeck short triggers pause. // We set balance to 0 which is definitely below 500 RUB threshold. $tenant1 = Tenant::factory()->create([ 'balance_rub' => '0.00', 'frozen_by_balance_at' => null, ]); $project1 = Project::factory()->create([ 'tenant_id' => $tenant1->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => EF_DOMAIN, 'daily_limit_target' => EF_HEALTHY_LIMIT, 'effective_daily_limit_today' => null, 'delivered_today' => 0, 'delivered_in_month' => 0, 'delivery_days_mask' => 127, 'preflight_blocked_at' => null, 'regions' => [EF_MOSCOW_CODE], ]); linkProjectToSupplier($project1, $supplier); // Client2: healthy balance — should receive the lead. $tenant2 = Tenant::factory()->create([ 'balance_rub' => '9999.00', 'frozen_by_balance_at' => null, ]); $project2 = Project::factory()->create([ 'tenant_id' => $tenant2->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => EF_DOMAIN, 'daily_limit_target' => EF_HEALTHY_LIMIT, 'effective_daily_limit_today' => null, 'delivered_today' => 0, 'delivered_in_month' => 0, 'delivery_days_mask' => 127, 'preflight_blocked_at' => null, 'regions' => [EF_MOSCOW_CODE], ]); linkProjectToSupplier($project2, $supplier); // Snapshot: both clients eligible (positive balance + unfrozen are live-state checks, // but snapshot itself is built before the charge; LeadRouter's SQL also checks balance > 0 // and frozen_by_balance_at IS NULL). Client1 has balance=0 so it will NOT appear in the // router's eligibility query (balance_rub > 0 filter), making this effectively test the // case where balance becomes 0 AFTER snapshot is built but before the charge. // // IMPORTANT FINDING NOTE: LeadRouter SQL WHERE tenants.balance_rub > 0 means that if // balance is already 0 before the route call, Client1 is excluded at the query stage // (not at the charge stage). To truly test E1 (insufficient balance at charge time), // we must set balance to a non-zero amount that is nonetheless below the tier price. // Tier 1 = 500 RUB. Set to 499.99 — positive but insufficient. ConditionLevers::setBalance($tenant1, '499.99'); $activeDate = SnapshotForge::activeDate(); // Build snapshots for both clients so both pass the snapshot filter. createRoutingSnapshotFromProject( project: $project1, date: $activeDate, signalType: 'site', signalIdentifier: EF_DOMAIN, dailyLimit: EF_HEALTHY_LIMIT, regions: '{'.EF_MOSCOW_CODE.'}', ); createRoutingSnapshotFromProject( project: $project2, date: $activeDate, signalType: 'site', signalIdentifier: EF_DOMAIN, dailyLimit: EF_HEALTHY_LIMIT, regions: '{'.EF_MOSCOW_CODE.'}', ); // FakeDaData: phone → Москва (qc=0, subject_code=82). $phone = '79161234001'; $fakeDaData = new FakeDaDataPhoneClient; $fakeDaData->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fakeDaData); // ── ACT ────────────────────────────────────────────────────────────────── $injector = new LeadInjector; $lead = $injector->site( domain: EF_DOMAIN, phone: $phone, tag: 'Москва', platform: EF_PLATFORM, vid: 1_100_000_001, ); // ── ASSERT ─────────────────────────────────────────────────────────────── // Client1's project must be paused (is_active=false) after the balance failure. $project1->refresh(); expect($project1->is_active)->toBeFalse( 'FINDING E1: project1 was NOT paused after InsufficientBalance. '. 'Expected RouteSupplierLeadJob::handleInsufficientBalance to set is_active=false. '. 'Actual is_active='.($project1->is_active ? 'true' : 'false') ); // ZeroBalancePausedMail must have been sent for tenant1 (rate-limit: first call always fires). Mail::assertSent(ZeroBalancePausedMail::class, function (ZeroBalancePausedMail $mail) use ($tenant1): bool { return $mail->hasTo($tenant1->contact_email); }); // Client2 (healthy) must have received the lead. $tenant2Deals = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenant2->id) ->count(); expect($tenant2Deals)->toBe(1, 'FINDING E1: Client2 (healthy) did NOT receive the lead. '. "Expected 1 deal for tenant2 (id={$tenant2->id}), got {$tenant2Deals}. ". 'The auto-pause flow should skip Client1 and continue routing to Client2.' ); // Client1 must NOT have a deal (charge rolled back). $tenant1Deals = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenant1->id) ->count(); expect($tenant1Deals)->toBe(0, 'FINDING E1: Client1 (insufficient balance) has a deal when it should have 0. '. "Tenant1 id={$tenant1->id} has {$tenant1Deals} deals. ". 'InsufficientBalance should roll back the transaction, preventing deal creation.' ); // lead must be marked processed. expect($lead->processed_at)->not->toBeNull( 'FINDING E1: SupplierLead.processed_at is null — lead was not marked processed.' ); })->group('imitation'); // ───────────────────────────────────────────────────────────────────────────── // E2 — Frozen tenant excluded at eligibility filter (before charge) // ───────────────────────────────────────────────────────────────────────────── it('E2: frozen tenant is excluded from eligibility; healthy client gets the lead', function (): void { // ── ARRANGE ────────────────────────────────────────────────────────────── $supplier = SupplierProject::factory()->create([ 'platform' => EF_PLATFORM, 'signal_type' => 'site', 'unique_key' => EF_DOMAIN, ]); // Client1: frozen via ConditionLevers::freeze(). // After freeze(), frozen_by_balance_at IS NOT NULL → excluded by LeadRouter SQL // WHERE tenants.frozen_by_balance_at IS NULL. $tenant1 = Tenant::factory()->create([ 'balance_rub' => '9999.00', // balance is fine — frozen is the blocker 'frozen_by_balance_at' => null, ]); $project1 = Project::factory()->create([ 'tenant_id' => $tenant1->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => EF_DOMAIN, 'daily_limit_target' => EF_HEALTHY_LIMIT, 'effective_daily_limit_today' => null, 'delivered_today' => 0, 'delivered_in_month' => 0, 'delivery_days_mask' => 127, 'preflight_blocked_at' => null, 'regions' => [EF_MOSCOW_CODE], ]); linkProjectToSupplier($project1, $supplier); // Freeze Client1 BEFORE snapshot rebuild. // LeadRouter SQL: AND tenants.frozen_by_balance_at IS NULL // Frozen clients are excluded at the query stage — no charge attempt is made. ConditionLevers::freeze($tenant1); // Client2: healthy. $tenant2 = Tenant::factory()->create([ 'balance_rub' => '9999.00', 'frozen_by_balance_at' => null, ]); $project2 = Project::factory()->create([ 'tenant_id' => $tenant2->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => EF_DOMAIN, 'daily_limit_target' => EF_HEALTHY_LIMIT, 'effective_daily_limit_today' => null, 'delivered_today' => 0, 'delivered_in_month' => 0, 'delivery_days_mask' => 127, 'preflight_blocked_at' => null, 'regions' => [EF_MOSCOW_CODE], ]); linkProjectToSupplier($project2, $supplier); $activeDate = SnapshotForge::activeDate(); // Snapshots for both clients. The snapshot itself may include Client1 (snapshot was // built from static project data), but LeadRouter's SQL live-checks frozen_by_balance_at. createRoutingSnapshotFromProject( project: $project1, date: $activeDate, signalType: 'site', signalIdentifier: EF_DOMAIN, dailyLimit: EF_HEALTHY_LIMIT, regions: '{'.EF_MOSCOW_CODE.'}', ); createRoutingSnapshotFromProject( project: $project2, date: $activeDate, signalType: 'site', signalIdentifier: EF_DOMAIN, dailyLimit: EF_HEALTHY_LIMIT, regions: '{'.EF_MOSCOW_CODE.'}', ); $phone = '79161234002'; $fakeDaData = new FakeDaDataPhoneClient; $fakeDaData->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fakeDaData); // ── ACT ────────────────────────────────────────────────────────────────── $injector = new LeadInjector; $lead = $injector->site( domain: EF_DOMAIN, phone: $phone, tag: 'Москва', platform: EF_PLATFORM, vid: 1_100_000_002, ); // ── ASSERT ─────────────────────────────────────────────────────────────── // Client1 (frozen) must have ZERO deals — excluded at filter stage. $tenant1Deals = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenant1->id) ->count(); expect($tenant1Deals)->toBe(0, 'FINDING E2: Frozen client (tenant1) received a deal. '. "Expected 0 deals for tenant1 (id={$tenant1->id}), got {$tenant1Deals}. ". 'LeadRouter SQL WHERE tenants.frozen_by_balance_at IS NULL should exclude this tenant. '. 'This is a serious billing/freeze bug.' ); // Client2 (healthy) must have received the lead. $tenant2Deals = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenant2->id) ->count(); expect($tenant2Deals)->toBe(1, 'FINDING E2: Healthy Client2 did NOT receive the lead. '. "Expected 1 deal for tenant2 (id={$tenant2->id}), got {$tenant2Deals}. ". 'With frozen Client1 excluded, Client2 should be the sole eligible recipient.' ); // Verify tenant1 IS still frozen (no accidental unfreeze by any path). $tenant1->refresh(); expect($tenant1->frozen_by_balance_at)->not->toBeNull( 'FINDING E2: tenant1.frozen_by_balance_at was cleared during routing. '. 'Freeze state must be preserved across the routing cycle.' ); // lead processed. expect($lead->processed_at)->not->toBeNull( 'FINDING E2: SupplierLead.processed_at is null — lead was not marked processed.' ); })->group('imitation'); // ───────────────────────────────────────────────────────────────────────────── // F — Daily limit reached: project excluded from eligibility // ───────────────────────────────────────────────────────────────────────────── it('F: client at daily limit is excluded; lead goes to client with remaining capacity', function (): void { // ── ARRANGE ────────────────────────────────────────────────────────────── $supplier = SupplierProject::factory()->create([ 'platform' => EF_PLATFORM, 'signal_type' => 'site', 'unique_key' => EF_DOMAIN, ]); $dailyLimit = 5; // small limit so fillToLimit is clear // Client1: delivered_today EQUAL to daily_limit → excluded (delivered_today < daily_limit is false). $tenant1 = Tenant::factory()->create([ 'balance_rub' => '9999.00', 'frozen_by_balance_at' => null, ]); $project1 = Project::factory()->create([ 'tenant_id' => $tenant1->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => EF_DOMAIN, 'daily_limit_target' => $dailyLimit, 'effective_daily_limit_today' => null, 'delivered_today' => 0, 'delivered_in_month' => 0, 'delivery_days_mask' => 127, 'preflight_blocked_at' => null, 'regions' => [EF_MOSCOW_CODE], ]); linkProjectToSupplier($project1, $supplier); // Client2: healthy with headroom. $tenant2 = Tenant::factory()->create([ 'balance_rub' => '9999.00', 'frozen_by_balance_at' => null, ]); $project2 = Project::factory()->create([ 'tenant_id' => $tenant2->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => EF_DOMAIN, 'daily_limit_target' => EF_HEALTHY_LIMIT, 'effective_daily_limit_today' => null, 'delivered_today' => 0, 'delivered_in_month' => 0, 'delivery_days_mask' => 127, 'preflight_blocked_at' => null, 'regions' => [EF_MOSCOW_CODE], ]); linkProjectToSupplier($project2, $supplier); $activeDate = SnapshotForge::activeDate(); // Build snapshots BEFORE setting delivered_today to limit, // so both clients appear in the snapshot. createRoutingSnapshotFromProject( project: $project1, date: $activeDate, signalType: 'site', signalIdentifier: EF_DOMAIN, dailyLimit: $dailyLimit, regions: '{'.EF_MOSCOW_CODE.'}', ); createRoutingSnapshotFromProject( project: $project2, date: $activeDate, signalType: 'site', signalIdentifier: EF_DOMAIN, dailyLimit: EF_HEALTHY_LIMIT, regions: '{'.EF_MOSCOW_CODE.'}', ); // NOW set Client1 delivered_today = limit (5) so it fails the eligibility check: // LeadRouter SQL: AND projects.delivered_today < snap.daily_limit // Also the inner createDealCopyForProject recheck: $lockedProject->delivered_today >= $effectiveLimit ConditionLevers::fillToLimit($project1); $phone = '79161234003'; $fakeDaData = new FakeDaDataPhoneClient; $fakeDaData->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fakeDaData); // ── ACT ────────────────────────────────────────────────────────────────── $injector = new LeadInjector; $lead = $injector->site( domain: EF_DOMAIN, phone: $phone, tag: 'Москва', platform: EF_PLATFORM, vid: 1_100_000_003, ); // ── ASSERT ─────────────────────────────────────────────────────────────── // Client1 (at limit) must have ZERO deals. $tenant1Deals = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenant1->id) ->count(); expect($tenant1Deals)->toBe(0, 'FINDING F: Client1 (at daily limit) received a deal. '. "Expected 0 deals for tenant1 (id={$tenant1->id}), got {$tenant1Deals}. ". 'LeadRouter SQL: projects.delivered_today < snap.daily_limit excludes at-limit projects. '. "project1.daily_limit_target={$dailyLimit}, delivered_today should be {$dailyLimit} after fillToLimit." ); // Client2 (has headroom) must receive exactly 1 deal. $tenant2Deals = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenant2->id) ->count(); expect($tenant2Deals)->toBe(1, 'FINDING F: Client2 (with headroom) did NOT receive the lead. '. "Expected 1 deal for tenant2 (id={$tenant2->id}), got {$tenant2Deals}. ". 'With Client1 at limit and excluded, Client2 should be the sole eligible recipient.' ); // Verify project1 delivered_today is still at limit (nothing was delivered to it). $project1->refresh(); expect((int) $project1->delivered_today)->toBe($dailyLimit, 'FINDING F: project1.delivered_today changed during routing. '. "Expected {$dailyLimit} (untouched), got {$project1->delivered_today}. ". 'A lead was delivered to a project that exceeded its limit.' ); // project1 is_active should still be true — limit exhaustion alone does NOT trigger // auto-pause (only InsufficientBalance does). This is a deliberate design check. expect($project1->is_active)->toBeTrue( 'FINDING F: project1.is_active was set to false due to limit exhaustion. '. 'DESIGN NOTE: daily-limit exhaustion alone must NOT trigger auto-pause. '. 'Auto-pause (is_active=false) is only triggered by InsufficientBalance (billing failure). '. 'If this fails: auto-pause is being triggered by the wrong condition — report as bug.' ); // lead processed. expect($lead->processed_at)->not->toBeNull( 'FINDING F: SupplierLead.processed_at is null — lead was not marked processed.' ); })->group('imitation');