116b0aaa42
Косяк 01: расчёт capacity садился на устаревшую цену через PricingTier::where is_active true при двух активных версиях тарифа. Переведено на PricingTierRepository activeAt now во всех путях расчёта: release-сервис, контроллер runPreflight, bulk-лимит, sweep-джоб, reminder-джоб. Реальное списание было корректным — деньги клиентов не затронуты. TDD: PreflightUsesCurrentTariffVersionTest 5 кейсов, GREEN. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
127 lines
6.1 KiB
PHP
127 lines
6.1 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Jobs\Billing\BalanceFrozenReminderJob;
|
||
use App\Jobs\Billing\BalancePreflightSweepJob;
|
||
use App\Jobs\SyncSupplierProjectJob;
|
||
use App\Mail\BalanceFrozenReminderMail;
|
||
use App\Models\PricingTier;
|
||
use App\Models\Project;
|
||
use App\Models\Tenant;
|
||
use App\Models\User;
|
||
use App\Services\Billing\BillingTopupService;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Mail;
|
||
use Illuminate\Support\Facades\Queue;
|
||
use Tests\Concerns\SharesSupplierPdo;
|
||
|
||
uses(DatabaseTransactions::class);
|
||
uses(SharesSupplierPdo::class);
|
||
|
||
// Косяк 01 (24.06.2026): при нескольких версиях тарифа (старая дорогая + новая дешёвая,
|
||
// обе is_active — это нормальное версионирование цены по effective_from) расчёт баланса
|
||
// и снятия блока ДОЛЖЕН брать ДЕЙСТВУЮЩУЮ версию по дате — как списание и витрина через
|
||
// PricingTierRepository::activeAt — а не «по-простому» PricingTier::where(is_active)->get(),
|
||
// которое садится на старую дорогую версию (садится на запись с меньшим id).
|
||
|
||
beforeEach(function () {
|
||
Queue::fake();
|
||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||
|
||
// СТАРАЯ дорогая версия — создаём ПЕРВОЙ (ниже id → наивный sortBy('tier_no') садится на неё).
|
||
PricingTier::query()->create([
|
||
'tier_no' => 1,
|
||
'leads_in_tier' => 100,
|
||
'price_per_lead_kopecks' => 50000, // 500₽/лид — устаревшая версия
|
||
'is_active' => true,
|
||
'effective_from' => '2020-01-01',
|
||
]);
|
||
// НОВАЯ действующая версия — свежий effective_from.
|
||
PricingTier::query()->create([
|
||
'tier_no' => 1,
|
||
'leads_in_tier' => 100,
|
||
'price_per_lead_kopecks' => 5000, // 50₽/лид — действующая версия
|
||
'is_active' => true,
|
||
'effective_from' => now(),
|
||
]);
|
||
});
|
||
|
||
it('topup unblocks project using the CURRENT cheap tariff, not the stale expensive one', function () {
|
||
$tenant = Tenant::factory()->withRequisites()->create(['balance_rub' => '0.00']);
|
||
$project = Project::factory()->for($tenant)->create([
|
||
'is_active' => true,
|
||
'daily_limit_target' => 30,
|
||
'preflight_blocked_at' => now(),
|
||
]);
|
||
|
||
// 2000₽: по действующей 50₽/лид = 40 лидов >= 30 → блок снять.
|
||
// (по устаревшей 500₽/лид = 4 лида < 30 → остался бы заблокирован — это и есть баг.)
|
||
app(BillingTopupService::class)->topup($tenant->id, '2000.00', null);
|
||
|
||
expect($project->fresh()->preflight_blocked_at)->toBeNull();
|
||
Queue::assertPushed(SyncSupplierProjectJob::class);
|
||
});
|
||
|
||
it('creates project (no 409) when limit is affordable at the CURRENT tariff', function () {
|
||
// 2000₽: действующая 50₽ = 40 лидов >= 30 → создать (по устаревшей 500₽ = 4 < 30 → 409 = БАГ).
|
||
$tenant = Tenant::factory()->withRequisites()->create(['balance_rub' => '2000.00']);
|
||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||
|
||
$this->actingAs($user)->postJson('/api/projects', [
|
||
'name' => 'По действующей цене',
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => 'current-tariff.ru',
|
||
'daily_limit_target' => 30,
|
||
'regions' => [],
|
||
'delivery_days_mask' => 127,
|
||
])->assertStatus(201);
|
||
|
||
expect(Project::where('signal_identifier', 'current-tariff.ru')->exists())->toBeTrue();
|
||
});
|
||
|
||
it('applies bulk limit increase affordable at the CURRENT tariff', function () {
|
||
// 2000₽: действующая 50₽ = 40 лидов. Два проекта по 15 → сумма 30 ≤ 40 → применить.
|
||
// (по устаревшей 500₽ = 4 < 30 → оба снялись бы = БАГ.)
|
||
$tenant = Tenant::factory()->withRequisites()->create(['balance_rub' => '2000.00']);
|
||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||
$p1 = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 5, 'delivered_today' => 0]);
|
||
$p2 = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 5, 'delivered_today' => 0]);
|
||
|
||
$this->actingAs($user)->postJson('/api/projects/bulk', [
|
||
'action' => 'update_limit',
|
||
'ids' => [$p1->id, $p2->id],
|
||
'replace' => 15,
|
||
])->assertOk()->assertJsonPath('updated', 2);
|
||
|
||
expect($p1->fresh()->daily_limit_target)->toBe(15);
|
||
expect($p2->fresh()->daily_limit_target)->toBe(15);
|
||
});
|
||
|
||
it('does NOT freeze tenant affordable at the CURRENT tariff during the sweep', function () {
|
||
// 2000₽: действующая 50₽ = 40 лидов >= 30 → НЕ замораживать.
|
||
// (по устаревшей 500₽ = 4 < 30 → заморозил бы = БАГ.)
|
||
$tenant = Tenant::factory()->create(['balance_rub' => '2000.00', 'frozen_by_balance_at' => null]);
|
||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 30]);
|
||
|
||
(new BalancePreflightSweepJob)->handle();
|
||
|
||
expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
|
||
});
|
||
|
||
it('reminder email shows capacity at the CURRENT tariff, not the stale one', function () {
|
||
Mail::fake();
|
||
// Замороженный клиент в окне reminder (25ч). 2000₽, лимит 100 → нужно 100.
|
||
// действующая 50₽ = 40 лидов вместимость; устаревшая 500₽ = 4 (это попало бы в письмо = БАГ).
|
||
$tenant = Tenant::factory()->create([
|
||
'balance_rub' => '2000.00',
|
||
'frozen_by_balance_at' => now()->subHours(25),
|
||
]);
|
||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 100]);
|
||
|
||
(new BalanceFrozenReminderJob)->handle();
|
||
|
||
Mail::assertQueued(BalanceFrozenReminderMail::class, fn ($mail) => $mail->result->capacityLeads === 40);
|
||
});
|