36d7fd1923
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.
197 lines
8.2 KiB
PHP
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);
|
|
});
|