150 lines
6.1 KiB
PHP
150 lines
6.1 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Services\Billing;
|
||
|
||
use App\Exceptions\Billing\InsufficientBalanceException;
|
||
use App\Models\BalanceTransaction;
|
||
use App\Models\Deal;
|
||
use App\Models\LeadCharge;
|
||
use App\Models\Supplier;
|
||
use App\Models\SupplierLead;
|
||
use App\Models\Tenant;
|
||
use App\Repositories\PricingTierRepository;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* Командный сервис биллинга на горячем пути доставки лида.
|
||
*
|
||
* Контракт: вызывается ВНУТРИ открытой DB-транзакции под lockForUpdate(Tenant).
|
||
* Применяет always-rub flow (Billing v2 Spec A — prepaid-лиды ликвидированы):
|
||
* 1. tier-lookup по tenants.delivered_in_month + 1
|
||
* 2. bcmath проверка balance_rub × 100 ≥ priceKopecks; иначе throw
|
||
* 3. balance_rub -= price/100 (bcmath)
|
||
* 4. INSERT lead_charges (charge_source='rub')
|
||
* 5. INSERT balance_transactions (amount_leads=null, amount_rub отрицательное)
|
||
* 6. INSERT supplier_lead_costs (gap-fix sharing-flow)
|
||
*
|
||
* Spec: docs/superpowers/specs/2026-05-23-billing-v2-spec-a-balance-rub-design.md §4.2
|
||
*/
|
||
final class LedgerService
|
||
{
|
||
public function __construct(
|
||
private readonly PricingTierResolver $resolver,
|
||
private readonly PricingTierRepository $tiers,
|
||
) {}
|
||
|
||
/**
|
||
* @throws InsufficientBalanceException когда balance_rub * 100 < priceKopecks.
|
||
* До throw НЕ модифицирует tenant/charges/transactions/costs.
|
||
*
|
||
* @precondition caller wraps in DB::transaction with lockForUpdate($lockedTenant).
|
||
* Атомарность всех INSERT'ов (lead_charges + balance_transactions + supplier_lead_costs)
|
||
* обеспечивается этой внешней транзакцией.
|
||
*/
|
||
public function chargeForDelivery(
|
||
Tenant $lockedTenant,
|
||
Deal $deal,
|
||
?SupplierLead $lead = null,
|
||
): ChargeResult {
|
||
$activeTiers = $this->tiers->activeAt(Carbon::now('Europe/Moscow'));
|
||
$tier = $this->resolver->resolveForCount(
|
||
$activeTiers,
|
||
($lockedTenant->delivered_in_month ?? 0) + 1
|
||
);
|
||
$priceKopecks = (int) $tier->price_per_lead_kopecks;
|
||
|
||
// bcmath: balance_rub × 100 ≥ priceKopecks — единственный путь списания.
|
||
// Billing v2 Spec A: prepaid-лиды убраны, balance_leads НЕ читается и НЕ изменяется.
|
||
$balanceKopecks = bcmul((string) $lockedTenant->balance_rub, '100', 0);
|
||
if (bccomp($balanceKopecks, (string) $priceKopecks, 0) < 0) {
|
||
throw new InsufficientBalanceException(
|
||
priceKopecks: $priceKopecks,
|
||
balanceRub: (string) $lockedTenant->balance_rub,
|
||
);
|
||
}
|
||
|
||
$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();
|
||
|
||
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' => $priceKopecks,
|
||
'charge_source' => 'rub',
|
||
'charged_at' => now(),
|
||
'created_at' => now(),
|
||
]);
|
||
|
||
BalanceTransaction::create([
|
||
'tenant_id' => $lockedTenant->id,
|
||
'type' => BalanceTransaction::TYPE_LEAD_CHARGE,
|
||
'amount_leads' => null,
|
||
'amount_rub' => '-'.$amountRub,
|
||
'balance_leads_after' => null,
|
||
'balance_rub_after' => (string) $lockedTenant->balance_rub,
|
||
'related_type' => Deal::class,
|
||
'related_id' => $deal->id,
|
||
'created_at' => now(),
|
||
]);
|
||
|
||
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($tier, $priceKopecks);
|
||
}
|
||
|
||
/**
|
||
* 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 = trim((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;
|
||
}
|
||
}
|