feat(billing): Plan 4 Task 3 — LedgerService::chargeForDelivery (dual-balance + lead_charges/supplier_lead_costs INSERT)

This commit is contained in:
Дмитрий
2026-05-11 09:42:29 +03:00
parent 1e0c0ab90a
commit d2030f9121
5 changed files with 409 additions and 0 deletions
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Billing;
use RuntimeException;
/**
* Выбрасывается LedgerService::chargeForDelivery, когда tenant не имеет
* ни prepaid-лидов (balance_leads >= 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,
);
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing;
use App\Models\PricingTier;
/**
* Read-only DTO с результатом charge'а: source (prepaid/rub), снимок ступени, цена в копейках.
*/
final readonly class ChargeResult
{
public function __construct(
public string $source,
public PricingTier $tier,
public int $priceKopecks,
) {}
}
+158
View File
@@ -0,0 +1,158 @@
<?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).
* Применяет dual-balance flow:
* 1. tier-lookup по tenants.delivered_in_month + 1
* 2. prepaid: balance_leads--, lead_charges (price=0)
* 3. rub: balance_rub -= price/100 (bcmath), lead_charges (price=tier)
* 4. INSERT supplier_lead_costs (gap-fix sharing-flow)
* 5. INSERT balance_transactions (universal ledger движения баланса)
*
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §3
*/
final class LedgerService
{
public function __construct(
private readonly PricingTierResolver $resolver,
private readonly PricingTierRepository $tiers,
) {}
public function chargeForDelivery(
Tenant $lockedTenant,
Deal $deal,
?SupplierLead $lead = null,
): ChargeResult {
// 1. tier-resolution для (delivered_in_month + 1)-го лида
$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;
// 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;
}
}
+24
View File
@@ -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
@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
use App\Exceptions\Billing\InsufficientBalanceException;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Supplier;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function () {
// Под `pest --parallel` Laravel создаёт per-worker testing DBs (liderra_testing_test_1..N)
// и migrate'ит их без seed'ов. Поэтому seed'им ступени в beforeEach (DatabaseTransactions
// rollback'ает их в end-of-test, поэтому seed безопасен и не накапливается).
// PricingTierSeeder использует updateOrCreate — idempotent при повторном вызове.
$this->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);
});