seed(PricingTierSeeder::class); $this->ledger = app(LedgerService::class); }); function makeTenantWith(int $balanceLeads, string $balanceRub, int $deliveredInMonth = 0): Tenant { return Tenant::factory()->create([ 'balance_leads' => $balanceLeads, 'balance_rub' => $balanceRub, 'delivered_in_month' => $deliveredInMonth, ]); } function makeDealForTenant(Tenant $tenant): Deal { return Deal::factory()->create([ 'tenant_id' => $tenant->id, 'received_at' => now(), ]); } it('charges rub when balance_rub >= price', function () { $tenant = makeTenantWith(balanceLeads: 0, balanceRub: '1000.00', deliveredInMonth: 0); $deal = makeDealForTenant($tenant); $result = DB::transaction(function () use ($tenant, $deal) { DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); $locked = Tenant::whereKey($tenant->id)->lockForUpdate()->firstOrFail(); return $this->ledger->chargeForDelivery($locked, $deal); }); expect($result->priceKopecks)->toBe(50000); $tenant->refresh(); expect((string) $tenant->balance_rub)->toBe('500.00'); expect((int) $tenant->balance_leads)->toBe(0); expect($tenant->delivered_in_month)->toBe(1); $charge = LeadCharge::first(); expect($charge->charge_source)->toBe('rub'); expect($charge->price_per_lead_kopecks)->toBe(50000); }); it('throws InsufficientBalanceException when balance_rub * 100 < priceKopecks', function () { $tenant = makeTenantWith(balanceLeads: 0, balanceRub: '400.00', deliveredInMonth: 0); $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); expect(LeadCharge::count())->toBe(0); $tenant->refresh(); expect((int) $tenant->balance_leads)->toBe(0); expect((string) $tenant->balance_rub)->toBe('400.00'); expect($tenant->delivered_in_month)->toBe(0); }); it('charges rub at exact balance == price boundary', function () { $tenant = makeTenantWith(balanceLeads: 0, balanceRub: '500.00', deliveredInMonth: 0); $deal = makeDealForTenant($tenant); $result = DB::transaction(function () use ($tenant, $deal) { DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); $locked = Tenant::whereKey($tenant->id)->lockForUpdate()->firstOrFail(); return $this->ledger->chargeForDelivery($locked, $deal); }); $tenant->refresh(); expect((string) $tenant->balance_rub)->toBe('0.00'); }); it('always uses charge_source=rub regardless of historic balance_leads value', function () { // Historic prepaid leftover — must be ignored by new always-rub flow. $tenant = makeTenantWith(balanceLeads: 5, balanceRub: '1000.00', deliveredInMonth: 0); $deal = makeDealForTenant($tenant); 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); }); $tenant->refresh(); // balance_leads must remain UNCHANGED (we never touch this column anymore). expect((int) $tenant->balance_leads)->toBe(5); // balance_rub must be debited by tier 1 price (500₽ for delivered_in_month=0 → tier 1). expect((string) $tenant->balance_rub)->toBe('500.00'); expect($tenant->delivered_in_month)->toBe(1); $charge = LeadCharge::first(); expect($charge->charge_source)->toBe('rub'); expect($charge->price_per_lead_kopecks)->toBe(50000); }); it('crosses tier boundary: delivered_in_month=99 → tier 1; delivered_in_month=100 → tier 2', function () { $tenantA = makeTenantWith(balanceLeads: 0, balanceRub: '10000.00', deliveredInMonth: 99); $tenantB = makeTenantWith(balanceLeads: 0, balanceRub: '10000.00', deliveredInMonth: 100); $dealA = makeDealForTenant($tenantA); $dealB = makeDealForTenant($tenantB); $resultA = DB::transaction(function () use ($tenantA, $dealA) { DB::statement("SET LOCAL app.current_tenant_id = '{$tenantA->id}'"); $locked = Tenant::whereKey($tenantA->id)->lockForUpdate()->firstOrFail(); return $this->ledger->chargeForDelivery($locked, $dealA); }); $resultB = DB::transaction(function () use ($tenantB, $dealB) { DB::statement("SET LOCAL app.current_tenant_id = '{$tenantB->id}'"); $locked = Tenant::whereKey($tenantB->id)->lockForUpdate()->firstOrFail(); return $this->ledger->chargeForDelivery($locked, $dealB); }); expect($resultA->tier->tier_no)->toBe(1); expect($resultB->tier->tier_no)->toBe(2); }); it('writes supplier_lead_costs (gap-fix: Plan 2/3 не писали в sharing-flow)', function () { $tenant = makeTenantWith(balanceLeads: 0, balanceRub: '10000.00'); $supplier = Supplier::where('code', 'b1')->first(); $supplierProject = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', ]); $deal = Deal::factory()->create(['tenant_id' => $tenant->id, 'received_at' => now()]); $lead = SupplierLead::factory()->create(['supplier_project_id' => $supplierProject->id]); DB::transaction(function () use ($tenant, $deal, $lead) { DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); $locked = Tenant::whereKey($tenant->id)->lockForUpdate()->firstOrFail(); $this->ledger->chargeForDelivery($locked, $deal, $lead); }); $cost = DB::table('supplier_lead_costs')->where('deal_id', $deal->id)->first(); expect($cost)->not->toBeNull(); 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); });