Files
portal/app/tests/Feature/Billing/LedgerServiceTest.php
T
Дмитрий 36d7fd1923 feat(billing): R-03 — LedgerService rejects frozen tenants
Stage 3 Task 3.1. Add frozen_by_balance_at guard in chargeForDelivery() before
bcmath arithmetic. Even if balance_rub > 0, a tenant flagged by
BalancePreflightSweepJob must not be charged for new lead deliveries. The
InsufficientBalanceException throw triggers the existing auto-pause flow
(RouteSupplierLeadJob::handleInsufficientBalance → projects.is_active=false +
ZeroBalancePausedMail rate-limited). Spec §4.3.1.
2026-05-28 15:33:36 +03:00

197 lines
8.2 KiB
PHP

<?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 rub when 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->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 balance_rub * 100 < priceKopecks', 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);
});
$tenant->refresh();
expect((string) $tenant->balance_rub)->toBe('0.00');
});
it('always uses charge_source=rub regardless of historic balance_leads value', function () {
// Historic prepaid leftover — must be ignored by new always-rub flow.
$tenant = makeTenantWith(balanceLeads: 5, balanceRub: '1000.00', deliveredInMonth: 0);
$deal = makeDealForTenant($tenant);
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);
});
$tenant->refresh();
// balance_leads must remain UNCHANGED (we never touch this column anymore).
expect((int) $tenant->balance_leads)->toBe(5);
// balance_rub must be debited by tier 1 price (500₽ for delivered_in_month=0 → tier 1).
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);
});
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: 0, balanceRub: '10000.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);
});
// Stage 3 / Task 3.1 — R-03 (spec §4.3.1): a frozen tenant must be rejected at
// charge time even when balance_rub > 0. Guard is BEFORE bcmath arithmetic so
// no balance / charges state is touched on rejection. The same auto-pause flow
// kicks in (InsufficientBalanceException → RouteSupplierLeadJob handler flips
// projects.is_active=false and queues ZeroBalancePausedMail rate-limited).
it('throws InsufficientBalanceException when tenant frozen_by_balance_at is set', function () {
$tenant = Tenant::factory()->create([
'balance_rub' => '500.00',
'frozen_by_balance_at' => now(),
]);
$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);
// No side effects on frozen reject — balance and charges untouched.
$tenant->refresh();
expect((string) $tenant->balance_rub)->toBe('500.00');
expect(LeadCharge::where('tenant_id', $tenant->id)->count())->toBe(0);
});