Files
portal/app/tests/Feature/Billing/PreflightUsesCurrentTariffVersionTest.php
T
Дмитрий 116b0aaa42 fix/billing: префлайт и блокировка баланса берут действующую версию тарифа по дате
Косяк 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>
2026-06-24 13:03:55 +03:00

127 lines
6.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
});