Merge pull request #24 from CoralMinister/feat/billing-v2-spec-c
Feat/billing v2 spec c
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\Billing\BalanceFrozenReminderJob;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Daily: повторные письма заморозки баланса.
|
||||
* • reminder в окне 24-48ч после freeze;
|
||||
* • final в окне 72-96ч после freeze.
|
||||
*
|
||||
* Запускается @18:30 MSK (routes/console.php), после основного preflight-sweep @18:00.
|
||||
* Throttle живёт в BalanceFrozenReminderJob через balance_freeze_log markers.
|
||||
*/
|
||||
final class BillingFrozenReminderCommand extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'billing:frozen-reminder';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Повторные письма заморозки баланса (reminder +1д, final +3д)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
(new BalanceFrozenReminderJob)->handle();
|
||||
$this->info('Повторные письма заморозки разосланы (если есть кандидаты в окнах).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\Billing\BalancePreflightSweepJob;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* One-time: при выкатке преfflight прогнать всех тенантов и заморозить
|
||||
* недофинансированных. Запускается ОДИН раз вручную после миграции.
|
||||
*
|
||||
* См. спек §3.9: «Клиент уже в минусовом балансе на момент запуска
|
||||
* преfflight (legacy состояние) — одноразовая artisan-команда».
|
||||
*
|
||||
* Идемпотентна: повторный запуск не пере-замораживает уже замороженных
|
||||
* (логика sweep-джоба — переход active→frozen / frozen→active, стабильное
|
||||
* состояние не трогается).
|
||||
*/
|
||||
final class BillingPreflightInitialSweepCommand extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'billing:preflight-initial-sweep';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Разовый преfflight при внедрении — заморозить недофинансированных тенантов';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->warn('Разовый преfflight всех тенантов. Запускать ОДИН раз после выкатки Spec C.');
|
||||
(new BalancePreflightSweepJob)->handle();
|
||||
$this->info('Initial sweep завершён.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\Billing\BalancePreflightSweepJob;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class BillingPreflightSweepCommand extends Command
|
||||
{
|
||||
protected $signature = 'billing:preflight-sweep';
|
||||
|
||||
protected $description = 'Ежедневный преfflight баланса — заморозка/разморозка тенантов (cut-off 18:00 MSK)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
(new BalancePreflightSweepJob)->handle();
|
||||
$this->info('Преfflight sweep завершён.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,14 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use App\Services\Billing\BalanceToLeadsConverter;
|
||||
use App\Services\Billing\BillingTopupService;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -111,6 +114,101 @@ class BillingController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/billing/balance-status — лёгкий статус баланса для UI префлайта
|
||||
* (Billing v2 Spec C §3.6). Питает глобальный баннер заморозки
|
||||
* (BalanceFrozenBanner: frozen_by_balance_at + дефицит) и индикатор ёмкости
|
||||
* (BalanceCapacityIndicator: balance / capacity / required). Грузится в
|
||||
* AppLayout на всех страницах, поэтому без tiers_preview и истории.
|
||||
*/
|
||||
public function balanceStatus(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
/** @var Tenant $tenant */
|
||||
$tenant = Tenant::query()->findOrFail((int) $user->tenant_id);
|
||||
|
||||
$activeTiers = app(PricingTierRepository::class)->activeAt(Carbon::now('Europe/Moscow'));
|
||||
$deliveredInMonth = (int) ($tenant->delivered_in_month ?? 0);
|
||||
|
||||
$capacityLeads = (int) app(BalanceToLeadsConverter::class)->convert(
|
||||
(string) $tenant->balance_rub,
|
||||
$deliveredInMonth,
|
||||
$activeTiers,
|
||||
)['leads'];
|
||||
|
||||
// Требуемые лиды/день — сумма лимитов активных не-заблокированных проектов
|
||||
// (та же выборка, что в ProjectController preflight).
|
||||
$requiredLeads = (int) Project::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->sum('daily_limit_target');
|
||||
|
||||
$deficitLeads = max(0, $requiredLeads - $capacityLeads);
|
||||
$deficitRub = '0.00';
|
||||
if ($deficitLeads > 0) {
|
||||
$needed = $this->minBalanceForLeads($requiredLeads, $deliveredInMonth, $activeTiers);
|
||||
$deficitRub = bcsub($needed, (string) $tenant->balance_rub, 2);
|
||||
if (bccomp($deficitRub, '0', 2) < 0) {
|
||||
$deficitRub = '0.00';
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'frozen_by_balance_at' => $tenant->frozen_by_balance_at?->toISOString(),
|
||||
'balance_rub' => (string) $tenant->balance_rub,
|
||||
'capacity_leads' => $capacityLeads,
|
||||
'required_leads_per_day' => $requiredLeads,
|
||||
'deficit_leads' => $deficitLeads,
|
||||
'deficit_rub' => $deficitRub,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Минимальный баланс (₽, scale 2), чтобы позволить себе $leads лидов при уже
|
||||
* доставленных $deliveredInMonth в этом месяце — сумма цен ступеней по позициям
|
||||
* [delivered .. delivered+leads-1]. Зеркалит логику BalanceToLeadsConverter.
|
||||
*
|
||||
* @param Collection<int, PricingTier> $tiers
|
||||
*/
|
||||
private function minBalanceForLeads(int $leads, int $deliveredInMonth, $tiers): string
|
||||
{
|
||||
if ($leads <= 0) {
|
||||
return '0.00';
|
||||
}
|
||||
|
||||
$sorted = $tiers
|
||||
->filter(fn ($t) => (bool) $t->is_active)
|
||||
->sortBy('tier_no')
|
||||
->values();
|
||||
|
||||
$kopecks = '0';
|
||||
$remaining = $leads;
|
||||
$cumulative = 0; // позиции [0..cumulative) пройдены предыдущими ступенями
|
||||
$position = $deliveredInMonth;
|
||||
|
||||
foreach ($sorted as $tier) {
|
||||
$unlimited = $tier->leads_in_tier === null;
|
||||
$tierEnd = $unlimited ? PHP_INT_MAX : $cumulative + (int) $tier->leads_in_tier;
|
||||
|
||||
$slotsInTier = max(0, $tierEnd - max($cumulative, $position));
|
||||
if ($slotsInTier > 0) {
|
||||
$take = min($remaining, $slotsInTier);
|
||||
$kopecks = bcadd($kopecks, bcmul((string) (int) $tier->price_per_lead_kopecks, (string) $take, 0), 0);
|
||||
$remaining -= $take;
|
||||
$position += $take;
|
||||
}
|
||||
|
||||
if ($remaining <= 0 || $unlimited) {
|
||||
break;
|
||||
}
|
||||
$cumulative = $tierEnd;
|
||||
}
|
||||
|
||||
return bcdiv($kopecks, '100', 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/billing/transactions?type=topup|lead_charge|migration&page=N
|
||||
* — пагинированная история balance_transactions тенанта (20/страница).
|
||||
|
||||
@@ -10,7 +10,10 @@ use App\Http\Requests\StoreProjectRequest;
|
||||
use App\Http\Requests\UpdateProjectRequest;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Jobs\SyncSupplierProjectJob;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\BalancePreflightService;
|
||||
use App\Services\Project\ProjectService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -117,7 +120,35 @@ class ProjectController extends Controller
|
||||
/** POST /api/projects */
|
||||
public function store(StoreProjectRequest $request): JsonResponse
|
||||
{
|
||||
$project = $this->projects->create($request->user()->tenant, $request->validated());
|
||||
$validated = $request->validated();
|
||||
$tenant = $request->user()->tenant;
|
||||
$forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false);
|
||||
unset($validated['force_save_blocked']);
|
||||
|
||||
// Spec C §3.4: преfflight баланса при создании. existingLimit учитывает только активные.
|
||||
$existingLimit = (int) Project::where('tenant_id', $tenant->id)
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->sum('daily_limit_target');
|
||||
$wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target'];
|
||||
|
||||
$preflight = $this->runPreflight($tenant, $wouldBeRequired);
|
||||
|
||||
if (! $preflight['passes'] && ! $forceSaveBlocked) {
|
||||
return response()->json([
|
||||
'error' => 'balance_insufficient',
|
||||
'current_balance_rub' => (string) $tenant->balance_rub,
|
||||
'current_capacity_leads' => $preflight['capacity_leads'],
|
||||
'would_be_required_leads' => $wouldBeRequired,
|
||||
'deficit_leads' => $preflight['deficit_leads'],
|
||||
], 409);
|
||||
}
|
||||
|
||||
if (! $preflight['passes'] && $forceSaveBlocked) {
|
||||
$validated['preflight_blocked_at'] = now();
|
||||
}
|
||||
|
||||
$project = $this->projects->create($tenant, $validated);
|
||||
|
||||
return response()->json(['data' => new ProjectResource($project)], 201);
|
||||
}
|
||||
@@ -126,11 +157,70 @@ class ProjectController extends Controller
|
||||
public function update(UpdateProjectRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
|
||||
$updated = $this->projects->update($project, $request->validated());
|
||||
$validated = $request->validated();
|
||||
$tenant = $request->user()->tenant;
|
||||
$forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false);
|
||||
unset($validated['force_save_blocked']);
|
||||
|
||||
// Spec C §3.4: преfflight при изменении лимита — учитываем новое значение для ЭТОГО
|
||||
// проекта + лимиты остальных активных не-blocked.
|
||||
if (array_key_exists('daily_limit_target', $validated)) {
|
||||
$existingLimit = (int) Project::where('tenant_id', $tenant->id)
|
||||
->where('id', '!=', $project->id)
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->sum('daily_limit_target');
|
||||
$wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target'];
|
||||
|
||||
$preflight = $this->runPreflight($tenant, $wouldBeRequired);
|
||||
|
||||
if (! $preflight['passes'] && ! $forceSaveBlocked) {
|
||||
return response()->json([
|
||||
'error' => 'balance_insufficient',
|
||||
'current_balance_rub' => (string) $tenant->balance_rub,
|
||||
'current_capacity_leads' => $preflight['capacity_leads'],
|
||||
'would_be_required_leads' => $wouldBeRequired,
|
||||
'deficit_leads' => $preflight['deficit_leads'],
|
||||
], 409);
|
||||
}
|
||||
|
||||
if (! $preflight['passes'] && $forceSaveBlocked) {
|
||||
$validated['preflight_blocked_at'] = now();
|
||||
}
|
||||
}
|
||||
|
||||
$updated = $this->projects->update($project, $validated);
|
||||
|
||||
return response()->json(['data' => new ProjectResource($updated)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{passes: bool, capacity_leads: int, deficit_leads: int}
|
||||
*/
|
||||
private function runPreflight(Tenant $tenant, int $requiredLeads): array
|
||||
{
|
||||
$tiers = PricingTier::query()->where('is_active', true)->get();
|
||||
|
||||
// Safe fallback: без активных pricing_tiers биллинг не настроен —
|
||||
// преfflight не имеет смысла, пропускаем (legacy-окружения / тесты).
|
||||
if ($tiers->isEmpty()) {
|
||||
return ['passes' => true, 'capacity_leads' => PHP_INT_MAX, 'deficit_leads' => 0];
|
||||
}
|
||||
|
||||
$result = (new BalancePreflightService)->evaluate(
|
||||
balanceRub: (string) $tenant->balance_rub,
|
||||
deliveredInMonth: (int) $tenant->delivered_in_month,
|
||||
requiredLeads: $requiredLeads,
|
||||
tiers: $tiers,
|
||||
);
|
||||
|
||||
return [
|
||||
'passes' => $result->passes,
|
||||
'capacity_leads' => $result->capacityLeads,
|
||||
'deficit_leads' => $result->deficitLeads,
|
||||
];
|
||||
}
|
||||
|
||||
/** GET /api/projects/{id} */
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
|
||||
@@ -28,6 +28,9 @@ class StoreProjectRequest extends FormRequest
|
||||
'regions' => ['present', 'array'],
|
||||
'regions.*' => ['integer', 'between:1,89'],
|
||||
'delivery_days_mask' => ['required', 'integer', 'min:1', 'max:127'],
|
||||
// Spec C §3.4: при перегрузке преfflight UI шлёт force_save_blocked=true →
|
||||
// проект создаётся с preflight_blocked_at=now() вместо ответа 409.
|
||||
'force_save_blocked' => ['sometimes', 'boolean'],
|
||||
];
|
||||
|
||||
if ($signalType === 'site') {
|
||||
|
||||
@@ -28,6 +28,9 @@ class UpdateProjectRequest extends FormRequest
|
||||
'sms_senders' => ['sometimes', 'array', 'min:1'],
|
||||
'sms_senders.*' => ['string', 'max:11'],
|
||||
'sms_keyword' => ['sometimes', 'nullable', 'string', 'min:1', 'max:50'],
|
||||
// Spec C §3.4: при перегрузке преfflight UI шлёт force_save_blocked=true →
|
||||
// проект помечается preflight_blocked_at=now() вместо ответа 409.
|
||||
'force_save_blocked' => ['sometimes', 'boolean'],
|
||||
];
|
||||
|
||||
// 18.05.2026 UX: редактирование источника (signal_identifier) для site/call.
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Billing;
|
||||
|
||||
use App\Mail\BalanceFrozenFinalMail;
|
||||
use App\Mail\BalanceFrozenReminderMail;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\BalancePreflightService;
|
||||
use App\Services\Billing\PreflightResult;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Повторные письма заморозки баланса:
|
||||
* • reminder в окне 24-48ч после freeze;
|
||||
* • final в окне 72-96ч после freeze.
|
||||
*
|
||||
* Throttle через balance_freeze_log markers (event_type 'reminder_sent' / 'final_sent') —
|
||||
* один marker-row на (tenant, тип) в течение окна 5 дней. Запускается daily @ 18:30 MSK
|
||||
* (routes/console.php). См. спек §3.7.
|
||||
*
|
||||
* Re-evaluate PreflightResult: показываем клиенту АКТУАЛЬНЫЙ дефицит (он мог частично
|
||||
* пополниться — reminder отразит обновлённую цифру).
|
||||
*/
|
||||
final class BalanceFrozenReminderJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
|
||||
private const REMINDER_MIN_HOURS = 24;
|
||||
|
||||
private const REMINDER_MAX_HOURS = 48;
|
||||
|
||||
private const FINAL_MIN_HOURS = 72;
|
||||
|
||||
private const FINAL_MAX_HOURS = 96;
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$service = new BalancePreflightService;
|
||||
$tiers = PricingTier::query()->where('is_active', true)->get();
|
||||
|
||||
Tenant::query()
|
||||
->whereNotNull('frozen_by_balance_at')
|
||||
->whereNull('deleted_at')
|
||||
->chunkById(200, function (Collection $tenants) use ($service, $tiers): void {
|
||||
foreach ($tenants as $tenant) {
|
||||
/** @var Tenant $tenant */
|
||||
$this->processTenant($tenant, $service, $tiers);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, PricingTier> $tiers
|
||||
*/
|
||||
private function processTenant(Tenant $tenant, BalancePreflightService $service, Collection $tiers): void
|
||||
{
|
||||
// diffInHours округляет — у заморозки на 25h это 25, на 73h это 73 (OK).
|
||||
$hours = (int) abs(now()->diffInHours($tenant->frozen_by_balance_at, false));
|
||||
|
||||
$window = $this->matchWindow($hours);
|
||||
if ($window === null) {
|
||||
return; // вне окон reminder/final
|
||||
}
|
||||
|
||||
$marker = $window === 'reminder' ? 'reminder_sent' : 'final_sent';
|
||||
if ($this->alreadySent($tenant->id, $marker)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-evaluate для актуального дефицита в тексте письма.
|
||||
$result = $service->evaluate(
|
||||
balanceRub: (string) $tenant->balance_rub,
|
||||
deliveredInMonth: (int) $tenant->delivered_in_month,
|
||||
requiredLeads: $tenant->requiredLeadsForTomorrow(),
|
||||
tiers: $tiers,
|
||||
);
|
||||
|
||||
$mail = $window === 'reminder'
|
||||
? new BalanceFrozenReminderMail($tenant, $result)
|
||||
: new BalanceFrozenFinalMail($tenant, $result);
|
||||
|
||||
Mail::queue($mail);
|
||||
$this->mark($tenant, $marker, $result);
|
||||
}
|
||||
|
||||
private function matchWindow(int $hours): ?string
|
||||
{
|
||||
if ($hours >= self::REMINDER_MIN_HOURS && $hours < self::REMINDER_MAX_HOURS) {
|
||||
return 'reminder';
|
||||
}
|
||||
if ($hours >= self::FINAL_MIN_HOURS && $hours < self::FINAL_MAX_HOURS) {
|
||||
return 'final';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function alreadySent(int $tenantId, string $marker): bool
|
||||
{
|
||||
return DB::connection('pgsql_supplier')->table('balance_freeze_log')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('event_type', $marker)
|
||||
->where('created_at', '>=', now()->subDays(5))
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function mark(Tenant $tenant, string $marker, PreflightResult $result): void
|
||||
{
|
||||
DB::connection('pgsql_supplier')->table('balance_freeze_log')->insert([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_type' => $marker,
|
||||
'triggered_by' => 'reminder_cron',
|
||||
'balance_rub_at_event' => $tenant->balance_rub,
|
||||
'required_leads' => $result->requiredLeads,
|
||||
'capacity_leads' => $result->capacityLeads,
|
||||
'total_daily_limit' => $result->requiredLeads,
|
||||
'details' => json_encode(['deficit_leads' => $result->deficitLeads]),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Billing;
|
||||
|
||||
use App\Jobs\SyncSupplierProjectJob;
|
||||
use App\Mail\BalanceFrozenMail;
|
||||
use App\Mail\BalanceUnfrozenMail;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\BalancePreflightService;
|
||||
use App\Services\Billing\PreflightResult;
|
||||
use App\Services\Supplier\SupplierExportMode;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Ежедневный преfflight всех тенантов перед формированием заказа поставщику.
|
||||
* Запускается cron @18:00 MSK (routes/console.php). См. спек §3.5, §5.2.
|
||||
*
|
||||
* NB: бегает без tenant-RLS (системный контекст); запросы к projects/tenants
|
||||
* явные по tenant_id (урок Спека B). Переход active→frozen / frozen→active
|
||||
* шлёт письмо; стабильное состояние не трогается (идемпотентность).
|
||||
*/
|
||||
final class BalancePreflightSweepJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$service = new BalancePreflightService;
|
||||
$tiers = PricingTier::query()->where('is_active', true)->get();
|
||||
|
||||
Tenant::query()->whereNull('deleted_at')->chunkById(200, function (Collection $tenants) use ($service, $tiers): void {
|
||||
foreach ($tenants as $tenant) {
|
||||
/** @var Tenant $tenant */
|
||||
$this->evaluateTenant($tenant, $service, $tiers);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, PricingTier> $tiers
|
||||
*/
|
||||
private function evaluateTenant(Tenant $tenant, BalancePreflightService $service, Collection $tiers): void
|
||||
{
|
||||
// Spec C deploy hotfix (25.05.2026): CLI-команды и фоновые джобы не проходят
|
||||
// через SetTenantContext middleware → app.current_tenant_id не выставлен →
|
||||
// RLS-policy на projects падает с "unrecognized configuration parameter".
|
||||
// Зеркалим mechanic SetTenantContext: SET LOCAL внутри транзакции (PgBouncer-safe).
|
||||
DB::transaction(function () use ($tenant, $service, $tiers): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tenant->id);
|
||||
|
||||
$required = $tenant->requiredLeadsForTomorrow();
|
||||
$result = $service->evaluate(
|
||||
balanceRub: (string) $tenant->balance_rub,
|
||||
deliveredInMonth: (int) $tenant->delivered_in_month,
|
||||
requiredLeads: $required,
|
||||
tiers: $tiers,
|
||||
);
|
||||
|
||||
$isFrozen = $tenant->frozen_by_balance_at !== null;
|
||||
|
||||
// Переход active → frozen.
|
||||
if (! $result->passes && ! $isFrozen) {
|
||||
$tenant->frozen_by_balance_at = now();
|
||||
$tenant->save();
|
||||
$this->logEvent($tenant, 'frozen', 'cutoff_18msk', $result);
|
||||
Mail::queue(new BalanceFrozenMail($tenant, $result));
|
||||
$this->dispatchSupplierSyncIfOnline($tenant);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Переход frozen → active.
|
||||
if ($result->passes && $isFrozen) {
|
||||
$tenant->frozen_by_balance_at = null;
|
||||
$tenant->save();
|
||||
$this->logEvent($tenant, 'unfrozen', 'cutoff_18msk', $result);
|
||||
Mail::queue(new BalanceUnfrozenMail($tenant, $result));
|
||||
$this->dispatchSupplierSyncIfOnline($tenant);
|
||||
|
||||
return;
|
||||
}
|
||||
// Иначе состояние не изменилось — ничего не делаем (идемпотентность).
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Spec C extension (26.05.2026): при переходе freeze ↔ unfreeze в режиме 'online'
|
||||
* диспатчим точечный sync с поставщиком per-project (group-recalc внутри handleOnline
|
||||
* сам учтёт шеринг через signal_identifier). В режиме 'batch' изменения уезжают
|
||||
* cut-off cron'ом @18:00 MSK через SyncSupplierProjectsJob (множественный).
|
||||
* Привязка к админ-переключателю SupplierExportMode (system_settings.supplier_export_mode).
|
||||
*
|
||||
* Вызывается ВНУТРИ DB::transaction обёртки evaluateTenant — app.current_tenant_id выставлен,
|
||||
* RLS-фильтрация projects работает.
|
||||
*/
|
||||
private function dispatchSupplierSyncIfOnline(Tenant $tenant): void
|
||||
{
|
||||
if (! SupplierExportMode::isOnline()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$projectIds = $tenant->projects()
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->pluck('id');
|
||||
|
||||
foreach ($projectIds as $id) {
|
||||
SyncSupplierProjectJob::dispatch((int) $id);
|
||||
}
|
||||
}
|
||||
|
||||
private function logEvent(Tenant $tenant, string $event, string $trigger, PreflightResult $result): void
|
||||
{
|
||||
DB::connection('pgsql_supplier')->table('balance_freeze_log')->insert([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_type' => $event,
|
||||
'triggered_by' => $trigger,
|
||||
'balance_rub_at_event' => $tenant->balance_rub,
|
||||
'required_leads' => $result->requiredLeads,
|
||||
'capacity_leads' => $result->capacityLeads,
|
||||
'total_daily_limit' => $result->requiredLeads,
|
||||
'details' => json_encode(['deficit_leads' => $result->deficitLeads]),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -83,12 +83,8 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
$this->client = app(SupplierPortalClient::class);
|
||||
$consecutiveTransient = 0;
|
||||
|
||||
// 1. Load active Лидерра-projects via pgsql_supplier
|
||||
/** @var Collection<int, Project> $projects */
|
||||
$projects = Project::on(self::DB_CONNECTION)
|
||||
->where('is_active', true)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
// 1. Load active Лидерра-projects via pgsql_supplier (фильтруя frozen, Billing v2 Spec C §3.10).
|
||||
$projects = $this->collectEligibleProjects();
|
||||
|
||||
// 2. Group by (signal_type, identifier) — no subject_code split.
|
||||
// Portal constraint: one identifier = one B1/B2/B3 group (status=Doubles on duplicate name).
|
||||
@@ -181,6 +177,35 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Собрать eligible Лидерра-проекты для расчёта заказа поставщику.
|
||||
*
|
||||
* Фильтры (Billing v2 Spec C §3.10 — преfflight баланса):
|
||||
* — is_active = true (базовый);
|
||||
* — preflight_blocked_at IS NULL (точечная блокировка проекта при «перегрузе» лимита);
|
||||
* — tenant.frozen_by_balance_at IS NULL (пассивная заморозка тенанта по пустому балансу).
|
||||
*
|
||||
* Запрос через pgsql_supplier (BYPASSRLS) — джоб бегает в системном контексте.
|
||||
* Метод публичный для unit-теста; никто из caller'ов кроме handle() его не зовёт.
|
||||
*
|
||||
* @return Collection<int, Project>
|
||||
*/
|
||||
public function collectEligibleProjects(): Collection
|
||||
{
|
||||
// NB: whereIn-subquery вместо whereHas — whereHas строит relation-query
|
||||
// через default Eloquent connection (pgsql), а наш родительский Project::on
|
||||
// на pgsql_supplier; cross-connection JOIN ломал sync-тесты (8 fails).
|
||||
// FROM 'tenants' внутри subquery наследует connection родителя.
|
||||
return Project::on(self::DB_CONNECTION)
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->whereIn('tenant_id', function ($q): void {
|
||||
$q->select('id')->from('tenants')->whereNull('frozen_by_balance_at');
|
||||
})
|
||||
->orderBy('id')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{signal_type: string, identifier: string, merged_regions: list<int>, has_all_russia: bool, platforms: list<string>, projects: list<Project>} $group
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\PreflightResult;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Финальное письмо: приём лидов приостановлен 3 дня (Billing v2 Spec C §3.7, T+72h).
|
||||
* После него повторов нет до следующего цикла заморозки.
|
||||
*/
|
||||
final class BalanceFrozenFinalMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly Tenant $tenant,
|
||||
public readonly PreflightResult $result,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Приём лидов приостановлен 3 дня',
|
||||
to: [$this->tenant->contact_email],
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.balance_frozen_final');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\PreflightResult;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Письмо клиенту: приём лидов приостановлен из-за нехватки баланса (Billing v2 Spec C §3.7).
|
||||
*
|
||||
* Триггер: BalancePreflightSweepJob при переходе тенанта active → frozen (cut-off 18:00 MSK).
|
||||
*/
|
||||
final class BalanceFrozenMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly Tenant $tenant,
|
||||
public readonly PreflightResult $result,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Приём лидов приостановлен — недостаточно баланса',
|
||||
to: [$this->tenant->contact_email],
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.balance_frozen');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\PreflightResult;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Письмо-напоминание: приём лидов всё ещё приостановлен (Billing v2 Spec C §3.7, T+24h).
|
||||
*/
|
||||
final class BalanceFrozenReminderMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly Tenant $tenant,
|
||||
public readonly PreflightResult $result,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Приём лидов всё ещё приостановлен',
|
||||
to: [$this->tenant->contact_email],
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.balance_frozen_reminder');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\PreflightResult;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Письмо клиенту: приём лидов возобновлён (Billing v2 Spec C §3.7).
|
||||
*
|
||||
* Триггер: BalancePreflightSweepJob при переходе frozen → active (пополнение/снижение лимита).
|
||||
*/
|
||||
final class BalanceUnfrozenMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly Tenant $tenant,
|
||||
public readonly PreflightResult $result,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Приём лидов возобновлён',
|
||||
to: [$this->tenant->contact_email],
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.balance_unfrozen');
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,8 @@ class Project extends Model
|
||||
// Plan 2/5 Task 1 (schema v8.18): дневной счётчик доставленных лидов
|
||||
// (сбрасывается cron'ом в 00:00 МСК, используется LeadRouter'ом).
|
||||
'delivered_today',
|
||||
// Billing v2 Spec C: флаг точечной блокировки проекта по преfflight (NULL = не заблокирован).
|
||||
'preflight_blocked_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -81,6 +83,8 @@ class Project extends Model
|
||||
'delivery_days_mask' => 'integer',
|
||||
'ttfr_target_minutes' => 'integer',
|
||||
'effective_limit_calculated_at' => 'datetime',
|
||||
// Billing v2 Spec C: флаг преfflight-блокировки проекта.
|
||||
'preflight_blocked_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
// Supplier integration:
|
||||
|
||||
@@ -44,6 +44,7 @@ class Tenant extends Model
|
||||
'delivered_in_month',
|
||||
'api_key_limit',
|
||||
'limits',
|
||||
'frozen_by_balance_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -61,6 +62,8 @@ class Tenant extends Model
|
||||
'limits' => 'array',
|
||||
'last_activity_at' => 'datetime',
|
||||
'last_webhook_at' => 'datetime',
|
||||
// Billing v2 Spec C: флаг заморозки по балансу (NULL = не заморожен).
|
||||
'frozen_by_balance_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
@@ -79,6 +82,19 @@ class Tenant extends Model
|
||||
return $this->hasMany(Project::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сумма daily_limit_target активных проектов — «сколько лидов клиент хочет в день».
|
||||
* Используется преfflight'ом (Billing v2 Spec C §3.3) как requiredLeads.
|
||||
*
|
||||
* NB: фильтр по `is_active` (boolean), не `status` — у projects нет колонки status.
|
||||
*/
|
||||
public function requiredLeadsForTomorrow(): int
|
||||
{
|
||||
return (int) $this->projects()
|
||||
->where('is_active', true)
|
||||
->sum('daily_limit_target');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<TariffPlan, $this> */
|
||||
public function tariff(): BelongsTo
|
||||
{
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\PricingTier;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
/**
|
||||
* Pure: проходит ли клиент преfflight — хватает ли баланса на ПОЛНЫЙ дневной
|
||||
* лимит всех его eligible-проектов по текущему тарифу.
|
||||
*
|
||||
* Сравнение в ЛИДАХ (capacity vs required), не в рублях — переиспользует
|
||||
* BalanceToLeadsConverter::convert, который учитывает 7 ступеней и накопленный
|
||||
* месячный объём (deliveredInMonth).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-24-billing-v2-spec-c-preflight-vtb-design.md §3.3
|
||||
*/
|
||||
final class BalancePreflightService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BalanceToLeadsConverter $converter = new BalanceToLeadsConverter,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param Collection<int, PricingTier> $tiers
|
||||
*/
|
||||
public function evaluate(
|
||||
string $balanceRub,
|
||||
int $deliveredInMonth,
|
||||
int $requiredLeads,
|
||||
Collection $tiers,
|
||||
): PreflightResult {
|
||||
if ($requiredLeads <= 0) {
|
||||
return new PreflightResult(true, 0, 0, 0);
|
||||
}
|
||||
|
||||
$capacity = (int) $this->converter->convert($balanceRub, $deliveredInMonth, $tiers)['leads'];
|
||||
$passes = $capacity >= $requiredLeads;
|
||||
|
||||
return new PreflightResult(
|
||||
passes: $passes,
|
||||
requiredLeads: $requiredLeads,
|
||||
capacityLeads: $capacity,
|
||||
deficitLeads: $passes ? 0 : ($requiredLeads - $capacity),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
/**
|
||||
* Результат преfflight-проверки платёжеспособности тенанта (Billing v2 Spec C).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-24-billing-v2-spec-c-preflight-vtb-design.md §3.3
|
||||
*/
|
||||
final class PreflightResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly bool $passes,
|
||||
public readonly int $requiredLeads,
|
||||
public readonly int $capacityLeads,
|
||||
public readonly int $deficitLeads,
|
||||
) {}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Всё DDL через pgsql_supplier — избегаем deadlock при смешивании соединений.
|
||||
// Laravel оборачивает миграцию в транзакцию на дефолтном pgsql; ALTER TABLE tenants
|
||||
// на pgsql + CREATE TABLE с FK на tenants через pgsql_supplier = взаимная блокировка.
|
||||
// На dev pgsql_supplier = postgres superuser (те же права), на prod — явные GRANT'ы ниже.
|
||||
$supplier = DB::connection('pgsql_supplier');
|
||||
|
||||
// Флаги заморозки. tenant-level — пассивный износ; project-level — точечная перегрузка.
|
||||
$supplier->statement('ALTER TABLE tenants ADD COLUMN IF NOT EXISTS frozen_by_balance_at TIMESTAMPTZ NULL');
|
||||
$supplier->statement('ALTER TABLE projects ADD COLUMN IF NOT EXISTS preflight_blocked_at TIMESTAMPTZ NULL');
|
||||
|
||||
$supplier->statement('CREATE INDEX IF NOT EXISTS tenants_frozen_by_balance_idx ON tenants (frozen_by_balance_at) WHERE frozen_by_balance_at IS NOT NULL');
|
||||
$supplier->statement('CREATE INDEX IF NOT EXISTS projects_preflight_blocked_idx ON projects (preflight_blocked_at) WHERE preflight_blocked_at IS NOT NULL');
|
||||
|
||||
// Журнал заморозок/разморозок. Создаём через pgsql_supplier (урок Спека B — prod-роли).
|
||||
$supplier->statement(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS balance_freeze_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
|
||||
event_type VARCHAR(30) NOT NULL,
|
||||
triggered_by VARCHAR(30) NOT NULL,
|
||||
balance_rub_at_event DECIMAL(12,2) NOT NULL,
|
||||
required_leads INTEGER NOT NULL,
|
||||
capacity_leads INTEGER NOT NULL,
|
||||
total_daily_limit INTEGER NOT NULL,
|
||||
details JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
SQL);
|
||||
$supplier->statement('ALTER TABLE balance_freeze_log ENABLE ROW LEVEL SECURITY');
|
||||
$supplier->statement(<<<'SQL'
|
||||
CREATE POLICY tenant_isolation ON balance_freeze_log
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint)
|
||||
SQL);
|
||||
$supplier->statement('CREATE INDEX IF NOT EXISTS balance_freeze_log_tenant_idx ON balance_freeze_log (tenant_id, created_at DESC)');
|
||||
|
||||
// Гранты для 4 ролей (mirror webhook_dedup_keys / supplier_lead_deliveries).
|
||||
foreach (['crm_app_user', 'crm_supplier_worker', 'crm_migrator', 'crm_admin_user'] as $role) {
|
||||
$supplier->statement(<<<SQL
|
||||
DO \$\$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{$role}') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO {$role};
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO {$role};
|
||||
END IF;
|
||||
END
|
||||
\$\$
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$supplier = DB::connection('pgsql_supplier');
|
||||
$supplier->statement('DROP TABLE IF EXISTS balance_freeze_log');
|
||||
$supplier->statement('ALTER TABLE projects DROP COLUMN IF EXISTS preflight_blocked_at');
|
||||
$supplier->statement('ALTER TABLE tenants DROP COLUMN IF EXISTS frozen_by_balance_at');
|
||||
}
|
||||
};
|
||||
@@ -106,3 +106,27 @@ export async function topup(amountRub: number): Promise<TopupResult> {
|
||||
const { data } = await apiClient.post<TopupResult>('/api/billing/topup', { amount_rub: amountRub });
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ответ GET /api/billing/balance-status — лёгкий статус баланса для UI префлайта
|
||||
* (Billing v2 Spec C §3.6): питает баннер заморозки + индикатор ёмкости.
|
||||
*/
|
||||
export interface BalanceStatus {
|
||||
/** ISO-дата заморозки или null (не заморожен). */
|
||||
frozen_by_balance_at: string | null;
|
||||
balance_rub: string;
|
||||
/** Сколько лидов покрывает баланс по текущему тарифу. */
|
||||
capacity_leads: number;
|
||||
/** Суммарный дневной заказ активных не-заблокированных проектов. */
|
||||
required_leads_per_day: number;
|
||||
/** На сколько лидов заказ превышает ёмкость (0 если хватает). */
|
||||
deficit_leads: number;
|
||||
/** Сколько ₽ не хватает, чтобы покрыть дефицит (scale 2, "0.00" если хватает). */
|
||||
deficit_rub: string;
|
||||
}
|
||||
|
||||
/** GET /api/billing/balance-status — статус для баннера заморозки и индикатора ёмкости. */
|
||||
export async function getBalanceStatus(): Promise<BalanceStatus> {
|
||||
const { data } = await apiClient.get<BalanceStatus>('/api/billing/balance-status');
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Постоянная подсказка под балансом (Billing v2 Spec C §3.6, Task 1.10).
|
||||
*
|
||||
* Чистый presentational-компонент: показывает, на сколько дней хватит ёмкости
|
||||
* баланса (в лидах) при текущем дневном заказе всех eligible-проектов.
|
||||
* Зелёный — хватает на 3+ дня; жёлтый — меньше 3 дней; красный — не хватает.
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
/** Баланс в рублях (строка scale 2, например "1000.00"). */
|
||||
balanceRub: string;
|
||||
/** Сколько лидов покрывает баланс по текущему тарифу. */
|
||||
capacityLeads: number;
|
||||
/** Суммарный дневной заказ всех активных проектов (лидов/день). */
|
||||
requiredLeadsPerDay: number;
|
||||
}>();
|
||||
|
||||
const daysLeft = computed(() =>
|
||||
props.requiredLeadsPerDay > 0 ? props.capacityLeads / props.requiredLeadsPerDay : Infinity,
|
||||
);
|
||||
|
||||
const statusClass = computed(() => {
|
||||
if (props.requiredLeadsPerDay > 0 && props.capacityLeads < props.requiredLeadsPerDay) {
|
||||
return 'capacity-insufficient';
|
||||
}
|
||||
if (daysLeft.value < 3) return 'capacity-warning';
|
||||
return 'capacity-ok';
|
||||
});
|
||||
|
||||
const daysLabel = computed(() => (Number.isFinite(daysLeft.value) ? daysLeft.value.toFixed(1) : '∞'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="balance-capacity text-body-2" :class="statusClass" data-testid="balance-capacity-indicator">
|
||||
<div>Баланс: {{ balanceRub }}₽ = до {{ capacityLeads }} лидов по тарифу</div>
|
||||
<div>Проекты заказывают: {{ requiredLeadsPerDay }} лидов в день</div>
|
||||
<div v-if="statusClass === 'capacity-insufficient'" class="capacity-note">
|
||||
⚠️ Не хватает — пополните счёт
|
||||
</div>
|
||||
<div v-else-if="statusClass === 'capacity-warning'" class="capacity-note">
|
||||
Хватит на ~{{ daysLabel }} дн. — скоро потребуется пополнение
|
||||
</div>
|
||||
<div v-else class="capacity-note">✅ Хватит на ~{{ daysLabel }} дн.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.balance-capacity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.capacity-note {
|
||||
font-weight: 600;
|
||||
}
|
||||
.capacity-ok .capacity-note {
|
||||
color: rgb(var(--v-theme-success));
|
||||
}
|
||||
.capacity-warning .capacity-note {
|
||||
color: rgb(var(--v-theme-warning));
|
||||
}
|
||||
.capacity-insufficient .capacity-note {
|
||||
color: rgb(var(--v-theme-error));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Красный баннер заморозки баланса (Billing v2 Spec C §3.6, Task 1.10).
|
||||
*
|
||||
* Показывается на всех страницах, когда tenant.frozen_by_balance_at !== null
|
||||
* (проп `frozen`). Источник данных — tenantStore, загружаемый глобально в
|
||||
* AppLayout. Чистый presentational-компонент.
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
frozen: boolean;
|
||||
/** Сколько ₽ не хватает на дневной заказ (строка scale 2). */
|
||||
deficitRub?: string;
|
||||
/** Сколько лидов превышают ёмкость баланса. */
|
||||
deficitLeads?: number;
|
||||
}>();
|
||||
|
||||
const hasDeficit = computed(() => (props.deficitLeads ?? 0) > 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-alert
|
||||
v-if="frozen"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
rounded="lg"
|
||||
class="balance-frozen-banner ma-4 mb-0"
|
||||
data-testid="balance-frozen-banner"
|
||||
>
|
||||
<div class="text-subtitle-2 font-weight-bold">Приём лидов приостановлен</div>
|
||||
<div class="text-body-2 mb-2">
|
||||
Не хватает баланса на дневной заказ.<span v-if="hasDeficit">
|
||||
Нужно ещё {{ deficitRub }}₽ (или сократи лимиты на {{ deficitLeads }} лидов).</span>
|
||||
</div>
|
||||
<RouterLink to="/billing" data-testid="banner-topup-link" class="banner-link">
|
||||
Пополнить счёт
|
||||
</RouterLink>
|
||||
<RouterLink to="/projects" data-testid="banner-projects-link" class="banner-link ml-4">
|
||||
Перейти к проектам
|
||||
</RouterLink>
|
||||
</v-alert>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.banner-link {
|
||||
font-weight: 600;
|
||||
color: rgb(var(--v-theme-error));
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Диалог перегрузки лимита (Billing v2 Spec C §6.2, Task 1.10).
|
||||
*
|
||||
* Открывается, когда POST/PATCH /api/projects вернул 409 `balance_insufficient`.
|
||||
* Показывает дефицит и предлагает три исхода:
|
||||
* - «Сохранить и приостановить» → save-blocked (родитель пере-сабмитит с
|
||||
* force_save_blocked=true → проект создаётся с preflight_blocked_at);
|
||||
* - «Поставить лимит 0» → set-zero (родитель ставит daily_limit_target=0);
|
||||
* - «Отмена» → закрытие без сохранения.
|
||||
*/
|
||||
export interface OverloadPayload {
|
||||
current_balance_rub: string;
|
||||
current_capacity_leads: number;
|
||||
would_be_required_leads: number;
|
||||
deficit_leads: number;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
modelValue: boolean;
|
||||
payload: OverloadPayload | null;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
'save-blocked': [];
|
||||
'set-zero': [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
max-width="520"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<v-card v-if="payload" data-testid="overload-dialog">
|
||||
<v-card-title>Лимит превышает баланс</v-card-title>
|
||||
<v-card-text>
|
||||
<p>
|
||||
У тебя {{ payload.current_balance_rub }}₽ =
|
||||
{{ payload.current_capacity_leads }} лидов по текущему тарифу.
|
||||
</p>
|
||||
<p>После сохранения нужно {{ payload.would_be_required_leads }} лидов.</p>
|
||||
<p class="font-weight-medium">Не хватает: {{ payload.deficit_leads }} лидов.</p>
|
||||
<p class="text-medium-emphasis mt-2">
|
||||
Чтобы проект начал работать — пополни счёт, поставь его лимит 0
|
||||
или уменьши лимиты других проектов.
|
||||
</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" data-testid="overload-cancel" @click="$emit('update:modelValue', false)">
|
||||
Отмена
|
||||
</v-btn>
|
||||
<v-btn variant="text" data-testid="overload-set-zero" @click="$emit('set-zero')">
|
||||
Поставить лимит 0
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
data-testid="overload-save-blocked"
|
||||
@click="$emit('save-blocked')"
|
||||
>
|
||||
Сохранить и приостановить
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
@@ -14,16 +14,19 @@ import { RouterView, useRoute } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useNotificationsStore } from '../stores/notifications';
|
||||
import { useRemindersStore } from '../stores/reminders';
|
||||
import { useTenantStore } from '../stores/tenantStore';
|
||||
import { usePolling } from '../composables/usePolling';
|
||||
import { POLLING_INTERVAL_MS, POLLING_REMINDERS_INTERVAL_MS } from '../constants/polling';
|
||||
import AppSidebar from '../components/layout/AppSidebar.vue';
|
||||
import AppTopbar from '../components/layout/AppTopbar.vue';
|
||||
import DevIndexBadge from '../components/DevIndexBadge.vue';
|
||||
import CommandPalette from '../components/layout/CommandPalette.vue';
|
||||
import BalanceFrozenBanner from '../components/billing/BalanceFrozenBanner.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const notifications = useNotificationsStore();
|
||||
const reminders = useRemindersStore();
|
||||
const tenant = useTenantStore();
|
||||
const route = useRoute();
|
||||
|
||||
const drawerOpen = ref(true);
|
||||
@@ -60,12 +63,19 @@ async function loadReminderCounts(): Promise<void> {
|
||||
await reminders.refreshCounts();
|
||||
}
|
||||
|
||||
async function loadBalanceStatus(): Promise<void> {
|
||||
if (!auth.user) return;
|
||||
await tenant.load();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadNotifications();
|
||||
void loadReminderCounts();
|
||||
void loadBalanceStatus();
|
||||
});
|
||||
usePolling(loadNotifications, { intervalMs: POLLING_INTERVAL_MS, enabled: true });
|
||||
usePolling(loadReminderCounts, { intervalMs: POLLING_REMINDERS_INTERVAL_MS, enabled: true });
|
||||
usePolling(loadBalanceStatus, { intervalMs: POLLING_REMINDERS_INTERVAL_MS, enabled: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -74,6 +84,11 @@ usePolling(loadReminderCounts, { intervalMs: POLLING_REMINDERS_INTERVAL_MS, enab
|
||||
<AppTopbar :page-title="currentPageTitle" @toggle-drawer="drawerOpen = !drawerOpen" />
|
||||
|
||||
<v-main class="app-main">
|
||||
<BalanceFrozenBanner
|
||||
:frozen="tenant.frozen"
|
||||
:deficit-rub="tenant.deficitRub"
|
||||
:deficit-leads="tenant.deficitLeads"
|
||||
/>
|
||||
<RouterView v-slot="{ Component, route: r }">
|
||||
<Transition :name="(r.meta.transition as string) ?? 'ld-route-fadeup'" mode="out-in">
|
||||
<component :is="Component" :key="r.fullPath" />
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import * as billingApi from '../api/billing';
|
||||
import type { BalanceStatus } from '../api/billing';
|
||||
|
||||
/**
|
||||
* Tenant-store: статус баланса текущего тенанта для UI префлайта (Billing v2
|
||||
* Spec C Task 1.10). Единый источник для глобального баннера заморозки
|
||||
* (BalanceFrozenBanner в AppLayout) и индикатора ёмкости (BalanceCapacityIndicator
|
||||
* в BillingView) — чтобы не делать два запроса.
|
||||
*
|
||||
* Загружается глобально в AppLayout (load + polling). `load()` молча проглатывает
|
||||
* ошибку: баннер/индикатор не критичны и не должны валить страницу.
|
||||
*/
|
||||
export const useTenantStore = defineStore('tenant', () => {
|
||||
const status = ref<BalanceStatus | null>(null);
|
||||
|
||||
const frozen = computed(() => status.value?.frozen_by_balance_at != null);
|
||||
const deficitRub = computed(() => status.value?.deficit_rub ?? '0.00');
|
||||
const deficitLeads = computed(() => status.value?.deficit_leads ?? 0);
|
||||
const balanceRub = computed(() => status.value?.balance_rub ?? '0.00');
|
||||
const capacityLeads = computed(() => status.value?.capacity_leads ?? 0);
|
||||
const requiredLeadsPerDay = computed(() => status.value?.required_leads_per_day ?? 0);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
status.value = await billingApi.getBalanceStatus();
|
||||
} catch {
|
||||
// Не критично — оставляем прошлый статус (или null → баннер скрыт).
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
frozen,
|
||||
deficitRub,
|
||||
deficitLeads,
|
||||
balanceRub,
|
||||
capacityLeads,
|
||||
requiredLeadsPerDay,
|
||||
load,
|
||||
};
|
||||
});
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import BalanceCard from '../components/billing/BalanceCard.vue';
|
||||
import BalanceCapacityIndicator from '../components/billing/BalanceCapacityIndicator.vue';
|
||||
import TierPricesPanel from '../components/billing/TierPricesPanel.vue';
|
||||
import TransactionsTable from '../components/billing/TransactionsTable.vue';
|
||||
import InvoicesTable from '../components/billing/InvoicesTable.vue';
|
||||
@@ -19,8 +20,10 @@ import ChargesTab from './billing/ChargesTab.vue';
|
||||
import { formatPlain, featureLabel } from '../composables/billingFormatters';
|
||||
import { getWallet, type Wallet } from '../api/billing';
|
||||
import { extractErrorMessage } from '../api/client';
|
||||
import { useTenantStore } from '../stores/tenantStore';
|
||||
|
||||
const activeView = ref<'overview' | 'charges'>('overview');
|
||||
const tenant = useTenantStore();
|
||||
|
||||
const wallet = ref<Wallet | null>(null);
|
||||
const loading = ref(true);
|
||||
@@ -59,10 +62,15 @@ async function onTopupSuccess(): Promise<void> {
|
||||
topupOpen.value = false;
|
||||
topupSnackbar.value = true;
|
||||
await loadWallet();
|
||||
// Пополнение могло снять заморозку → обновляем статус баланса (баннер/индикатор).
|
||||
await tenant.load();
|
||||
txTableRef.value?.refresh();
|
||||
}
|
||||
|
||||
onMounted(loadWallet);
|
||||
onMounted(() => {
|
||||
void loadWallet();
|
||||
void tenant.load();
|
||||
});
|
||||
|
||||
defineExpose({ loadWallet, wallet, topupOpen });
|
||||
</script>
|
||||
@@ -117,6 +125,14 @@ defineExpose({ loadWallet, wallet, topupOpen });
|
||||
@topup="topupOpen = true"
|
||||
/>
|
||||
|
||||
<BalanceCapacityIndicator
|
||||
v-if="tenant.status"
|
||||
class="mt-3"
|
||||
:balance-rub="tenant.balanceRub"
|
||||
:capacity-leads="tenant.capacityLeads"
|
||||
:required-leads-per-day="tenant.requiredLeadsPerDay"
|
||||
/>
|
||||
|
||||
<TierPricesPanel :tiers="tiersPreview" :current-tier-no="currentTierNo" />
|
||||
|
||||
<TransactionsTable ref="txTableRef" />
|
||||
|
||||
@@ -190,6 +190,13 @@
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<ProjectLimitOverloadDialog
|
||||
v-model="overloadOpen"
|
||||
:payload="overloadPayload"
|
||||
@save-blocked="onOverloadSaveBlocked"
|
||||
@set-zero="onOverloadSetZero"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -199,6 +206,7 @@ import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
|
||||
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
|
||||
import type { Project } from '../../stores/projectsStore';
|
||||
import DevIndexBadge from '../../components/DevIndexBadge.vue';
|
||||
import ProjectLimitOverloadDialog from '../../components/projects/ProjectLimitOverloadDialog.vue';
|
||||
|
||||
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
|
||||
|
||||
@@ -225,6 +233,16 @@ const errors = reactive<Record<string, string[]>>({});
|
||||
const saving = ref(false);
|
||||
const generalError = ref<string | null>(null);
|
||||
|
||||
// Spec C §6.2: префлайт баланса — диалог перегрузки лимита по 409.
|
||||
interface OverloadPayloadShape {
|
||||
current_balance_rub: string;
|
||||
current_capacity_leads: number;
|
||||
would_be_required_leads: number;
|
||||
deficit_leads: number;
|
||||
}
|
||||
const overloadOpen = ref(false);
|
||||
const overloadPayload = ref<OverloadPayloadShape | null>(null);
|
||||
|
||||
// Plan 4 Task 4: обязательный выбор региона + явная «Вся РФ» с подтверждением.
|
||||
// vsyaRf — чекбокс выбран; vsyaRfConfirmed — подтверждён через предупреждение.
|
||||
// На бэке regions=[] (Вся РФ) и «забыл» неотличимы → гейт намеренно UI-only.
|
||||
@@ -303,6 +321,37 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function persist(extra: Record<string, unknown> = {}): Promise<void> {
|
||||
saving.value = true;
|
||||
try {
|
||||
await ensureCsrfCookie();
|
||||
const body = { ...form, ...extra };
|
||||
if (props.mode === 'edit' && props.project) {
|
||||
await apiClient.patch(`/api/projects/${props.project.id}`, body);
|
||||
} else {
|
||||
await apiClient.post('/api/projects', body);
|
||||
}
|
||||
overloadOpen.value = false;
|
||||
emit('saved');
|
||||
close();
|
||||
} catch (e: unknown) {
|
||||
const err = e as {
|
||||
response?: { status?: number; data?: { error?: string; errors?: Record<string, string[]> } };
|
||||
};
|
||||
// Spec C §6.2: лимит превышает баланс — открываем диалог перегрузки.
|
||||
if (err.response?.status === 409 && err.response.data?.error === 'balance_insufficient') {
|
||||
overloadPayload.value = err.response.data as OverloadPayloadShape;
|
||||
overloadOpen.value = true;
|
||||
} else if (err.response?.status === 422 && err.response.data?.errors) {
|
||||
Object.assign(errors, err.response.data.errors);
|
||||
} else {
|
||||
generalError.value = extractErrorMessage(e);
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
generalError.value = null;
|
||||
Object.keys(errors).forEach((k) => delete errors[k]);
|
||||
@@ -313,26 +362,18 @@ async function submit() {
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await ensureCsrfCookie();
|
||||
if (props.mode === 'edit' && props.project) {
|
||||
await apiClient.patch(`/api/projects/${props.project.id}`, { ...form });
|
||||
} else {
|
||||
await apiClient.post('/api/projects', { ...form });
|
||||
}
|
||||
emit('saved');
|
||||
close();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { status?: number; data?: { errors?: Record<string, string[]> } } };
|
||||
if (err.response?.status === 422 && err.response.data?.errors) {
|
||||
Object.assign(errors, err.response.data.errors);
|
||||
} else {
|
||||
generalError.value = extractErrorMessage(e);
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
await persist();
|
||||
}
|
||||
|
||||
// Spec C §6.2 — исходы диалога перегрузки лимита.
|
||||
async function onOverloadSaveBlocked(): Promise<void> {
|
||||
await persist({ force_save_blocked: true });
|
||||
}
|
||||
|
||||
async function onOverloadSetZero(): Promise<void> {
|
||||
form.daily_limit_target = 0;
|
||||
overloadOpen.value = false;
|
||||
await persist();
|
||||
}
|
||||
|
||||
function close() {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
Здравствуйте, {{ $tenant->organization_name }}!
|
||||
|
||||
Приём лидов по вашим проектам приостановлен — на счёте недостаточно средств
|
||||
на завтрашний дневной заказ.
|
||||
|
||||
Сейчас баланса хватает на {{ $result->capacityLeads }} лидов, а ваши проекты
|
||||
заказывают {{ $result->requiredLeads }} лидов в день. Не хватает: {{ $result->deficitLeads }} лидов.
|
||||
|
||||
Чтобы возобновить приём, пополните счёт или уменьшите дневные лимиты проектов
|
||||
до 18:00 по московскому времени.
|
||||
|
||||
С уважением, команда Лидерра.
|
||||
@@ -0,0 +1,11 @@
|
||||
Здравствуйте, {{ $tenant->organization_name }}!
|
||||
|
||||
Приём лидов по вашим проектам приостановлен уже 3 дня из-за нехватки баланса.
|
||||
Это последнее напоминание.
|
||||
|
||||
Баланса хватает на {{ $result->capacityLeads }} лидов, проекты заказывают
|
||||
{{ $result->requiredLeads }} в день. Не хватает: {{ $result->deficitLeads }} лидов.
|
||||
|
||||
Чтобы возобновить приём — пополните счёт или уменьшите лимиты проектов.
|
||||
|
||||
С уважением, команда Лидерра.
|
||||
@@ -0,0 +1,10 @@
|
||||
Здравствуйте, {{ $tenant->organization_name }}!
|
||||
|
||||
Напоминаем: приём лидов по вашим проектам всё ещё приостановлен из-за нехватки баланса.
|
||||
|
||||
Баланса хватает на {{ $result->capacityLeads }} лидов, проекты заказывают
|
||||
{{ $result->requiredLeads }} в день. Не хватает: {{ $result->deficitLeads }} лидов.
|
||||
|
||||
Пополните счёт или уменьшите лимиты до 18:00 МСК, чтобы возобновить приём.
|
||||
|
||||
С уважением, команда Лидерра.
|
||||
@@ -0,0 +1,10 @@
|
||||
Здравствуйте, {{ $tenant->organization_name }}!
|
||||
|
||||
Приём лидов по вашим проектам возобновлён — баланса снова хватает на дневной заказ.
|
||||
|
||||
Текущий запас: {{ $result->capacityLeads }} лидов, проекты заказывают
|
||||
{{ $result->requiredLeads }} в день.
|
||||
|
||||
Заказ поставщику будет сформирован в ближайшем вечернем цикле (18:00 МСК).
|
||||
|
||||
С уважением, команда Лидерра.
|
||||
+22
-1
@@ -64,6 +64,25 @@ Schedule::command('partitions:drop-expired')
|
||||
->onSuccess(fn () => $hb->recordRunResult('partitions:drop-expired', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('partitions:drop-expired', false, 'Command failed', null));
|
||||
|
||||
// Billing v2 Spec C §3.2: преfflight баланса в 18:00 MSK — заморозка/разморозка
|
||||
// тенантов перед формированием заказа поставщику (без «бедных» клиентов).
|
||||
// ВАЖНО: идёт ДО SyncSupplierProjectsJob (сдвинут на 18:05) — фильтр frozen-проектов
|
||||
// должен примениться к расчёту заказа того же вечера.
|
||||
Schedule::command('billing:preflight-sweep')
|
||||
->dailyAt('18:00')
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('billing:preflight-sweep', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('billing:preflight-sweep', false, 'Command failed', null));
|
||||
|
||||
// Billing v2 Spec C §3.7: повторные письма заморозки (reminder +1д, final +3д).
|
||||
// Идёт ПОСЛЕ основного sweep — если sweep только что заморозил тенанта, окно reminder
|
||||
// (24h+) ещё не открылось, повторного письма в тот же день не будет (correct).
|
||||
Schedule::command('billing:frozen-reminder')
|
||||
->dailyAt('18:30')
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('billing:frozen-reminder', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('billing:frozen-reminder', false, 'Command failed', null));
|
||||
|
||||
// Plan 3 Task 8: 5 Schedule entries для supplier-flow.
|
||||
//
|
||||
// NB: ->onOneServer() требует cache_locks таблицу, которой у нас нет
|
||||
@@ -83,8 +102,10 @@ Schedule::job(new RefreshSupplierSessionJob)
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@daily', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@daily', false, 'Job failed', null));
|
||||
// Billing v2 Spec C: сдвинут 18:00 → 18:05, чтобы billing:preflight-sweep (18:00)
|
||||
// успел проставить frozen-флаги до формирования заказа поставщику.
|
||||
Schedule::job(new SyncSupplierProjectsJob)
|
||||
->dailyAt('18:00')
|
||||
->dailyAt('18:05')
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\SyncSupplierProjectsJob', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\SyncSupplierProjectsJob', false, 'Job failed', null));
|
||||
|
||||
@@ -189,6 +189,7 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing/charges')->g
|
||||
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing')->group(function () {
|
||||
Route::post('/topup', 'App\Http\Controllers\Api\BillingController@topup');
|
||||
Route::get('/wallet', 'App\Http\Controllers\Api\BillingController@wallet');
|
||||
Route::get('/balance-status', 'App\Http\Controllers\Api\BillingController@balanceStatus');
|
||||
Route::get('/transactions', 'App\Http\Controllers\Api\BillingController@transactions');
|
||||
Route::get('/invoices', 'App\Http\Controllers\Api\BillingController@invoices');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Mail\BalanceFrozenMail;
|
||||
use App\Mail\BalanceUnfrozenMail;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\PreflightResult;
|
||||
|
||||
it('renders frozen mail with deficit', function () {
|
||||
$tenant = Tenant::factory()->create(['organization_name' => 'ООО Альфа']);
|
||||
$result = new PreflightResult(false, 30, 20, 10);
|
||||
|
||||
$rendered = (new BalanceFrozenMail($tenant, $result))->render();
|
||||
|
||||
expect($rendered)->toContain('приостановлен');
|
||||
expect($rendered)->toContain('10'); // дефицит лидов
|
||||
expect($rendered)->toContain('ООО Альфа');
|
||||
});
|
||||
|
||||
it('renders unfrozen mail', function () {
|
||||
$tenant = Tenant::factory()->create(['organization_name' => 'ООО Бета']);
|
||||
$result = new PreflightResult(true, 25, 40, 0);
|
||||
|
||||
$rendered = (new BalanceUnfrozenMail($tenant, $result))->render();
|
||||
|
||||
expect($rendered)->toContain('возобновлён');
|
||||
expect($rendered)->toContain('ООО Бета');
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Billing\BalanceFrozenReminderJob;
|
||||
use App\Mail\BalanceFrozenFinalMail;
|
||||
use App\Mail\BalanceFrozenReminderMail;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
// Изоляция: liderra_testing persistent — DatabaseTransactions откатывает default-pgsql,
|
||||
// SharesSupplierPdo делает pgsql_supplier общим PDO (паттерн Спека B / Task 1.4).
|
||||
// Per-tenant Mail-фильтры обязательны — DemoSeeder тенанты могут попасть в sweep
|
||||
// (прецедент idempotent-fixup 55a1bc05).
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// RLS-контекст (системный tenant 0).
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
PricingTier::query()->create([
|
||||
'tier_no' => 1,
|
||||
'leads_in_tier' => null,
|
||||
'price_per_lead_kopecks' => 5000,
|
||||
'is_active' => true,
|
||||
'effective_from' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends reminder ~1 day after freeze', function () {
|
||||
Mail::fake();
|
||||
// frozen 25h назад — попадает в окно reminder (24-48h).
|
||||
Carbon::setTestNow('2026-05-25 12:00:00');
|
||||
$tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '0.00',
|
||||
'frozen_by_balance_at' => Carbon::now()->subHours(25),
|
||||
]);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
||||
|
||||
(new BalanceFrozenReminderJob)->handle();
|
||||
|
||||
Mail::assertQueued(BalanceFrozenReminderMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
||||
Mail::assertNotQueued(BalanceFrozenFinalMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
||||
});
|
||||
|
||||
it('sends final ~3 days after freeze', function () {
|
||||
Mail::fake();
|
||||
// frozen 73h назад — попадает в окно final (72-96h).
|
||||
Carbon::setTestNow('2026-05-25 12:00:00');
|
||||
$tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '0.00',
|
||||
'frozen_by_balance_at' => Carbon::now()->subHours(73),
|
||||
]);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
||||
|
||||
(new BalanceFrozenReminderJob)->handle();
|
||||
|
||||
Mail::assertQueued(BalanceFrozenFinalMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
||||
Mail::assertNotQueued(BalanceFrozenReminderMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
||||
});
|
||||
|
||||
it('sends nothing for freshly frozen tenant', function () {
|
||||
Mail::fake();
|
||||
// frozen 2h назад — окно ещё не открылось.
|
||||
Carbon::setTestNow('2026-05-25 12:00:00');
|
||||
$tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '0.00',
|
||||
'frozen_by_balance_at' => Carbon::now()->subHours(2),
|
||||
]);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
||||
|
||||
(new BalanceFrozenReminderJob)->handle();
|
||||
|
||||
Mail::assertNotQueued(BalanceFrozenReminderMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
||||
Mail::assertNotQueued(BalanceFrozenFinalMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
||||
});
|
||||
|
||||
it('is throttled — does not re-send reminder for same tenant in window', function () {
|
||||
Mail::fake();
|
||||
Carbon::setTestNow('2026-05-25 12:00:00');
|
||||
$tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '0.00',
|
||||
'frozen_by_balance_at' => Carbon::now()->subHours(25),
|
||||
]);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
||||
|
||||
// Первый прогон — отправляет reminder.
|
||||
(new BalanceFrozenReminderJob)->handle();
|
||||
Mail::assertQueued(BalanceFrozenReminderMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
||||
|
||||
// Сброс fake и второй прогон в том же окне — повторного письма быть не должно.
|
||||
Mail::fake();
|
||||
(new BalanceFrozenReminderJob)->handle();
|
||||
Mail::assertNotQueued(BalanceFrozenReminderMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Billing\BalancePreflightSweepJob;
|
||||
use App\Jobs\SyncSupplierProjectJob;
|
||||
use App\Mail\BalanceFrozenMail;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
// Изоляция: liderra_testing persistent (RefreshDatabase off). DatabaseTransactions
|
||||
// откатывает default-pgsql после каждого теста; SharesSupplierPdo делает pgsql_supplier
|
||||
// общим PDO с pgsql — иначе job-запись balance_freeze_log (pgsql_supplier) не видит
|
||||
// незакоммиченного tenant и падает на FK (паттерн Спека B / AutoPauseFlowTest).
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// RLS-контекст (системный tenant 0) — паттерн supplier-тестов.
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
PricingTier::query()->create(['tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 5000, 'is_active' => true, 'effective_from' => now()]);
|
||||
});
|
||||
|
||||
it('freezes tenant whose balance no longer covers daily limit', function () {
|
||||
Mail::fake();
|
||||
// 500₽ / 50₽ = 10 лидов; проекты хотят 25 → заморозка.
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
||||
|
||||
(new BalancePreflightSweepJob)->handle();
|
||||
|
||||
expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull();
|
||||
Mail::assertQueued(BalanceFrozenMail::class);
|
||||
});
|
||||
|
||||
it('unfreezes tenant whose balance now covers daily limit', function () {
|
||||
Mail::fake();
|
||||
// 2000₽ / 50₽ = 40 лидов; хотят 25 → разморозка.
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '2000.00', 'frozen_by_balance_at' => now()->subDay()]);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
||||
|
||||
(new BalancePreflightSweepJob)->handle();
|
||||
|
||||
expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
|
||||
});
|
||||
|
||||
it('is idempotent — does not re-freeze already frozen tenant', function () {
|
||||
Mail::fake();
|
||||
$frozenAt = now()->subDay();
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '0.00', 'frozen_by_balance_at' => $frozenAt]);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
||||
|
||||
(new BalancePreflightSweepJob)->handle();
|
||||
|
||||
// Дата заморозки не перезаписана; для ЭТОГО tenant повторного письма нет.
|
||||
// NB: per-tenant фильтр, т.к. liderra_testing persistent (DemoSeeder тенанты
|
||||
// могут попасть в sweep и тоже получить BalanceFrozenMail — не наш ответ).
|
||||
expect($tenant->fresh()->frozen_by_balance_at->timestamp)->toBe($frozenAt->timestamp);
|
||||
Mail::assertNotQueued(BalanceFrozenMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
||||
});
|
||||
|
||||
// Spec C extension (26.05.2026): freeze/unfreeze дёргают supplier sync в режиме 'online'.
|
||||
// Привязка к существующему админ-переключателю SupplierExportMode (system_settings.supplier_export_mode).
|
||||
// Online нужен сейчас для отладки (моментальный sync с поставщиком); batch будет рабочим режимом
|
||||
// при росте числа клиентов (накопленные изменения уезжают одним cut-off-cron'ом в 18:00 MSK).
|
||||
|
||||
it('dispatches SyncSupplierProjectJob for each active project on freeze when supplier mode is online', function () {
|
||||
Mail::fake();
|
||||
Queue::fake();
|
||||
DB::table('system_settings')->updateOrInsert(['key' => 'supplier_export_mode'], ['value' => 'online']);
|
||||
|
||||
// 500₽ / 50₽ = 10 лидов; 2 проекта по 15 = 30 → заморозка.
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
|
||||
$p1 = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 15]);
|
||||
$p2 = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 15]);
|
||||
|
||||
(new BalancePreflightSweepJob)->handle();
|
||||
|
||||
expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull();
|
||||
Queue::assertPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === $p1->id);
|
||||
Queue::assertPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === $p2->id);
|
||||
});
|
||||
|
||||
it('does NOT dispatch SyncSupplierProjectJob on freeze when supplier mode is batch', function () {
|
||||
Mail::fake();
|
||||
Queue::fake();
|
||||
DB::table('system_settings')->updateOrInsert(['key' => 'supplier_export_mode'], ['value' => 'batch']);
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
||||
|
||||
(new BalancePreflightSweepJob)->handle();
|
||||
|
||||
expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull();
|
||||
// batch-режим: sync с поставщиком отложен до cut-off 18:00 MSK через SyncSupplierProjectsJob (множественный).
|
||||
Queue::assertNotPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === Project::query()->where('tenant_id', $tenant->id)->value('id'));
|
||||
});
|
||||
|
||||
it('dispatches SyncSupplierProjectJob on unfreeze when supplier mode is online', function () {
|
||||
Mail::fake();
|
||||
Queue::fake();
|
||||
DB::table('system_settings')->updateOrInsert(['key' => 'supplier_export_mode'], ['value' => 'online']);
|
||||
|
||||
// 2000₽ / 50₽ = 40 лидов; хотят 25 → разморозка.
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '2000.00', 'frozen_by_balance_at' => now()->subDay()]);
|
||||
$project = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
||||
|
||||
(new BalancePreflightSweepJob)->handle();
|
||||
|
||||
expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
|
||||
Queue::assertPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === $project->id);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\PricingTierSeeder;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
/**
|
||||
* GET /api/billing/balance-status — статус баланса для UI префлайта (Billing v2
|
||||
* Spec C Task 1.10): питает баннер заморозки (BalanceFrozenBanner) и индикатор
|
||||
* ёмкости (BalanceCapacityIndicator).
|
||||
*
|
||||
* PricingTierSeeder: ступень 1 — 100 лидов × 500₽ (см. BillingOverviewControllerTest).
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
$this->tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '5000.00',
|
||||
'delivered_in_month' => 0,
|
||||
'frozen_by_balance_at' => null,
|
||||
]);
|
||||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
test('GET /api/billing/balance-status: структура ответа', function () {
|
||||
$this->getJson('/api/billing/balance-status')
|
||||
->assertOk()
|
||||
->assertJsonStructure([
|
||||
'frozen_by_balance_at',
|
||||
'balance_rub',
|
||||
'capacity_leads',
|
||||
'required_leads_per_day',
|
||||
'deficit_leads',
|
||||
'deficit_rub',
|
||||
]);
|
||||
});
|
||||
|
||||
test('balance-status: хватает баланса — deficit=0, не заморожен', function () {
|
||||
// 5000₽ при ступени 1 (500₽/лид) = 10 лидов ёмкости. Проект лимит 5 — впритык.
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 5,
|
||||
]);
|
||||
|
||||
$resp = $this->getJson('/api/billing/balance-status')->assertOk();
|
||||
|
||||
expect($resp->json('balance_rub'))->toBe('5000.00');
|
||||
expect($resp->json('capacity_leads'))->toBe(10);
|
||||
expect($resp->json('required_leads_per_day'))->toBe(5);
|
||||
expect($resp->json('deficit_leads'))->toBe(0);
|
||||
expect($resp->json('deficit_rub'))->toBe('0.00');
|
||||
expect($resp->json('frozen_by_balance_at'))->toBeNull();
|
||||
});
|
||||
|
||||
test('balance-status: не хватает — deficit_leads + deficit_rub точные', function () {
|
||||
// Ёмкость = 10 лидов. Проект лимит 25 → нужно 25, дефицит 15 лидов.
|
||||
// minBalanceForLeads(25) = 25 × 500₽ = 12500₽ → deficit_rub = 12500 − 5000 = 7500.00.
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 25,
|
||||
]);
|
||||
|
||||
$resp = $this->getJson('/api/billing/balance-status')->assertOk();
|
||||
|
||||
expect($resp->json('capacity_leads'))->toBe(10);
|
||||
expect($resp->json('required_leads_per_day'))->toBe(25);
|
||||
expect($resp->json('deficit_leads'))->toBe(15);
|
||||
expect($resp->json('deficit_rub'))->toBe('7500.00');
|
||||
});
|
||||
|
||||
test('balance-status: required исключает inactive и preflight_blocked проекты', function () {
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $this->tenant->id, 'is_active' => true, 'daily_limit_target' => 5,
|
||||
]);
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $this->tenant->id, 'is_active' => false, 'daily_limit_target' => 100,
|
||||
]);
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $this->tenant->id, 'is_active' => true, 'daily_limit_target' => 100,
|
||||
'preflight_blocked_at' => now(),
|
||||
]);
|
||||
|
||||
$resp = $this->getJson('/api/billing/balance-status')->assertOk();
|
||||
|
||||
expect($resp->json('required_leads_per_day'))->toBe(5);
|
||||
});
|
||||
|
||||
test('balance-status: возвращает frozen_by_balance_at когда установлен', function () {
|
||||
$this->tenant->update(['frozen_by_balance_at' => now()]);
|
||||
|
||||
$resp = $this->getJson('/api/billing/balance-status')->assertOk();
|
||||
|
||||
expect($resp->json('frozen_by_balance_at'))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('GET /api/billing/balance-status без auth: 401', function () {
|
||||
auth()->logout();
|
||||
$this->getJson('/api/billing/balance-status')->assertStatus(401);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Mail\BalanceFrozenMail;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function () {
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
PricingTier::query()->create([
|
||||
'tier_no' => 1,
|
||||
'leads_in_tier' => null,
|
||||
'price_per_lead_kopecks' => 5000,
|
||||
'is_active' => true,
|
||||
'effective_from' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
it('freezes pre-existing underfunded tenant on first run', function () {
|
||||
Mail::fake();
|
||||
// 0₽ + проекты на 25 лидов → должен быть заморожен.
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '0.00', 'frozen_by_balance_at' => null]);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
||||
|
||||
$this->artisan('billing:preflight-initial-sweep')->assertSuccessful();
|
||||
|
||||
expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull();
|
||||
Mail::assertQueued(BalanceFrozenMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
PricingTier::query()->create([
|
||||
'tier_no' => 1,
|
||||
'leads_in_tier' => null,
|
||||
'price_per_lead_kopecks' => 5000, // 50₽/лид — capacity = balance/50
|
||||
'is_active' => true,
|
||||
'effective_from' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns 409 when new project would overload balance', function () {
|
||||
// 1000₽ / 50₽ = 20 лидов capacity; запрашиваем daily_limit_target=30 → дефицит 10.
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/projects', [
|
||||
'name' => 'Перегрузка',
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'overload.ru',
|
||||
'daily_limit_target' => 30,
|
||||
'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
$response->assertStatus(409);
|
||||
$response->assertJsonPath('error', 'balance_insufficient');
|
||||
$response->assertJsonPath('deficit_leads', 10);
|
||||
$response->assertJsonPath('current_capacity_leads', 20);
|
||||
$response->assertJsonPath('would_be_required_leads', 30);
|
||||
expect(Project::where('signal_identifier', 'overload.ru')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('creates blocked project when force_save_blocked=true', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/projects', [
|
||||
'name' => 'Заблокированный',
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'force-blocked.ru',
|
||||
'daily_limit_target' => 30,
|
||||
'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
'force_save_blocked' => true,
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$project = Project::where('signal_identifier', 'force-blocked.ru')->first();
|
||||
expect($project)->not->toBeNull();
|
||||
expect($project->preflight_blocked_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('creates normally when within balance', function () {
|
||||
// 2000₽ / 50₽ = 40 лидов capacity; daily_limit_target=30 — passes.
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '2000.00']);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/projects', [
|
||||
'name' => 'Норма',
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'ok-balance.ru',
|
||||
'daily_limit_target' => 30,
|
||||
'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$project = Project::where('signal_identifier', 'ok-balance.ru')->first();
|
||||
expect($project->preflight_blocked_at)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns 409 on update when increased limit overloads balance', function () {
|
||||
// существующий проект на 15 лидов, всё ок (capacity 20).
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 15,
|
||||
]);
|
||||
|
||||
// UPDATE до 30 → suma 30 > capacity 20 → 409.
|
||||
$response = $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [
|
||||
'daily_limit_target' => 30,
|
||||
]);
|
||||
|
||||
$response->assertStatus(409);
|
||||
$response->assertJsonPath('error', 'balance_insufficient');
|
||||
expect($project->fresh()->daily_limit_target)->toBe(15); // не изменилось
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
it('sums daily_limit_target of active projects for required leads', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 10]);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 15]);
|
||||
Project::factory()->for($tenant)->create(['is_active' => false, 'daily_limit_target' => 100]); // не считается
|
||||
|
||||
expect($tenant->requiredLeadsForTomorrow())->toBe(25);
|
||||
});
|
||||
|
||||
it('casts frozen_by_balance_at to datetime', function () {
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => now()]);
|
||||
expect($tenant->frozen_by_balance_at)->toBeInstanceOf(Carbon::class);
|
||||
});
|
||||
|
||||
it('casts project preflight_blocked_at to datetime', function () {
|
||||
$project = Project::factory()->create(['preflight_blocked_at' => now()]);
|
||||
expect($project->preflight_blocked_at)->toBeInstanceOf(Carbon::class);
|
||||
});
|
||||
@@ -12,7 +12,9 @@ use Illuminate\Console\Scheduling\Schedule;
|
||||
* Session refresh — на 15 мин раньше sync (17:45).
|
||||
*/
|
||||
|
||||
it('SyncSupplierProjectsJob is scheduled at 18:00 MSK', function (): void {
|
||||
it('SyncSupplierProjectsJob is scheduled at 18:05 MSK (after billing:preflight-sweep @18:00)', function (): void {
|
||||
// Billing v2 Spec C §3.8: sync сдвинут с 18:00 на 18:05, чтобы
|
||||
// billing:preflight-sweep (18:00) успел проставить frozen-флаги.
|
||||
$schedule = app(Schedule::class);
|
||||
$events = collect($schedule->events());
|
||||
|
||||
@@ -20,7 +22,7 @@ it('SyncSupplierProjectsJob is scheduled at 18:00 MSK', function (): void {
|
||||
|| str_contains((string) $e->command, 'SyncSupplierProjectsJob'));
|
||||
|
||||
expect($sync)->not->toBeNull();
|
||||
expect($sync->expression)->toBe('0 18 * * *');
|
||||
expect($sync->expression)->toBe('5 18 * * *');
|
||||
expect($sync->timezone)->toBe('Europe/Moscow');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Supplier\SyncSupplierProjectsJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
// Изоляция: persistent liderra_testing, RefreshDatabase off. DatabaseTransactions
|
||||
// откатывает pgsql после теста; SharesSupplierPdo делает pgsql_supplier общим PDO
|
||||
// с pgsql, иначе query через Project::on('pgsql_supplier') не видит uncommitted
|
||||
// tenant/project (паттерн Спека B / AutoPauseFlowTest).
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
it('excludes projects of frozen tenants from supplier order', function () {
|
||||
$frozenTenant = Tenant::factory()->create(['frozen_by_balance_at' => now()]);
|
||||
Project::factory()->for($frozenTenant)->create(['is_active' => true, 'daily_limit_target' => 50]);
|
||||
|
||||
$activeTenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
Project::factory()->for($activeTenant)->create(['is_active' => true, 'daily_limit_target' => 30]);
|
||||
|
||||
$eligible = app(SyncSupplierProjectsJob::class)->collectEligibleProjects();
|
||||
|
||||
$tenantIds = $eligible->pluck('tenant_id')->unique()->all();
|
||||
expect($tenantIds)->not->toContain($frozenTenant->id);
|
||||
expect($tenantIds)->toContain($activeTenant->id);
|
||||
});
|
||||
|
||||
it('excludes individually preflight-blocked projects', function () {
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
$okProject = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 30, 'preflight_blocked_at' => null]);
|
||||
$blocked = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 20, 'preflight_blocked_at' => now()]);
|
||||
|
||||
$eligible = app(SyncSupplierProjectsJob::class)->collectEligibleProjects();
|
||||
|
||||
$ids = $eligible->pluck('id')->all();
|
||||
expect($ids)->toContain($okProject->id);
|
||||
expect($ids)->not->toContain($blocked->id);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import BalanceCapacityIndicator from '../../resources/js/components/billing/BalanceCapacityIndicator.vue';
|
||||
|
||||
// Billing v2 Spec C Task 1.10 — подсказка под балансом (§3.6). Чистый
|
||||
// presentational-компонент: статус по соотношению ёмкости и требуемого/день.
|
||||
|
||||
describe('BalanceCapacityIndicator', () => {
|
||||
it('зелёный, когда ёмкости хватает на 3+ дня', () => {
|
||||
const w = mount(BalanceCapacityIndicator, {
|
||||
props: { balanceRub: '3000.00', capacityLeads: 90, requiredLeadsPerDay: 25 },
|
||||
});
|
||||
expect(w.text()).toContain('90');
|
||||
expect(w.classes()).toContain('capacity-ok');
|
||||
});
|
||||
|
||||
it('жёлтый, когда ёмкости хватает меньше чем на 3 дня', () => {
|
||||
const w = mount(BalanceCapacityIndicator, {
|
||||
props: { balanceRub: '1000.00', capacityLeads: 30, requiredLeadsPerDay: 25 },
|
||||
});
|
||||
expect(w.classes()).toContain('capacity-warning');
|
||||
});
|
||||
|
||||
it('красный, когда ёмкости меньше требуемого', () => {
|
||||
const w = mount(BalanceCapacityIndicator, {
|
||||
props: { balanceRub: '500.00', capacityLeads: 10, requiredLeadsPerDay: 25 },
|
||||
});
|
||||
expect(w.classes()).toContain('capacity-insufficient');
|
||||
});
|
||||
|
||||
it('зелёный (бесконечный запас), когда проекты ничего не заказывают', () => {
|
||||
const w = mount(BalanceCapacityIndicator, {
|
||||
props: { balanceRub: '500.00', capacityLeads: 10, requiredLeadsPerDay: 0 },
|
||||
});
|
||||
expect(w.classes()).toContain('capacity-ok');
|
||||
});
|
||||
|
||||
it('показывает баланс, ёмкость и дневной заказ', () => {
|
||||
const w = mount(BalanceCapacityIndicator, {
|
||||
props: { balanceRub: '1000.00', capacityLeads: 30, requiredLeadsPerDay: 25 },
|
||||
});
|
||||
expect(w.text()).toContain('1000.00');
|
||||
expect(w.text()).toContain('30');
|
||||
expect(w.text()).toContain('25');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import BalanceFrozenBanner from '../../resources/js/components/billing/BalanceFrozenBanner.vue';
|
||||
|
||||
// Billing v2 Spec C Task 1.10 — красный баннер заморозки (§3.6). Показывается
|
||||
// на всех страницах, когда tenant.frozen_by_balance_at !== null.
|
||||
|
||||
const routerLinkStub = {
|
||||
RouterLink: {
|
||||
props: ['to'],
|
||||
inheritAttrs: false,
|
||||
template: '<a v-bind="$attrs" :href="to"><slot /></a>',
|
||||
},
|
||||
};
|
||||
|
||||
const mountBanner = (props: { frozen: boolean; deficitRub?: string; deficitLeads?: number }) =>
|
||||
mount(BalanceFrozenBanner, {
|
||||
props,
|
||||
global: { plugins: [createVuetify()], stubs: routerLinkStub },
|
||||
});
|
||||
|
||||
describe('BalanceFrozenBanner', () => {
|
||||
it('не заморожен — баннер не рендерится', () => {
|
||||
const w = mountBanner({ frozen: false });
|
||||
expect(w.find('[data-testid="balance-frozen-banner"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('заморожен — красный баннер с текстом «приостановлен»', () => {
|
||||
const w = mountBanner({ frozen: true, deficitRub: '380.00', deficitLeads: 10 });
|
||||
const banner = w.find('[data-testid="balance-frozen-banner"]');
|
||||
expect(banner.exists()).toBe(true);
|
||||
expect(banner.text()).toContain('приостановлен');
|
||||
});
|
||||
|
||||
it('показывает дефицит в рублях и лидах', () => {
|
||||
const w = mountBanner({ frozen: true, deficitRub: '380.00', deficitLeads: 10 });
|
||||
const text = w.find('[data-testid="balance-frozen-banner"]').text();
|
||||
expect(text).toContain('380');
|
||||
expect(text).toContain('10');
|
||||
});
|
||||
|
||||
it('две ссылки — на /billing и на /projects', () => {
|
||||
const w = mountBanner({ frozen: true, deficitRub: '380.00', deficitLeads: 10 });
|
||||
expect(w.find('[data-testid="banner-topup-link"]').attributes('href')).toBe('/billing');
|
||||
expect(w.find('[data-testid="banner-projects-link"]').attributes('href')).toBe('/projects');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import BillingView from '../../resources/js/views/BillingView.vue';
|
||||
import * as billingApi from '../../resources/js/api/billing';
|
||||
@@ -31,13 +32,14 @@ function makeWallet(): Wallet {
|
||||
|
||||
describe('BillingView.vue', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.mocked(billingApi.getWallet).mockResolvedValue(makeWallet());
|
||||
});
|
||||
|
||||
const factory = () =>
|
||||
mount(BillingView, {
|
||||
global: {
|
||||
plugins: [vuetify],
|
||||
plugins: [vuetify, createPinia()],
|
||||
stubs: { TransactionsTable: true, InvoicesTable: true, ChargesTab: true, TopupDialog: true },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -104,4 +104,42 @@ describe('NewProjectDialog', () => {
|
||||
await flushPromises();
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/api/projects', expect.objectContaining({ regions: [82, 83] }));
|
||||
});
|
||||
|
||||
it('409 balance_insufficient → открывает диалог перегрузки; save-blocked пере-сабмитит с force_save_blocked', async () => {
|
||||
// Spec C §6.2 (Task 1.10): первый POST падает 409, второй (после выбора
|
||||
// «Сохранить и приостановить») уходит с force_save_blocked=true.
|
||||
(apiClient.post as ReturnType<typeof vi.fn>).mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 409,
|
||||
data: {
|
||||
error: 'balance_insufficient',
|
||||
current_balance_rub: '1000.00',
|
||||
current_capacity_leads: 30,
|
||||
would_be_required_leads: 50,
|
||||
deficit_leads: 20,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
// Проходим гейт обязательного региона через подтверждённую «Вся РФ».
|
||||
(wrapper.vm as unknown as { confirmVsyaRf: () => void }).confirmVsyaRf();
|
||||
await (wrapper.vm as unknown as { submit: () => Promise<void> }).submit();
|
||||
await flushPromises();
|
||||
|
||||
const overload = wrapper.find('[data-testid="overload-dialog"]');
|
||||
expect(overload.exists()).toBe(true);
|
||||
expect(overload.text()).toContain('20');
|
||||
|
||||
await wrapper.find('[data-testid="overload-save-blocked"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(2);
|
||||
expect(apiClient.post).toHaveBeenLastCalledWith(
|
||||
'/api/projects',
|
||||
expect.objectContaining({ force_save_blocked: true }),
|
||||
);
|
||||
expect(wrapper.emitted('saved')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import ProjectLimitOverloadDialog from '../../resources/js/components/projects/ProjectLimitOverloadDialog.vue';
|
||||
|
||||
// Billing v2 Spec C Task 1.10 — диалог перегрузки лимита (§6.2). Принимает
|
||||
// 409-payload `balance_insufficient`, эмитит решение пользователя.
|
||||
|
||||
const payload = {
|
||||
current_balance_rub: '1000.00',
|
||||
current_capacity_leads: 30,
|
||||
would_be_required_leads: 50,
|
||||
deficit_leads: 20,
|
||||
};
|
||||
|
||||
const mountDialog = () =>
|
||||
mount(ProjectLimitOverloadDialog, {
|
||||
props: { modelValue: true, payload },
|
||||
global: {
|
||||
plugins: [createVuetify()],
|
||||
stubs: { VDialog: { template: '<div><slot /></div>' } },
|
||||
},
|
||||
});
|
||||
|
||||
describe('ProjectLimitOverloadDialog', () => {
|
||||
it('рендерит данные 409-payload', () => {
|
||||
const w = mountDialog();
|
||||
const text = w.text();
|
||||
expect(text).toContain('1000.00');
|
||||
expect(text).toContain('30');
|
||||
expect(text).toContain('50');
|
||||
expect(text).toContain('20');
|
||||
});
|
||||
|
||||
it('«Сохранить и приостановить» эмитит save-blocked', async () => {
|
||||
const w = mountDialog();
|
||||
await w.find('[data-testid="overload-save-blocked"]').trigger('click');
|
||||
expect(w.emitted('save-blocked')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('«Поставить лимит 0» эмитит set-zero', async () => {
|
||||
const w = mountDialog();
|
||||
await w.find('[data-testid="overload-set-zero"]').trigger('click');
|
||||
expect(w.emitted('set-zero')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('«Отмена» закрывает диалог (update:modelValue=false)', async () => {
|
||||
const w = mountDialog();
|
||||
await w.find('[data-testid="overload-cancel"]').trigger('click');
|
||||
expect(w.emitted('update:modelValue')?.[0]).toEqual([false]);
|
||||
});
|
||||
|
||||
it('payload=null — без падения, ничего не рендерит', () => {
|
||||
const w = mount(ProjectLimitOverloadDialog, {
|
||||
props: { modelValue: true, payload: null },
|
||||
global: { plugins: [createVuetify()], stubs: { VDialog: { template: '<div><slot /></div>' } } },
|
||||
});
|
||||
expect(w.find('[data-testid="overload-save-blocked"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\PricingTier;
|
||||
use App\Services\Billing\BalancePreflightService;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
function makeTiers(): Collection
|
||||
{
|
||||
// 1 ступень: цена 50₽/лид (5000 копеек), безлимитная.
|
||||
return new Collection([
|
||||
new PricingTier(['tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 5000, 'is_active' => true]),
|
||||
]);
|
||||
}
|
||||
|
||||
it('passes when balance covers full daily limit', function () {
|
||||
$service = new BalancePreflightService;
|
||||
// 2000₽ / 50₽ = 40 лидов capacity; нужно 30 → проходит.
|
||||
$result = $service->evaluate(
|
||||
balanceRub: '2000.00',
|
||||
deliveredInMonth: 0,
|
||||
requiredLeads: 30,
|
||||
tiers: makeTiers(),
|
||||
);
|
||||
|
||||
expect($result->passes)->toBeTrue();
|
||||
expect($result->capacityLeads)->toBe(40);
|
||||
expect($result->requiredLeads)->toBe(30);
|
||||
expect($result->deficitLeads)->toBe(0);
|
||||
});
|
||||
|
||||
it('fails when balance below full daily limit', function () {
|
||||
$service = new BalancePreflightService;
|
||||
// 1000₽ / 50₽ = 20 лидов capacity; нужно 30 → не проходит, дефицит 10.
|
||||
$result = $service->evaluate(
|
||||
balanceRub: '1000.00',
|
||||
deliveredInMonth: 0,
|
||||
requiredLeads: 30,
|
||||
tiers: makeTiers(),
|
||||
);
|
||||
|
||||
expect($result->passes)->toBeFalse();
|
||||
expect($result->capacityLeads)->toBe(20);
|
||||
expect($result->deficitLeads)->toBe(10);
|
||||
});
|
||||
|
||||
it('passes trivially when requiredLeads is zero', function () {
|
||||
$service = new BalancePreflightService;
|
||||
$result = $service->evaluate('0.00', 0, 0, makeTiers());
|
||||
expect($result->passes)->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
-- =============================================================================
|
||||
-- balance_freeze_log — журнал заморозок/разморозок тенантов по балансу
|
||||
-- (Billing v2 Spec C). INSERT-only таблица: записи не обновляются, только
|
||||
-- добавляются. Используется PreflightBalanceService для аудит-трейла.
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS balance_freeze_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
|
||||
event_type VARCHAR(30) NOT NULL,
|
||||
triggered_by VARCHAR(30) NOT NULL,
|
||||
balance_rub_at_event DECIMAL(12,2) NOT NULL,
|
||||
required_leads INTEGER NOT NULL,
|
||||
capacity_leads INTEGER NOT NULL,
|
||||
total_daily_limit INTEGER NOT NULL,
|
||||
details JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE balance_freeze_log ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON balance_freeze_log
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS balance_freeze_log_tenant_idx ON balance_freeze_log (tenant_id, created_at DESC);
|
||||
|
||||
-- Флаги заморозки на tenants и projects
|
||||
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS frozen_by_balance_at TIMESTAMPTZ NULL;
|
||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS preflight_blocked_at TIMESTAMPTZ NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS tenants_frozen_by_balance_idx ON tenants (frozen_by_balance_at) WHERE frozen_by_balance_at IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS projects_preflight_blocked_idx ON projects (preflight_blocked_at) WHERE preflight_blocked_at IS NOT NULL;
|
||||
|
||||
-- Явные GRANT'ы для 4 ролей: на prod таблица создаётся через pgsql_supplier
|
||||
-- (default privileges от postgres-superuser не наследуются на чужие creator-role).
|
||||
-- Mirror supplier_lead_deliveries grant pattern. DO block — idempotent + dev-safe
|
||||
-- (на dev ролей нет → silent skip). balance_freeze_log: SELECT+INSERT только
|
||||
-- (INSERT-only по дизайну — не UPDATE, не DELETE).
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_app_user;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_app_user;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_supplier_worker;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_supplier_worker;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_migrator;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_migrator;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_admin_user;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_admin_user;
|
||||
END IF;
|
||||
END $$;
|
||||
+54
-1
@@ -677,12 +677,17 @@ CREATE TABLE tenants (
|
||||
-- Метаданные
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ -- soft delete (раздел 4.5)
|
||||
deleted_at TIMESTAMPTZ, -- soft delete (раздел 4.5)
|
||||
-- v8.35 (Billing v2 Spec C): флаг заморозки по балансу.
|
||||
-- Не NULL = тенант заморожен (баланс < стоимость дня лидов). PreflightBalanceService.
|
||||
frozen_by_balance_at TIMESTAMPTZ NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tenants_subdomain ON tenants(subdomain) WHERE deleted_at IS NULL;
|
||||
-- idx_tenants_webhook_token удалён в v8.35 (legacy direct webhook removal)
|
||||
CREATE INDEX idx_tenants_inactive ON tenants(last_activity_at) WHERE deleted_at IS NULL;
|
||||
-- v8.35: частичный индекс для заморозки (sparse — большинство тенантов не заморожены)
|
||||
CREATE INDEX tenants_frozen_by_balance_idx ON tenants (frozen_by_balance_at) WHERE frozen_by_balance_at IS NOT NULL;
|
||||
|
||||
-- Forward FK на tenants для SaaS-админских таблиц, объявленных выше
|
||||
-- (saas_admin_sessions.impersonating_tenant_id — Ю-1; impersonation_tokens.tenant_id).
|
||||
@@ -853,6 +858,9 @@ CREATE TABLE projects (
|
||||
CHECK (ttfr_target_minutes BETWEEN 1 AND 1440),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
-- v8.35 (Billing v2 Spec C): флаг точечной блокировки проекта по preflight.
|
||||
-- Не NULL = проект заблокирован (не хватает баланса на ближайшие N лидов).
|
||||
preflight_blocked_at TIMESTAMPTZ NULL,
|
||||
UNIQUE (tenant_id, name),
|
||||
CONSTRAINT chk_projects_daily_limit_positive
|
||||
CHECK (daily_limit_target > 0),
|
||||
@@ -886,6 +894,8 @@ CREATE INDEX idx_projects_tenant_signal
|
||||
CREATE INDEX idx_projects_regions ON projects USING GIN (regions);
|
||||
-- v8.38: индекс для SupplierSnapshotGuard grace-проверки.
|
||||
CREATE INDEX projects_paused_at_idx ON projects(paused_at);
|
||||
-- v8.35: частичный индекс для preflight-блокировки (sparse — большинство проектов не заблокированы)
|
||||
CREATE INDEX projects_preflight_blocked_idx ON projects (preflight_blocked_at) WHERE preflight_blocked_at IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN projects.daily_limit_target IS
|
||||
'Целевой дневной лимит лидов, заданный клиентом. Фактический лимит на '
|
||||
@@ -3325,6 +3335,49 @@ ALTER TABLE lead_charges
|
||||
DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- balance_freeze_log — журнал заморозок/разморозок тенантов по балансу
|
||||
-- (Billing v2 Spec C, v8.35). INSERT-only: записи не обновляются.
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS balance_freeze_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
|
||||
event_type VARCHAR(30) NOT NULL,
|
||||
triggered_by VARCHAR(30) NOT NULL,
|
||||
balance_rub_at_event DECIMAL(12,2) NOT NULL,
|
||||
required_leads INTEGER NOT NULL,
|
||||
capacity_leads INTEGER NOT NULL,
|
||||
total_daily_limit INTEGER NOT NULL,
|
||||
details JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE balance_freeze_log ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON balance_freeze_log
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint);
|
||||
|
||||
CREATE INDEX balance_freeze_log_tenant_idx ON balance_freeze_log (tenant_id, created_at DESC);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_app_user;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_app_user;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_supplier_worker;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_supplier_worker;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_migrator;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_migrator;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_admin') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_app_admin;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_app_admin;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- КОНЕЦ schema.sql
|
||||
-- =============================================================================
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Биллинг v2 Спек C — Префлайт баланса + VTB-эквайринг — План реализации
|
||||
# Биллинг v2 Спек C — Преfflight баланса + VTB-эквайринг — План реализации
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Pravila §15.1** — git-коммит задачи только Sonnet/Opus субагентами (не Haiku); контроллер верифицирует `git rev-parse HEAD` после каждого субагента.
|
||||
|
||||
**Goal:** Защитить портал от заказа лидов у поставщика на клиентов без баланса (префлайт) + дать реальное пополнение баланса через безнал (PDF-счёт + сверка) с архитектурой под СБП/карты VTB.
|
||||
**Goal:** Защитить портал от заказа лидов у поставщика на клиентов без баланса (преfflight) + дать реальное пополнение баланса через безнал (PDF-счёт + сверка) с архитектурой под СБП/карты VTB.
|
||||
|
||||
**Architecture:** Префлайт — фильтр eligible-проектов до формулы `computeOrder` (формула не меняется) + флаги заморозки на tenant/project + ежедневный cron 18:00 MSK. VTB — интерфейс `TopupGatewayInterface` + 3 реализации (безнал полный, СБП/карты dev-заглушки) + журнал `topup_sessions`.
|
||||
**Architecture:** Преfflight — фильтр eligible-проектов до формулы `computeOrder` (формула не меняется) + флаги заморозки на tenant/project + ежедневный cron 18:00 MSK. VTB — интерфейс `TopupGatewayInterface` + 3 реализации (безнал полный, СБП/карты dev-заглушки) + журнал `topup_sessions`.
|
||||
|
||||
**Tech Stack:** PHP 8.3 / Laravel 13, PostgreSQL 16 (RLS), Pest 4, Vue 3 + Vuetify 3, Vitest, barryvdh/laravel-dompdf (новый).
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
**Что уже есть на `origin/main` (зависимости):**
|
||||
|
||||
- `App\Services\Billing\BalanceToLeadsConverter::convert(string $balanceRub, int $deliveredInMonth, Collection $tiers): array` — возвращает `['leads' => int, 'breakdown' => [...], 'current_tier' => ..., 'next_tier' => ...]`. Pure, bcmath. Используется в префлайт для «сколько лидов даёт баланс».
|
||||
- `App\Services\Billing\BalanceToLeadsConverter::convert(string $balanceRub, int $deliveredInMonth, Collection $tiers): array` — возвращает `['leads' => int, 'breakdown' => [...], 'current_tier' => ..., 'next_tier' => ...]`. Pure, bcmath. Используется в преfflight для «сколько лидов даёт баланс».
|
||||
- `App\Services\Billing\PricingTierResolver` — резолвер ступени по порядковому номеру лида.
|
||||
- `App\Services\Billing\LedgerService` — списания (не трогаем).
|
||||
- `App\Services\Billing\BillingTopupService::topup(int $tenantId, string $amountRub, ?int $userId)` — MVP-stub пополнения (рефакторим).
|
||||
@@ -72,12 +72,11 @@ Expected: миграции прошли, converter-тесты GREEN (завис
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Префлайт баланса
|
||||
## Phase 1 — Преfflight баланса
|
||||
|
||||
### Task 1.1: Миграция БД — флаги заморозки + журнал
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/database/migrations/2026_05_24_100000_add_balance_freeze_to_tenants_and_projects.php`
|
||||
- Create: `db/migrations/2026_05_24_balance_freeze_log.sql` (для prod-синка вручную, как в Спеке B)
|
||||
- Modify: `db/schema.sql` (добавить колонки + таблицу + запись в `db/CHANGELOG_schema.md`)
|
||||
@@ -173,7 +172,6 @@ git commit -m "feat(billing-v2-c): миграция — флаги заморо
|
||||
### Task 1.2: `BalancePreflightService` — pure-проверка платёжеспособности
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Services/Billing/BalancePreflightService.php`
|
||||
- Create: `app/app/Services/Billing/PreflightResult.php`
|
||||
- Test: `app/tests/Unit/Billing/BalancePreflightServiceTest.php`
|
||||
@@ -272,7 +270,7 @@ namespace App\Services\Billing;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
/**
|
||||
* Pure: проходит ли клиент префлайт — хватает ли баланса на ПОЛНЫЙ дневной
|
||||
* Pure: проходит ли клиент преfflight — хватает ли баланса на ПОЛНЫЙ дневной
|
||||
* лимит всех его eligible-проектов по текущему тарифу.
|
||||
*
|
||||
* Сравнение в ЛИДАХ (capacity vs required), не в рублях — переиспользует
|
||||
@@ -330,7 +328,6 @@ git commit -m "feat(billing-v2-c): BalancePreflightService — pure-провер
|
||||
### Task 1.3: Tenant/Project — хелперы платёжеспособности + резолвер eligible-лимита
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Models/Tenant.php` (метод `requiredLeadsForTomorrow()`, scope, casts флага)
|
||||
- Modify: `app/app/Models/Project.php` (cast `preflight_blocked_at`)
|
||||
- Test: `app/tests/Feature/Billing/TenantPreflightTest.php`
|
||||
@@ -372,7 +369,7 @@ Expected: FAIL — метод `requiredLeadsForTomorrow` не существуе
|
||||
```php
|
||||
/**
|
||||
* Сумма daily_limit активных проектов — «сколько лидов клиент хочет в день».
|
||||
* Используется префлайт'ом как requiredLeads.
|
||||
* Используется преfflight'ом как requiredLeads.
|
||||
*/
|
||||
public function requiredLeadsForTomorrow(): int
|
||||
{
|
||||
@@ -401,7 +398,6 @@ git commit -m "feat(billing-v2-c): Tenant::requiredLeadsForTomorrow + cast фл
|
||||
### Task 1.4: `BalancePreflightSweepJob` — ежедневный пересчёт заморозок
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Jobs/Billing/BalancePreflightSweepJob.php`
|
||||
- Create: `app/app/Console/Commands/BillingPreflightSweepCommand.php`
|
||||
- Modify: `app/routes/console.php` (Schedule @18:00 MSK + heartbeat)
|
||||
@@ -489,7 +485,7 @@ use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Ежедневный префлайт всех тенантов перед формированием заказа поставщику.
|
||||
* Ежедневный преfflight всех тенантов перед формированием заказа поставщику.
|
||||
* Запускается cron @18:00 MSK (routes/console.php). См. спек §3.5, §5.2.
|
||||
*
|
||||
* NB: crm_supplier_worker / системный контекст — джоб бегает без tenant-RLS;
|
||||
@@ -578,12 +574,12 @@ final class BillingPreflightSweepCommand extends Command
|
||||
{
|
||||
protected $signature = 'billing:preflight-sweep';
|
||||
|
||||
protected $description = 'Ежедневный префлайт баланса — заморозка/разморозка тенантов (cut-off 18:00 MSK)';
|
||||
protected $description = 'Ежедневный преfflight баланса — заморозка/разморозка тенантов (cut-off 18:00 MSK)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
(new BalancePreflightSweepJob())->handle();
|
||||
$this->info('Префлайт sweep завершён.');
|
||||
$this->info('Преfflight sweep завершён.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
@@ -595,7 +591,7 @@ final class BillingPreflightSweepCommand extends Command
|
||||
Добавить после существующих задач (с heartbeat-обёрткой по образцу `projects:reset-delivered-today`):
|
||||
|
||||
```php
|
||||
// Спек C §3.2: префлайт баланса в 18:00 MSK — формирование заказа поставщику без «бедных» клиентов.
|
||||
// Спек C §3.2: преfflight баланса в 18:00 MSK — формирование заказа поставщику без «бедных» клиентов.
|
||||
Schedule::command('billing:preflight-sweep')
|
||||
->dailyAt('18:00')
|
||||
->timezone('Europe/Moscow')
|
||||
@@ -629,7 +625,6 @@ git commit -m "feat(billing-v2-c): BalancePreflightSweepJob + cron 18:00 MSK"
|
||||
### Task 1.5: Mailable — 4 письма заморозки/разморозки
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Mail/BalanceFrozenMail.php` + `app/app/Mail/BalanceFrozenReminderMail.php` + `app/app/Mail/BalanceFrozenFinalMail.php` + `app/app/Mail/BalanceUnfrozenMail.php`
|
||||
- Create: 4 blade-шаблона в `app/resources/views/emails/billing/`
|
||||
- Test: `app/tests/Feature/Billing/BalanceFreezeMailTest.php`
|
||||
@@ -751,7 +746,6 @@ git commit -m "feat(billing-v2-c): 4 Mailable заморозки/разморо
|
||||
### Task 1.6: Повторные письма — reminder через 1 день + final через 3 дня
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Jobs/Billing/BalanceFrozenReminderJob.php`
|
||||
- Modify: `app/routes/console.php` (Schedule daily + heartbeat)
|
||||
- Test: `app/tests/Feature/Billing/BalanceFrozenReminderJobTest.php`
|
||||
@@ -894,10 +888,9 @@ git commit -m "feat(billing-v2-c): повторные письма заморо
|
||||
|
||||
---
|
||||
|
||||
### Task 1.7: `ProjectController` — префлайт при создании/правке проекта
|
||||
### Task 1.7: `ProjectController` — преfflight при создании/правке проекта
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/ProjectController.php` (store + update — 409 при перегрузке)
|
||||
- Test: `app/tests/Feature/Project/ProjectPreflightTest.php`
|
||||
|
||||
@@ -965,12 +958,12 @@ it('creates normally when within balance', function () {
|
||||
Run: `cd app && php artisan test --filter=ProjectPreflightTest`
|
||||
Expected: FAIL — 409 не возвращается (создаёт нормально).
|
||||
|
||||
- [ ] **Step 3: Реализовать префлайт-проверку в `ProjectController::store`** (и аналогично `update`)
|
||||
- [ ] **Step 3: Реализовать преfflight-проверку в `ProjectController::store`** (и аналогично `update`)
|
||||
|
||||
Вставить ПОСЛЕ валидации, ДО создания проекта:
|
||||
|
||||
```php
|
||||
// Спек C §3.4: префлайт при создании — если сумма лимитов перевешивает баланс.
|
||||
// Спек C §3.4: преfflight при создании — если сумма лимитов перевешивает баланс.
|
||||
$tenant = $request->user()->tenant;
|
||||
$tiers = \App\Models\PricingTier::query()->where('is_active', true)->get();
|
||||
$service = new \App\Services\Billing\BalancePreflightService();
|
||||
@@ -1012,7 +1005,7 @@ Expected: PASS (3 теста).
|
||||
|
||||
```bash
|
||||
git add app/app/Http/Controllers/Api/ProjectController.php app/tests/Feature/Project/ProjectPreflightTest.php
|
||||
git commit -m "feat(billing-v2-c): ProjectController префлайт — 409 при перегрузке баланса"
|
||||
git commit -m "feat(billing-v2-c): ProjectController преfflight — 409 при перегрузке баланса"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -1020,7 +1013,6 @@ git commit -m "feat(billing-v2-c): ProjectController префлайт — 409 п
|
||||
### Task 1.8: Фильтр frozen-проектов в `SyncSupplierProjectsJob`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` (исключить frozen перед allocator)
|
||||
- Test: `app/tests/Feature/Supplier/SyncSupplierPreflightFilterTest.php`
|
||||
|
||||
@@ -1102,7 +1094,6 @@ git commit -m "feat(billing-v2-c): SyncSupplierProjectsJob исключает fr
|
||||
### Task 1.9: One-time команда `billing:preflight-initial-sweep`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Console/Commands/BillingPreflightInitialSweepCommand.php`
|
||||
- Test: `app/tests/Feature/Billing/BillingPreflightInitialSweepTest.php`
|
||||
|
||||
@@ -1149,18 +1140,18 @@ use App\Jobs\Billing\BalancePreflightSweepJob;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* One-time: при выкатке префлайт прогнать всех тенантов и заморозить
|
||||
* One-time: при выкатке преfflight прогнать всех тенантов и заморозить
|
||||
* недофинансированных. Запускается ОДИН раз вручную после миграции. Спек §3.9.
|
||||
*/
|
||||
final class BillingPreflightInitialSweepCommand extends Command
|
||||
{
|
||||
protected $signature = 'billing:preflight-initial-sweep';
|
||||
|
||||
protected $description = 'Разовый префлайт при внедрении — заморозить недофинансированных тенантов';
|
||||
protected $description = 'Разовый преfflight при внедрении — заморозить недофинансированных тенантов';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->warn('Разовый префлайт всех тенантов. Запускать ОДИН раз после выкатки.');
|
||||
$this->warn('Разовый преfflight всех тенантов. Запускать ОДИН раз после выкатки.');
|
||||
(new BalancePreflightSweepJob())->handle();
|
||||
$this->info('Initial sweep завершён.');
|
||||
|
||||
@@ -1186,7 +1177,6 @@ git commit -m "feat(billing-v2-c): one-time billing:preflight-initial-sweep"
|
||||
### Task 1.10: Frontend — баннер заморозки + индикатор ёмкости + диалог перегрузки
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/resources/js/components/billing/BalanceFrozenBanner.vue`
|
||||
- Create: `app/resources/js/components/billing/BalanceCapacityIndicator.vue`
|
||||
- Create: `app/resources/js/components/projects/ProjectLimitOverloadDialog.vue`
|
||||
@@ -1281,7 +1271,7 @@ Expected: PASS.
|
||||
|
||||
```bash
|
||||
git add app/resources/js/components/billing/ app/resources/js/components/projects/ProjectLimitOverloadDialog.vue app/resources/js/layouts/AppLayout.vue
|
||||
git commit -m "feat(billing-v2-c): UI префлайт — баннер, индикатор ёмкости, диалог перегрузки"
|
||||
git commit -m "feat(billing-v2-c): UI преfflight — баннер, индикатор ёмкости, диалог перегрузки"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -1291,7 +1281,6 @@ git commit -m "feat(billing-v2-c): UI префлайт — баннер, инд
|
||||
### Task 2.1: Миграция БД — реквизиты тенанта + topup_sessions
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/database/migrations/2026_05_24_110000_add_legal_entity_and_topup_sessions.php`
|
||||
- Create: `db/migrations/2026_05_24_topup_sessions.sql` (prod-синк)
|
||||
- Modify: `db/schema.sql` + `db/CHANGELOG_schema.md`
|
||||
@@ -1388,7 +1377,6 @@ git commit -m "feat(billing-v2-c): миграция — реквизиты юр.
|
||||
### Task 2.2: Установить PDF-генератор
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/composer.json` (+ `barryvdh/laravel-dompdf`)
|
||||
|
||||
- [ ] **Step 1: Установить пакет**
|
||||
@@ -1413,7 +1401,6 @@ git commit -m "chore(billing-v2-c): + barryvdh/laravel-dompdf для счето
|
||||
### Task 2.3: Модель TopupSession + `TopupGatewayInterface` + `BankTransferGateway`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Models/TopupSession.php`
|
||||
- Create: `app/app/Services/Billing/Gateway/TopupGatewayInterface.php`
|
||||
- Create: `app/app/Services/Billing/Gateway/TopupSessionResult.php`
|
||||
@@ -1617,7 +1604,6 @@ git commit -m "feat(billing-v2-c): TopupSession + TopupGatewayInterface + BankTr
|
||||
### Task 2.4: PDF-счёт (Blade-шаблон + endpoint)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/resources/views/pdf/invoice.blade.php`
|
||||
- Modify: `app/app/Http/Controllers/Api/BillingController.php` (+ `invoicePdf`)
|
||||
- Modify: `app/routes/web.php` (route)
|
||||
@@ -1712,7 +1698,6 @@ git commit -m "feat(billing-v2-c): PDF-счёт на пополнение + RLS-
|
||||
### Task 2.5: Рефакторинг `BillingTopupService` под gateway + `confirmPayment`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Services/Billing/BillingTopupService.php`
|
||||
- Test: `app/tests/Feature/Billing/BillingTopupConfirmTest.php`
|
||||
|
||||
@@ -1812,7 +1797,7 @@ public function confirmPayment(string $providerRef, ?int $adminUserId): ?Balance
|
||||
Run: `cd app && php artisan test --filter=BillingTopupConfirmTest`
|
||||
Expected: PASS (2 теста).
|
||||
|
||||
- [ ] **Step 5: Связка с префлайт — после confirm перепроверить заморозку**
|
||||
- [ ] **Step 5: Связка с преfflight — после confirm перепроверить заморозку**
|
||||
|
||||
Добавить в конце `confirmPayment` (после коммита транзакции, или внутри): если `tenant.frozen_by_balance_at !== null` — прогнать `BalancePreflightService::evaluate`, и если теперь проходит — снять заморозку + `BalanceUnfrozenMail` + лог. Написать доп. тест `it('unfreezes tenant on topup confirm')`.
|
||||
|
||||
@@ -1828,7 +1813,6 @@ git commit -m "feat(billing-v2-c): BillingTopupService::confirmPayment + авт
|
||||
### Task 2.6: `VtbStatementSyncJob` (авто-поиск) + dev-симулятор
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Jobs/Billing/VtbStatementSyncJob.php`
|
||||
- Create: `app/app/Console/Commands/VtbStatementSimulateCommand.php` (dev only)
|
||||
- Create: `app/app/Services/Billing/VtbBusinessClient.php` (интерфейс + null/dev реализация)
|
||||
@@ -1907,7 +1891,6 @@ git commit -m "feat(billing-v2-c): VtbStatementSyncJob авто-поиск пл
|
||||
### Task 2.7: Админка — журнал ожидающих платежей + подтверждение + настройка режима
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Http/Controllers/Api/Admin/PendingTopupsController.php`
|
||||
- Create: `app/app/Http/Controllers/Api/Admin/BillingSettingsController.php`
|
||||
- Modify: `app/routes/web.php` (admin routes)
|
||||
@@ -1981,7 +1964,6 @@ git commit -m "feat(billing-v2-c): админка ожидающих плате
|
||||
### Task 2.8: `NotifyPendingConfirmationsJob` — часовой алерт админу
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Jobs/Billing/NotifyPendingConfirmationsJob.php`
|
||||
- Create: `app/app/Mail/PendingConfirmationsAdminMail.php` + шаблон
|
||||
- Modify: `app/routes/console.php` (hourly + heartbeat)
|
||||
@@ -2052,7 +2034,6 @@ git commit -m "feat(billing-v2-c): часовой алерт админу о н
|
||||
### Task 2.9: Frontend — пополнение (выбор метода + безнал) + реквизиты + админка
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/resources/js/views/billing/TopupView.vue`
|
||||
- Create: `app/resources/js/components/billing/TopupMethodPicker.vue`
|
||||
- Create: `app/resources/js/components/billing/BankTransferInvoiceView.vue`
|
||||
@@ -2103,7 +2084,6 @@ git commit -m "feat(billing-v2-c): UI пополнения (безнал) + ре
|
||||
### Task 3.1: `SbpGateway` (dev-режим) + фоновое авто-подтверждение
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Services/Billing/Gateway/SbpGateway.php`
|
||||
- Create: `app/app/Jobs/Billing/DevAutoConfirmSessionJob.php`
|
||||
- Test: `app/tests/Feature/Billing/SbpGatewayTest.php`
|
||||
@@ -2204,7 +2184,6 @@ git commit -m "feat(billing-v2-c): SbpGateway dev-заглушка + авто-п
|
||||
### Task 3.2: `CardGateway` (dev-режим) + dev-mock страница эквайринга
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Services/Billing/Gateway/CardGateway.php`
|
||||
- Create: `app/resources/js/views/dev/DevMockAcquiringView.vue` (только dev)
|
||||
- Modify: router (dev-only route `/dev-mock-vtb-acquiring/:sessionId`)
|
||||
@@ -2262,7 +2241,6 @@ git commit -m "feat(billing-v2-c): CardGateway dev-заглушка + mock-ст
|
||||
### Task 3.3: `FiscalReceiptProvider` — архитектура 54-ФЗ
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Services/Billing/Fiscal/FiscalReceiptProvider.php` (interface)
|
||||
- Create: `app/app/Services/Billing/Fiscal/NoOpFiscalProvider.php`
|
||||
- Create: `app/app/Services/Billing/Fiscal/DevMockFiscalProvider.php`
|
||||
@@ -2356,7 +2334,6 @@ git commit -m "feat(billing-v2-c): архитектура 54-ФЗ — FiscalRece
|
||||
### Task 3.4: Подключить выбор gateway + fiscal в `BillingTopupService::initiateTopup`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Services/Billing/BillingTopupService.php` (resolveGateway + initiateTopup)
|
||||
- Modify: `app/app/Http/Controllers/Api/BillingController.php` (+ `initiateTopup` endpoint)
|
||||
- Modify: `app/routes/web.php`
|
||||
@@ -2421,10 +2398,9 @@ git commit -m "feat(billing-v2-c): initiateTopup — единый вход дл
|
||||
|
||||
## Phase 4 — End-to-end сценарии + регрессия
|
||||
|
||||
### Task 4.1: Pest E2E — префлайт (заморозка → пополнение → разморозка)
|
||||
### Task 4.1: Pest E2E — преfflight (заморозка → пополнение → разморозка)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/tests/Feature/Billing/PreflightE2ETest.php`
|
||||
|
||||
- [ ] **Step 1: Написать E2E-тест полного цикла**
|
||||
@@ -2453,7 +2429,7 @@ it('full cycle: sufficient → drained → frozen → topup → unfrozen', funct
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1250.00']); // 1250/50 = 25
|
||||
Project::factory()->for($tenant)->create(['status' => 'active', 'daily_limit' => 25]);
|
||||
|
||||
// Префлайт cut-off — проходит.
|
||||
// Преfflight cut-off — проходит.
|
||||
(new BalancePreflightSweepJob())->handle();
|
||||
expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
|
||||
|
||||
@@ -2486,7 +2462,7 @@ Expected: PASS. (Если FAIL — починить связку confirmPayment
|
||||
|
||||
```bash
|
||||
git add app/tests/Feature/Billing/PreflightE2ETest.php
|
||||
git commit -m "test(billing-v2-c): E2E префлайт — полный цикл заморозки/разморозки"
|
||||
git commit -m "test(billing-v2-c): E2E преfflight — полный цикл заморозки/разморозки"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -2494,7 +2470,6 @@ git commit -m "test(billing-v2-c): E2E префлайт — полный цик
|
||||
### Task 4.2: Pest E2E — безнал (счёт → выписка → подтверждение)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/tests/Feature/Billing/BankTransferE2ETest.php`
|
||||
|
||||
- [ ] **Step 1: Написать E2E-тест**
|
||||
@@ -2596,19 +2571,18 @@ Expected: 0 leaks, 0 broken links.
|
||||
|
||||
## Phase 5 — Документация + ADR + push
|
||||
|
||||
### Task 5.1: ADR-016 — архитектура префлайт + VTB
|
||||
### Task 5.1: ADR-016 — архитектура преfflight + VTB
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `docs/adr/016-preflight-vtb-architecture.md`
|
||||
|
||||
- [ ] **Step 1: Написать ADR** (Context — переплата поставщику + нет реального пополнения; Decision — префлайт как фильтр eligible до формулы + gateway-pattern для 3 методов + dev-заглушки до Б-1; Consequences — границы PF1..PFN; Status — Accepted). REQUIRED SUB-SKILL: `adr-kit:adr`.
|
||||
- [ ] **Step 1: Написать ADR** (Context — переплата поставщику + нет реального пополнения; Decision — преfflight как фильтр eligible до формулы + gateway-pattern для 3 методов + dev-заглушки до Б-1; Consequences — границы PF1..PFN; Status — Accepted). REQUIRED SUB-SKILL: `adr-kit:adr`.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/adr/016-preflight-vtb-architecture.md
|
||||
git commit -m "docs(adr): ADR-016 — префлайт баланса + VTB gateway-архитектура"
|
||||
git commit -m "docs(adr): ADR-016 — преfflight баланса + VTB gateway-архитектура"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -2616,13 +2590,12 @@ git commit -m "docs(adr): ADR-016 — префлайт баланса + VTB gate
|
||||
### Task 5.2: Обновить memory + нормативку (по необходимости)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `memory/project_billing_v2.md` (Спек C: статус, артефакты, что выкачено)
|
||||
- Modify: `memory/MEMORY.md` (одна строка-указатель, если меняется)
|
||||
|
||||
- [ ] **Step 1: Обновить `project_billing_v2.md`** — раздел «Спек C» из PENDING brainstorm → IMPLEMENTED (worktree, ветка, фазы, что dev-заглушки до Б-1, что pending). Через Write tool (это memory, не код).
|
||||
|
||||
- [ ] **Step 2: CLAUDE.md / Pravila / Tooling** — **проверить**, нужны ли правки. Префлайт/VTB — это фича портала, не новый инструмент тулчейна → **скорее всего нормативка не меняется**. Если нужно — через `/claude-md-management:claude-md-improver` (НЕ прямой Edit CLAUDE.md, кроме worktree-эксцепшна §5 п.10).
|
||||
- [ ] **Step 2: CLAUDE.md / Pravila / Tooling** — **проверить**, нужны ли правки. Преfflight/VTB — это фича портала, не новый инструмент тулчейна → **скорее всего нормативка не меняется**. Если нужно — через `/claude-md-management:claude-md-improver` (НЕ прямой Edit CLAUDE.md, кроме worktree-эксцепшна §5 п.10).
|
||||
|
||||
- [ ] **Step 3: Commit (если были правки)**
|
||||
|
||||
@@ -2645,7 +2618,7 @@ REQUIRED SUB-SKILL: `superpowers:finishing-a-development-branch`.
|
||||
|
||||
Варианты: PR в main (через `gh`) ИЛИ FF-merge в main (паттерн Спека B `git push origin feat/billing-v2-spec-c:main` через worktree). **Только после явного «выкатываем» от заказчика** (Pravila §2.2 — не закрывать без подтверждения).
|
||||
|
||||
- [ ] **Step 3: НЕ выкатывать на прод автоматически.** Боевой деплой VTB-части невозможен до Б-1 (нет реквизитов). Префлайт-часть можно выкатить — но **только по явному решению заказчика** + ручной прогон `billing:preflight-initial-sweep` на проде с предупреждением клиентам.
|
||||
- [ ] **Step 3: НЕ выкатывать на прод автоматически.** Боевой деплой VTB-части невозможен до Б-1 (нет реквизитов). Преfflight-часть можно выкатить — но **только по явному решению заказчика** + ручной прогон `billing:preflight-initial-sweep` на проде с предупреждением клиентам.
|
||||
|
||||
- [ ] **Step 4: Обновить `ПИЛОТ.md`** (если/когда выкачено на боевой liderra.ru).
|
||||
|
||||
@@ -2677,7 +2650,7 @@ REQUIRED SUB-SKILL: `superpowers:finishing-a-development-branch`.
|
||||
|
||||
## Заметки для исполнителя
|
||||
|
||||
**По §3.9 спека (граничные случаи):** главный инвариант «баланс не уходит в минус» держится `LedgerService` (он уже защищён от списания ниже нуля) + ежедневным префлайт. Поэтому отдельное «предупреждение админа при ретро-операциях» (CSV-импорт исторических лидов / ручная правка баланса между cut-off и началом дня) — **опциональная UX-полировка**, не блокер. Если делать — добавить confirm-диалог в `ImportController` и в `AdminTenantsController::adjustBalance` UI с текстом «эта операция может вывести клиента в заморозку на следующем cut-off». Минимально — можно вынести в отдельную мелкую задачу или Спек D. Префлайт отработает корректно в любом случае.
|
||||
**По §3.9 спека (граничные случаи):** главный инвариант «баланс не уходит в минус» держится `LedgerService` (он уже защищён от списания ниже нуля) + ежедневным преfflight. Поэтому отдельное «предупреждение админа при ретро-операциях» (CSV-импорт исторических лидов / ручная правка баланса между cut-off и началом дня) — **опциональная UX-полировка**, не блокер. Если делать — добавить confirm-диалог в `ImportController` и в `AdminTenantsController::adjustBalance` UI с текстом «эта операция может вывести клиента в заморозку на следующем cut-off». Минимально — можно вынести в отдельную мелкую задачу или Спек D. Преfflight отработает корректно в любом случае.
|
||||
|
||||
**По UI-задачам (1.10, 2.9, 3.2):** для критичных компонентов (`BalanceCapacityIndicator`) дан полный код. Для остальных Vue-компонентов дан контракт (props / emit / поведение) — исполнитель пишет разметку по образцу первого компонента и существующих паттернов проекта (Vuetify 3, Composition API, `<script setup lang="ts">`). Это сознательное решение: расписывать 10 Vue-компонентов построчно раздуло бы план без пользы. Каждый UI-компонент имеет Vitest-тест с явными ожиданиями — они и фиксируют контракт.
|
||||
|
||||
@@ -2693,3 +2666,4 @@ REQUIRED SUB-SKILL: `superpowers:finishing-a-development-branch`.
|
||||
- Боевая интеграция ОФД-Атол (фискализация).
|
||||
- Боевые секреты в YC Lockbox (SEC-5).
|
||||
- Спек D: «отдать разморозившемуся клиенту лиды через шеринг» + «приоритет шеринга по платёжеспособности».
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ order = max(самый_большой_лимит, ceil(сумма_лимитов
|
||||
|
||||
### §1.2 Проблемы
|
||||
|
||||
**Префлайт:**
|
||||
**Преfflight:**
|
||||
|
||||
1. **Портал переплачивает поставщику** за лиды клиентов, у которых нет денег. Это прямой убыток — закупка оплачена, а продажа не состоится.
|
||||
2. **Нет проактивной защиты при создании/изменении проектов.** Клиент может выставить лимит, который сам по себе не оплачиваем. Сейчас проблема всплывает только в момент списания.
|
||||
@@ -42,8 +42,8 @@ order = max(самый_большой_лимит, ceil(сумма_лимитов
|
||||
|
||||
**VTB-эквайринг:**
|
||||
|
||||
1. **Реального пополнения нет** — `BillingTopupService` это заглушка. Деньги попадают на баланс только через ручное действие админа (`AdminTenantsController::adjustBalance`).
|
||||
2. **Нет 54-ФЗ фискализации** при ритейл-платежах (после подключения карт/СБП — обязательно).
|
||||
4. **Реального пополнения нет** — `BillingTopupService` это заглушка. Деньги попадают на баланс только через ручное действие админа (`AdminTenantsController::adjustBalance`).
|
||||
5. **Нет 54-ФЗ фискализации** при ритейл-платежах (после подключения карт/СБП — обязательно).
|
||||
|
||||
### §1.3 Триггер
|
||||
|
||||
@@ -57,7 +57,7 @@ order = max(самый_большой_лимит, ceil(сумма_лимитов
|
||||
|
||||
### §2.1 Что делаем
|
||||
|
||||
**Префлайт баланса:**
|
||||
**Преfflight баланса:**
|
||||
|
||||
- Расширение `SupplierQuotaAllocator` для учёта баланса клиента (фильтрация eligible-проектов до `computeOrder`).
|
||||
- Активная проверка при создании/правке проекта в личном кабинете (диалог выбора).
|
||||
@@ -81,17 +81,17 @@ order = max(самый_большой_лимит, ceil(сумма_лимитов
|
||||
- **«Отдать разморозившемуся клиенту лиды, уже купленные сегодня, через шеринг»** — отложено в Спек D (см. §8).
|
||||
- **Возвраты пополнений** (refund) — не реализуем (Спек A: «возвраты не делаем»).
|
||||
- **Recurring-платежи** (автосписание) — не реализуем.
|
||||
- **Изменение формулы `computeOrder`** — формула остаётся прежней, префлайт только фильтрует входной список.
|
||||
- **Изменение формулы `computeOrder`** — формула остаётся прежней, преfflight только фильтрует входной список.
|
||||
|
||||
---
|
||||
|
||||
## §3. Решение — часть 1: Префлайт баланса
|
||||
## §3. Решение — часть 1: Преfflight баланса
|
||||
|
||||
### §3.1 Главный инвариант
|
||||
|
||||
**Баланс клиента никогда не уходит в минус.** Гарант — префлайт, который проверяет «хватит ли на полный дневной заказ» **до** того, как заказ уйдёт поставщику. Если хватает — клиент в заказе; если поставщик пришлёт меньше планируемого (норма), остаток баланса уходит в следующий день.
|
||||
**Баланс клиента никогда не уходит в минус.** Гарант — преfflight, который проверяет «хватит ли на полный дневной заказ» **до** того, как заказ уйдёт поставщику. Если хватает — клиент в заказе; если поставщик пришлёт меньше планируемого (норма), остаток баланса уходит в следующий день.
|
||||
|
||||
### §3.2 Когда срабатывает префлайт
|
||||
### §3.2 Когда срабатывает преfflight
|
||||
|
||||
**Одна основная точка:** ежедневный cut-off в **18:00 MSK** (включая выходные).
|
||||
|
||||
@@ -124,11 +124,11 @@ $requiredLeads = $tenant->projects()
|
||||
$passes = $capacity >= $requiredLeads;
|
||||
```
|
||||
|
||||
Если `passes=true` — клиент проходит префлайт. Если `false` — не проходит.
|
||||
Если `passes=true` — клиент проходит преfflight. Если `false` — не проходит.
|
||||
|
||||
7-ступенчатый расчёт уже реализован в `BalanceToLeadsConverter::convert` (Спек A) — он сам пройдёт по ступеням, учтёт «текущую» (где сейчас клиент в накопленном объёме) и переход на следующие при росте.
|
||||
|
||||
**NB:** проверяется на **полный лимит** проектов, не на «уже отгруженное + остаток сегодняшнего дня». Это потому, что префлайт работает один раз перед формированием заказа на завтра, а не во время выдачи.
|
||||
**NB:** проверяется на **полный лимит** проектов, не на «уже отгруженное + остаток сегодняшнего дня». Это потому, что преfflight работает один раз перед формированием заказа на завтра, а не во время выдачи.
|
||||
|
||||
### §3.4 Что делает портал при создании/правке «перегруженного» проекта
|
||||
|
||||
@@ -218,16 +218,16 @@ $passes = $capacity >= $requiredLeads;
|
||||
- **Онлайн-режим** (сейчас, для малого числа клиентов): любое изменение в проектах Лидерры → немедленный апдейт на сервере поставщика (`SyncSupplierProjectJob` per-project). Поставщик сохраняет, использует в своём 21:00 слепке.
|
||||
- **Batch до 18:00** (на будущее при росте): накопленные изменения уезжают одним пакетом перед 18:00 (`SyncSupplierProjectsJob` daily cron).
|
||||
|
||||
**Префлайт работает одинаково в обоих режимах** — он только меняет, какие проекты идут в `SyncSupplierProjectJob` (исключает frozen-проекты из `active_today`). Дальше — стандартный механизм синхронизации.
|
||||
**Преfflight работает одинаково в обоих режимах** — он только меняет, какие проекты идут в `SyncSupplierProjectJob` (исключает frozen-проекты из `active_today`). Дальше — стандартный механизм синхронизации.
|
||||
|
||||
### §3.9 Граничные случаи
|
||||
|
||||
| Случай | Поведение |
|
||||
|---|---|
|
||||
| Ретро-операция (CSV-импорт исторических лидов) между 18:00 и началом следующего дня списывает баланс ниже плана | Допускается, но админ предупреждается в UI «эта операция может вывести клиента в заморозку, продолжить?». Если согласился — выполняется; на следующем cut-off клиент будет в заморозке. Минусовых балансов не возникает (CSV-импорт делает обычные `lead_charges` через `LedgerService`, который остаётся защищён от минуса) |
|
||||
| Ручная правка баланса админом (`adjustBalance`) уменьшает баланс ниже плана | Аналогично — админ предупреждается, ответственность на нём. Префлайт отработает на следующем cut-off |
|
||||
| Клиент уже в минусовом балансе на момент запуска префлайт (legacy состояние) | Одноразовая artisan-команда `billing:preflight-initial-sweep` — проходит по всем тенантам, помечает `frozen_by_balance_at` где нужно, отправляет письма с пояснением «у вас активирована новая защита баланса». Запускается один раз при выкатке миграции |
|
||||
| Тарифная ступень меняется в течение дня (накопился объём) | Префлайт на 18:00 MSK использует **текущую** ступень. На завтра ступень может быть другой — но это уже зона следующего cut-off |
|
||||
| Ручная правка баланса админом (`adjustBalance`) уменьшает баланс ниже плана | Аналогично — админ предупреждается, ответственность на нём. Преfflight отработает на следующем cut-off |
|
||||
| Клиент уже в минусовом балансе на момент запуска преfflight (legacy состояние) | Одноразовая artisan-команда `billing:preflight-initial-sweep` — проходит по всем тенантам, помечает `frozen_by_balance_at` где нужно, отправляет письма с пояснением «у вас активирована новая защита баланса». Запускается один раз при выкатке миграции |
|
||||
| Тарифная ступень меняется в течение дня (накопился объём) | Преfflight на 18:00 MSK использует **текущую** ступень. На завтра ступень может быть другой — но это уже зона следующего cut-off |
|
||||
| Поставщик прислал меньше планируемого (норма) | Остаток баланса клиента — экономия для следующего дня. Никаких корректировок |
|
||||
| Клиент пополнил после 18:00 | В сегодня-в-21:00-слепок поставщика не успевает, но в личном кабинете тут же «Возобновлено». В следующий вечерний cut-off — в заказ на послезавтра |
|
||||
|
||||
@@ -239,7 +239,7 @@ $passes = $capacity >= $requiredLeads;
|
||||
order = max(самый_большой_лимит, ceil(сумма_лимитов ÷ 3))
|
||||
```
|
||||
|
||||
Префлайт **не меняет формулу**, а **фильтрует входной массив `daily_limits`** — выкидывает клиентов, не прошедших проверку.
|
||||
Преfflight **не меняет формулу**, а **фильтрует входной массив `daily_limits`** — выкидывает клиентов, не прошедших проверку.
|
||||
|
||||
**Эффект зависит от того, кого выкинули:**
|
||||
|
||||
@@ -256,7 +256,7 @@ order = max(самый_большой_лимит, ceil(сумма_лимитов
|
||||
|
||||
### §3.11 Журналирование
|
||||
|
||||
При каждом срабатывании префлайт (заморозка / разморозка) — строка в новой таблице `balance_freeze_log`:
|
||||
При каждом срабатывании преfflight (заморозка / разморозка) — строка в новой таблице `balance_freeze_log`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE balance_freeze_log (
|
||||
@@ -488,7 +488,7 @@ ALTER TABLE tenants ADD COLUMN legal_entity_form VARCHAR(20); -- 'OOO' | 'IP
|
||||
| **Фронт-views** | `TopupView.vue` (новый, обёртка с переключателем methods); `BillingFrozenInfoView.vue` |
|
||||
| **Pinia** | `billingStore` (расширение под topup-сессии); `tenantStore` (frozen-флаг) |
|
||||
|
||||
### §5.2 Sequence-диаграмма префлайт на cut-off
|
||||
### §5.2 Sequence-диаграмма преfflight на cut-off
|
||||
|
||||
```
|
||||
18:00 MSK Cron
|
||||
@@ -518,7 +518,7 @@ ALTER TABLE tenants ADD COLUMN legal_entity_form VARCHAR(20); -- 'OOO' | 'IP
|
||||
│ │
|
||||
│ └── для следующего tenant...
|
||||
│
|
||||
18:05 MSK (после префлайт) — обычный SyncSupplierProjectsJob запускается
|
||||
18:05 MSK (после преfflight) — обычный SyncSupplierProjectsJob запускается
|
||||
│
|
||||
├── SupplierQuotaAllocator::allocate(eligible_projects)
|
||||
│ │
|
||||
@@ -635,12 +635,11 @@ $dto = SupplierQuotaAllocator::allocate(..., $eligibleProjects, $targetDate);
|
||||
|
||||
## §6. Сценарии (end-to-end)
|
||||
|
||||
### §6.1 Префлайт — пассивный износ
|
||||
### §6.1 Преfflight — пассивный износ
|
||||
|
||||
**Воскресенье 00:00.** Клиент с балансом 1000₽ = 30 лидов (tier 3, цена ~33₽/лид). Проекты заказывают 25/день. Запас 5.
|
||||
|
||||
**Воскресенье 18:00.** Cron `BalancePreflightSweepJob`:
|
||||
|
||||
- `BalancePreflightService::evaluate(client)` → `passes=true` (хватает на 25).
|
||||
- `frozen_by_balance_at` остаётся `NULL`.
|
||||
|
||||
@@ -649,7 +648,6 @@ $dto = SupplierQuotaAllocator::allocate(..., $eligibleProjects, $targetDate);
|
||||
**Понедельник.** В течение дня партии лидов приходят, клиент получает все 25, баланс падает до 0. Никаких внутридневных стопов.
|
||||
|
||||
**Понедельник 18:00.** Cron snova:
|
||||
|
||||
- `evaluate(client)` → `passes=false` (0₽ ≠ 25 лидов).
|
||||
- `frozen_by_balance_at = now()`.
|
||||
- `balance_freeze_log.insert(event='frozen', triggered_by='cutoff_18msk')`.
|
||||
@@ -663,11 +661,11 @@ $dto = SupplierQuotaAllocator::allocate(..., $eligibleProjects, $targetDate);
|
||||
|
||||
**Вторник 18:00.** Cron snova — клиент в заказе на среду. Со среды получает лиды.
|
||||
|
||||
### §6.2 Префлайт — активная нехватка при создании проекта
|
||||
### §6.2 Преfflight — активная нехватка при создании проекта
|
||||
|
||||
**Клиент с балансом 1000₽ = 30 лидов.** Имеет 3 проекта по 10 = 30 лимит. Всё впритык.
|
||||
|
||||
**Клиент создаёт 4-й проект с лимитом 20.** Бэк (`ProjectController::store`) делает превью-префлайт:
|
||||
**Клиент создаёт 4-й проект с лимитом 20.** Бэк (`ProjectController::store`) делает превью-преfflight:
|
||||
|
||||
- `Σ daily_limit (после сохранения) = 50` лидов
|
||||
- `capacity = BalanceToLeadsConverter::convert(1000₽, delivered, tiers)['leads'] = 30` лидов
|
||||
@@ -766,7 +764,7 @@ $dto = SupplierQuotaAllocator::allocate(..., $eligibleProjects, $targetDate);
|
||||
|
||||
Предварительная разбивка на фазы (для оценки масштаба):
|
||||
|
||||
**Phase 1 — Префлайт баланса** (~5-7 задач):
|
||||
**Phase 1 — Преfflight баланса** (~5-7 задач):
|
||||
|
||||
- Миграция БД (`frozen_by_balance_at`, `preflight_blocked_at`, `balance_freeze_log`).
|
||||
- `BalancePreflightService` (pure) + тесты.
|
||||
@@ -798,7 +796,7 @@ $dto = SupplierQuotaAllocator::allocate(..., $eligibleProjects, $targetDate);
|
||||
|
||||
**Phase 4 — Тесты + smoke** (~3-4 задач):
|
||||
|
||||
- Pest end-to-end сценарии префлайт (frozen/unfrozen flow).
|
||||
- Pest end-to-end сценарии преfflight (frozen/unfrozen flow).
|
||||
- Pest end-to-end сценарии безнала (создание счёта → симуляция выписки → подтверждение).
|
||||
- Vitest на новые Vue-компоненты.
|
||||
- Регрессия (Pest --parallel + Vitest + lychee + gitleaks).
|
||||
|
||||
Reference in New Issue
Block a user