From d2030f9121d698f62f077da681202964b41ffc8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 11 May 2026 09:42:29 +0300 Subject: [PATCH] =?UTF-8?q?feat(billing):=20Plan=204=20Task=203=20?= =?UTF-8?q?=E2=80=94=20LedgerService::chargeForDelivery=20(dual-balance=20?= =?UTF-8?q?+=20lead=5Fcharges/supplier=5Flead=5Fcosts=20INSERT)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Billing/InsufficientBalanceException.php | 33 ++++ app/app/Services/Billing/ChargeResult.php | 19 ++ app/app/Services/Billing/LedgerService.php | 158 ++++++++++++++++ app/phpstan-baseline.neon | 24 +++ .../Feature/Billing/LedgerServiceTest.php | 175 ++++++++++++++++++ 5 files changed, 409 insertions(+) create mode 100644 app/app/Exceptions/Billing/InsufficientBalanceException.php create mode 100644 app/app/Services/Billing/ChargeResult.php create mode 100644 app/app/Services/Billing/LedgerService.php create mode 100644 app/tests/Feature/Billing/LedgerServiceTest.php diff --git a/app/app/Exceptions/Billing/InsufficientBalanceException.php b/app/app/Exceptions/Billing/InsufficientBalanceException.php new file mode 100644 index 00000000..4a7ad45c --- /dev/null +++ b/app/app/Exceptions/Billing/InsufficientBalanceException.php @@ -0,0 +1,33 @@ += 1), ни рублей под текущую tier-цену + * (balance_rub * 100 >= priceKopecks). + * + * Ловится в RouteSupplierLeadJob::createDealCopyForProject — инициирует + * auto-pause flow (см. spec §4.2). + */ +final class InsufficientBalanceException extends RuntimeException +{ + public function __construct( + public readonly int $priceKopecks, + public readonly string $balanceRub, + public readonly int $balanceLeads, + ?\Throwable $previous = null, + ) { + parent::__construct( + sprintf( + 'Insufficient balance: price_kopecks=%d, balance_rub=%s, balance_leads=%d', + $priceKopecks, $balanceRub, $balanceLeads, + ), + previous: $previous, + ); + } +} diff --git a/app/app/Services/Billing/ChargeResult.php b/app/app/Services/Billing/ChargeResult.php new file mode 100644 index 00000000..c785a5f1 --- /dev/null +++ b/app/app/Services/Billing/ChargeResult.php @@ -0,0 +1,19 @@ +tiers->activeAt(Carbon::now('Europe/Moscow')); + $tier = $this->resolver->resolveForCount($activeTiers, ($lockedTenant->delivered_in_month ?? 0) + 1); + $priceKopecks = (int) $tier->price_per_lead_kopecks; + + // 2. Decide chargeSource (bcmath — НЕ PHP float) + $source = $this->decideSource($lockedTenant, $priceKopecks); + + // 3. Apply (bcmath для money; raw DB::update — Eloquent decrement() требует float|int, + // что несовместимо с string-precision arithmetic для копеек/рублей). + if ($source === 'prepaid') { + $lockedTenant->decrement('balance_leads', 1); + } else { + $amountRub = bcdiv((string) $priceKopecks, '100', 2); + $newBalanceRub = bcsub((string) $lockedTenant->balance_rub, $amountRub, 2); + DB::table('tenants') + ->where('id', $lockedTenant->id) + ->update(['balance_rub' => $newBalanceRub]); + } + $lockedTenant->increment('delivered_in_month', 1); + $lockedTenant->refresh(); + + // 4. INSERT lead_charges (always) + LeadCharge::create([ + 'tenant_id' => $lockedTenant->id, + 'deal_id' => $deal->id, + 'deal_received_at' => $deal->received_at, + 'tier_no' => $tier->tier_no, + 'price_per_lead_kopecks' => $source === 'prepaid' ? 0 : $priceKopecks, + 'charge_source' => $source, + 'charged_at' => now(), + 'created_at' => now(), + ]); + + // 5. INSERT balance_transactions (универсальный ledger) + BalanceTransaction::create([ + 'tenant_id' => $lockedTenant->id, + 'type' => BalanceTransaction::TYPE_LEAD_CHARGE, + 'amount_leads' => $source === 'prepaid' ? -1 : 0, + 'amount_rub' => $source === 'rub' ? '-'.bcdiv((string) $priceKopecks, '100', 2) : '0.00', + 'balance_leads_after' => (int) $lockedTenant->balance_leads, + 'balance_rub_after' => (string) $lockedTenant->balance_rub, + 'related_type' => Deal::class, + 'related_id' => $deal->id, + 'created_at' => now(), + ]); + + // 6. INSERT supplier_lead_costs (gap-fix Plan 2/3 sharing-flow) + if ($lead !== null) { + $supplierId = $this->resolveSupplierId($lead); + if ($supplierId !== null) { + /** @var Supplier $supplier */ + $supplier = Supplier::findOrFail($supplierId); + DB::table('supplier_lead_costs')->insert([ + 'deal_id' => $deal->id, + 'received_at' => $deal->received_at, + 'supplier_id' => $supplierId, + 'cost_rub' => $supplier->cost_rub, + 'created_at' => now(), + ]); + } + } + + return new ChargeResult($source, $tier, $source === 'prepaid' ? 0 : $priceKopecks); + } + + private function decideSource(Tenant $tenant, int $priceKopecks): string + { + if ((int) $tenant->balance_leads >= 1) { + return 'prepaid'; + } + + // bcmath: balance_rub (DECIMAL string) * 100 ≥ priceKopecks → можем списать rub + $balanceKopecks = bcmul((string) $tenant->balance_rub, '100', 0); + if (bccomp($balanceKopecks, (string) $priceKopecks, 0) >= 0) { + return 'rub'; + } + + throw new InsufficientBalanceException( + priceKopecks: $priceKopecks, + balanceRub: (string) $tenant->balance_rub, + balanceLeads: (int) $tenant->balance_leads, + ); + } + + /** + * supplier_id из $lead->supplier_project->platform → suppliers.code (lowercase). + * supplier_projects не имеет колонки supplier_id (см. schema.sql §2.3, sharing-model); + * supplier_id выводится из platform (B1/B2/B3) через лookup suppliers WHERE code='b1'/'b2'/'b3'. + * + * Fallback: парсим platform из raw_payload['project'] (B1_xxx → 'b1'), если у lead'а нет + * supplier_project_id (legacy/orphan webhook). + */ + private function resolveSupplierId(SupplierLead $lead): ?int + { + if ($lead->supplier_project_id !== null) { + $sp = DB::table('supplier_projects')->where('id', $lead->supplier_project_id)->first(); + if ($sp !== null && in_array($sp->platform, ['B1', 'B2', 'B3'], true)) { + $supplier = Supplier::where('code', strtolower($sp->platform))->first(); + if ($supplier !== null) { + return (int) $supplier->id; + } + } + } + // Fallback: парсим platform из raw_payload['project'] (B1_xxx → 'b1') + $project = (string) ($lead->raw_payload['project'] ?? ''); + if (preg_match('/^(B[123])_/', $project, $m) === 1) { + $code = strtolower($m[1]); + $supplier = Supplier::where('code', $code)->first(); + + return $supplier?->id; + } + + return null; + } +} diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 137ba6b5..97e0ba83 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -1043,3 +1043,27 @@ parameters: identifier: property.notFound count: 9 path: tests/Unit/Billing/PricingTierResolverTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Billing/LedgerServiceTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$ledger\.$#' + identifier: property.notFound + count: 8 + path: tests/Feature/Billing/LedgerServiceTest.php + + - + message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$delivered_in_month\.$#' + identifier: property.notFound + count: 3 + path: tests/Feature/Billing/LedgerServiceTest.php + + - + message: '#^Access to an undefined property App\\Models\\LeadCharge\:\:\$charge_source\.$#' + identifier: property.notFound + count: 2 + path: tests/Feature/Billing/LedgerServiceTest.php diff --git a/app/tests/Feature/Billing/LedgerServiceTest.php b/app/tests/Feature/Billing/LedgerServiceTest.php new file mode 100644 index 00000000..40799885 --- /dev/null +++ b/app/tests/Feature/Billing/LedgerServiceTest.php @@ -0,0 +1,175 @@ +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 prepaid when balance_leads >= 1 (price snapshot = 0, tier_no snapshot from delivered_in_month + 1)', function () { + $tenant = makeTenantWith(balanceLeads: 5, balanceRub: '0.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->source)->toBe('prepaid'); + expect($result->priceKopecks)->toBe(0); + expect($result->tier->tier_no)->toBe(1); + + $tenant->refresh(); + expect((int) $tenant->balance_leads)->toBe(4); + expect((string) $tenant->balance_rub)->toBe('0.00'); + expect($tenant->delivered_in_month)->toBe(1); + + $charge = LeadCharge::first(); + expect($charge->charge_source)->toBe('prepaid'); + expect($charge->price_per_lead_kopecks)->toBe(0); + expect($charge->tier_no)->toBe(1); +}); + +it('charges rub when balance_leads = 0 and 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->source)->toBe('rub'); + 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 both sources empty', 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); + }); + + expect($result->source)->toBe('rub'); + + $tenant->refresh(); + expect((string) $tenant->balance_rub)->toBe('0.00'); +}); + +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: 5, balanceRub: '0.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); +});