f78ab4af3d
Создавать проекты можно всегда; баланс проверяется в момент ЗАПУСКА (создать-и-запустить / запустить / возобновить одиночно и пачкой / автоподбор) под замком на клиента (без гонок). Не хватает — проект остаётся на паузе с меткой preflight_blocked_at, клиенту сообщение в рублях (сколько пополнить). Групповой запуск «сколько влезло». Нет активного тарифа на дату → запуск запрещён (fail-closed). Гейт реквизитов первого проекта добавлен и в автоподбор. - LaunchBalanceGate — единый гейт вместо 3 копий preflight (ProjectController store/update, AutopodborController), под DB::transaction + lockForUpdate(Tenant). - ProjectService::create($launch) + новый setActive(); bulk resume «сколько влезло». - AutopodborProjectCreator: пачка в транзакции через общий ProjectService::create. - Идемпотентность box/phone_type миграций автоподбора (Schema::hasColumn guard). - Тест-инфра: afterRefreshingDatabase восстанавливает месячные партиции. Тесты фичи 40/40 зелёные. Спека и план — docs/superpowers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
62 lines
2.6 KiB
PHP
62 lines
2.6 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\PricingTier;
|
||
use App\Models\Project;
|
||
use App\Models\Tenant;
|
||
use App\Services\Billing\LaunchBalanceGate;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Tests\Concerns\SharesSupplierPdo;
|
||
|
||
uses(DatabaseTransactions::class);
|
||
uses(SharesSupplierPdo::class);
|
||
|
||
function seedTier(int $priceKopecks = 10000): void // 100 ₽/лид
|
||
{
|
||
PricingTier::create([
|
||
'tier_no' => 1,
|
||
'leads_in_tier' => null,
|
||
'price_per_lead_kopecks' => $priceKopecks,
|
||
'is_active' => true,
|
||
'effective_from' => now()->toDateString(),
|
||
]);
|
||
}
|
||
|
||
it('passes when balance covers committed + new', function () {
|
||
seedTier(); // 100 ₽/лид
|
||
$t = Tenant::factory()->create(['balance_rub' => '1000.00', 'delivered_in_month' => 0]); // ёмкость 10 лидов
|
||
$r = app(LaunchBalanceGate::class)->evaluate($t, additionalLeads: 5);
|
||
expect($r->passes)->toBeTrue();
|
||
});
|
||
|
||
it('fails and computes topupRub in deficit', function () {
|
||
seedTier(); // 100 ₽/лид
|
||
$t = Tenant::factory()->create(['balance_rub' => '300.00', 'delivered_in_month' => 0]); // ёмкость 3 лида
|
||
$r = app(LaunchBalanceGate::class)->evaluate($t, additionalLeads: 5); // нужно 5, не хватает 2
|
||
expect($r->passes)->toBeFalse();
|
||
expect($r->deficitLeads)->toBe(2);
|
||
expect($r->topupRub)->toBe('200.00'); // 2 лида × 100 ₽
|
||
});
|
||
|
||
it('excludes given project from committed', function () {
|
||
seedTier();
|
||
$t = Tenant::factory()->create(['balance_rub' => '1000.00', 'delivered_in_month' => 0]); // 10 лидов
|
||
$p = Project::factory()->for($t)->create(['is_active' => true, 'daily_limit_target' => 8, 'preflight_blocked_at' => null]);
|
||
// без исключения: committed=8, +5 = 13 > 10 → fail; с исключением self: committed=0, +5=5 → pass
|
||
expect(app(LaunchBalanceGate::class)->evaluate($t, 5)->passes)->toBeFalse();
|
||
expect(app(LaunchBalanceGate::class)->evaluate($t, 5, [$p->id])->passes)->toBeTrue();
|
||
});
|
||
|
||
it('fail-closed when no active tiers and flag on', function () {
|
||
config()->set('billing.launch_requires_active_tiers', true);
|
||
$t = Tenant::factory()->create(['balance_rub' => '1000000.00', 'delivered_in_month' => 0]);
|
||
expect(app(LaunchBalanceGate::class)->evaluate($t, 1)->passes)->toBeFalse();
|
||
});
|
||
|
||
it('open when no active tiers and flag off (legacy/tests)', function () {
|
||
config()->set('billing.launch_requires_active_tiers', false);
|
||
$t = Tenant::factory()->create(['balance_rub' => '0.00', 'delivered_in_month' => 0]);
|
||
expect(app(LaunchBalanceGate::class)->evaluate($t, 1)->passes)->toBeTrue();
|
||
});
|