feat(billing): Plan 4 Task 3 — LedgerService::chargeForDelivery (dual-balance + lead_charges/supplier_lead_costs INSERT)
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user