seed(PricingTierSeeder::class); DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); }); /** * Подготовка sharing-flow: N тенантов с указанными балансами, каждый — * со своим Project, привязанным к одному supplierProject (платформа B1, site). * * @param array> $balances * @return array{tenants: array, projects: array, lead: SupplierLead, supplier: Supplier} */ function prepareSharingFlow(int $tenantsCount, array $balances): array { /** @var array $tenants */ $tenants = []; /** @var array $projects */ $projects = []; $supplier = Supplier::where('code', 'b1')->first(); $supplierProject = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'example.com', // NB: supplier_projects has NO supplier_id column; LedgerService resolves // supplier via platform → suppliers.code mapping. ]); for ($i = 0; $i < $tenantsCount; $i++) { $tenant = Tenant::factory()->create($balances[$i]); $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'signal_type' => 'site', 'signal_identifier' => 'example.com', 'supplier_b1_project_id' => $supplierProject->id, 'is_active' => true, 'daily_limit_target' => 10, 'effective_daily_limit_today' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255, ]); $tenants[] = $tenant; $projects[] = $project; } $vid = random_int(100_000_000, 999_999_999); $lead = SupplierLead::factory()->create([ 'vid' => $vid, 'phone' => '79991234567', 'raw_payload' => ['vid' => $vid, 'project' => 'B1_example.com', 'phone' => '79991234567', 'time' => time()], 'supplier_project_id' => $supplierProject->id, 'received_at' => now(), ]); return ['tenants' => $tenants, 'projects' => $projects, 'lead' => $lead, 'supplier' => $supplier]; } function dispatchJob(int $supplierLeadId): void { (new RouteSupplierLeadJob($supplierLeadId))->handle( app(LeadRouter::class), app(SupplierProjectResolver::class), app(DuplicateDetector::class), app(NotificationService::class), app(LedgerService::class), ); } it('charges prepaid for tenant with balance_leads > 0 + writes BalanceTransaction', function (): void { $ctx = prepareSharingFlow(1, [['balance_leads' => 5, 'balance_rub' => '0.00', 'delivered_in_month' => 0]]); dispatchJob($ctx['lead']->id); $tenant = $ctx['tenants'][0]->fresh(); expect((int) $tenant->balance_leads)->toBe(4); expect($tenant->delivered_in_month)->toBe(1); $charge = LeadCharge::first(); expect($charge)->not->toBeNull(); expect($charge->charge_source)->toBe('prepaid'); expect($charge->price_per_lead_kopecks)->toBe(0); // BalanceTransaction (carry-over M-2 assertion) $tx = BalanceTransaction::where('type', BalanceTransaction::TYPE_LEAD_CHARGE)->first(); expect($tx)->not->toBeNull(); expect((int) $tx->amount_leads)->toBe(-1); expect((int) $tx->balance_leads_after)->toBe(4); }); it('charges rub for tenant with balance_leads=0 and balance_rub >= price + writes BalanceTransaction', function (): void { $ctx = prepareSharingFlow(1, [['balance_leads' => 0, 'balance_rub' => '1000.00', 'delivered_in_month' => 0]]); dispatchJob($ctx['lead']->id); $tenant = $ctx['tenants'][0]->fresh(); 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); // BalanceTransaction (carry-over M-2 assertion) $tx = BalanceTransaction::where('type', BalanceTransaction::TYPE_LEAD_CHARGE)->first(); expect($tx)->not->toBeNull(); expect((string) $tx->amount_rub)->toBe('-500.00'); expect((string) $tx->balance_rub_after)->toBe('500.00'); }); it('writes supplier_lead_costs for each delivered deal copy (gap-fix)', function (): void { $ctx = prepareSharingFlow(2, [ ['balance_leads' => 5, 'balance_rub' => '0.00', 'delivered_in_month' => 0], ['balance_leads' => 0, 'balance_rub' => '1000.00', 'delivered_in_month' => 0], ]); dispatchJob($ctx['lead']->id); $costs = DB::table('supplier_lead_costs')->get(); expect($costs)->toHaveCount(2); foreach ($costs as $cost) { expect((int) $cost->supplier_id)->toBe($ctx['supplier']->id); expect((string) $cost->cost_rub)->toBe($ctx['supplier']->cost_rub); } }); it('retry idempotency: повторный run не дублирует lead_charges', function (): void { $ctx = prepareSharingFlow(1, [['balance_leads' => 5, 'balance_rub' => '0.00']]); $leadId = $ctx['lead']->id; dispatchJob($leadId); dispatchJob($leadId); // повторный — processed_at guard защищает expect(LeadCharge::count())->toBe(1); expect(Deal::count())->toBe(1); expect((int) $ctx['tenants'][0]->fresh()->balance_leads)->toBe(4); });