diff --git a/app/app/Console/Commands/BillingFrozenReminderCommand.php b/app/app/Console/Commands/BillingFrozenReminderCommand.php new file mode 100644 index 00000000..c8f7f992 --- /dev/null +++ b/app/app/Console/Commands/BillingFrozenReminderCommand.php @@ -0,0 +1,33 @@ +handle(); + $this->info('Повторные письма заморозки разосланы (если есть кандидаты в окнах).'); + + return self::SUCCESS; + } +} diff --git a/app/app/Console/Commands/BillingPreflightInitialSweepCommand.php b/app/app/Console/Commands/BillingPreflightInitialSweepCommand.php new file mode 100644 index 00000000..59cb58a8 --- /dev/null +++ b/app/app/Console/Commands/BillingPreflightInitialSweepCommand.php @@ -0,0 +1,37 @@ +warn('Разовый преfflight всех тенантов. Запускать ОДИН раз после выкатки Spec C.'); + (new BalancePreflightSweepJob)->handle(); + $this->info('Initial sweep завершён.'); + + return self::SUCCESS; + } +} diff --git a/app/app/Console/Commands/BillingPreflightSweepCommand.php b/app/app/Console/Commands/BillingPreflightSweepCommand.php new file mode 100644 index 00000000..a347f528 --- /dev/null +++ b/app/app/Console/Commands/BillingPreflightSweepCommand.php @@ -0,0 +1,23 @@ +handle(); + $this->info('Преfflight sweep завершён.'); + + return self::SUCCESS; + } +} diff --git a/app/app/Http/Controllers/Api/BillingController.php b/app/app/Http/Controllers/Api/BillingController.php index c8353b79..9111d23c 100644 --- a/app/app/Http/Controllers/Api/BillingController.php +++ b/app/app/Http/Controllers/Api/BillingController.php @@ -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 $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/страница). diff --git a/app/app/Http/Controllers/Api/ProjectController.php b/app/app/Http/Controllers/Api/ProjectController.php index d6eeb52c..6f3a831f 100644 --- a/app/app/Http/Controllers/Api/ProjectController.php +++ b/app/app/Http/Controllers/Api/ProjectController.php @@ -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 { diff --git a/app/app/Http/Requests/StoreProjectRequest.php b/app/app/Http/Requests/StoreProjectRequest.php index 632297bf..92f6ba01 100644 --- a/app/app/Http/Requests/StoreProjectRequest.php +++ b/app/app/Http/Requests/StoreProjectRequest.php @@ -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') { diff --git a/app/app/Http/Requests/UpdateProjectRequest.php b/app/app/Http/Requests/UpdateProjectRequest.php index 052c643f..dc094347 100644 --- a/app/app/Http/Requests/UpdateProjectRequest.php +++ b/app/app/Http/Requests/UpdateProjectRequest.php @@ -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. diff --git a/app/app/Jobs/Billing/BalanceFrozenReminderJob.php b/app/app/Jobs/Billing/BalanceFrozenReminderJob.php new file mode 100644 index 00000000..c69e8534 --- /dev/null +++ b/app/app/Jobs/Billing/BalanceFrozenReminderJob.php @@ -0,0 +1,132 @@ +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 $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(), + ]); + } +} diff --git a/app/app/Jobs/Billing/BalancePreflightSweepJob.php b/app/app/Jobs/Billing/BalancePreflightSweepJob.php new file mode 100644 index 00000000..d7e9fa9b --- /dev/null +++ b/app/app/Jobs/Billing/BalancePreflightSweepJob.php @@ -0,0 +1,137 @@ +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 $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(), + ]); + } +} diff --git a/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php index 24189a32..ccd667f4 100644 --- a/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php +++ b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php @@ -83,12 +83,8 @@ class SyncSupplierProjectsJob implements ShouldQueue $this->client = app(SupplierPortalClient::class); $consecutiveTransient = 0; - // 1. Load active Лидерра-projects via pgsql_supplier - /** @var Collection $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 + */ + 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, has_all_russia: bool, platforms: list, projects: list} $group */ diff --git a/app/app/Mail/BalanceFrozenFinalMail.php b/app/app/Mail/BalanceFrozenFinalMail.php new file mode 100644 index 00000000..502eacf5 --- /dev/null +++ b/app/app/Mail/BalanceFrozenFinalMail.php @@ -0,0 +1,41 @@ +tenant->contact_email], + ); + } + + public function content(): Content + { + return new Content(view: 'emails.balance_frozen_final'); + } +} diff --git a/app/app/Mail/BalanceFrozenMail.php b/app/app/Mail/BalanceFrozenMail.php new file mode 100644 index 00000000..68eb2b3a --- /dev/null +++ b/app/app/Mail/BalanceFrozenMail.php @@ -0,0 +1,42 @@ +tenant->contact_email], + ); + } + + public function content(): Content + { + return new Content(view: 'emails.balance_frozen'); + } +} diff --git a/app/app/Mail/BalanceFrozenReminderMail.php b/app/app/Mail/BalanceFrozenReminderMail.php new file mode 100644 index 00000000..0354ba7c --- /dev/null +++ b/app/app/Mail/BalanceFrozenReminderMail.php @@ -0,0 +1,40 @@ +tenant->contact_email], + ); + } + + public function content(): Content + { + return new Content(view: 'emails.balance_frozen_reminder'); + } +} diff --git a/app/app/Mail/BalanceUnfrozenMail.php b/app/app/Mail/BalanceUnfrozenMail.php new file mode 100644 index 00000000..c3080b61 --- /dev/null +++ b/app/app/Mail/BalanceUnfrozenMail.php @@ -0,0 +1,42 @@ +tenant->contact_email], + ); + } + + public function content(): Content + { + return new Content(view: 'emails.balance_unfrozen'); + } +} diff --git a/app/app/Models/Project.php b/app/app/Models/Project.php index a5de4e22..e4ec8410 100644 --- a/app/app/Models/Project.php +++ b/app/app/Models/Project.php @@ -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: diff --git a/app/app/Models/Tenant.php b/app/app/Models/Tenant.php index ba11587a..a0b4528b 100644 --- a/app/app/Models/Tenant.php +++ b/app/app/Models/Tenant.php @@ -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 */ public function tariff(): BelongsTo { diff --git a/app/app/Services/Billing/BalancePreflightService.php b/app/app/Services/Billing/BalancePreflightService.php new file mode 100644 index 00000000..b56249f1 --- /dev/null +++ b/app/app/Services/Billing/BalancePreflightService.php @@ -0,0 +1,49 @@ + $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), + ); + } +} diff --git a/app/app/Services/Billing/PreflightResult.php b/app/app/Services/Billing/PreflightResult.php new file mode 100644 index 00000000..eb99899e --- /dev/null +++ b/app/app/Services/Billing/PreflightResult.php @@ -0,0 +1,20 @@ +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(<<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'); + } +}; diff --git a/app/resources/js/api/billing.ts b/app/resources/js/api/billing.ts index b5578b88..6d744f90 100644 --- a/app/resources/js/api/billing.ts +++ b/app/resources/js/api/billing.ts @@ -106,3 +106,27 @@ export async function topup(amountRub: number): Promise { const { data } = await apiClient.post('/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 { + const { data } = await apiClient.get('/api/billing/balance-status'); + return data; +} diff --git a/app/resources/js/components/billing/BalanceCapacityIndicator.vue b/app/resources/js/components/billing/BalanceCapacityIndicator.vue new file mode 100644 index 00000000..3dd16edb --- /dev/null +++ b/app/resources/js/components/billing/BalanceCapacityIndicator.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/app/resources/js/components/billing/BalanceFrozenBanner.vue b/app/resources/js/components/billing/BalanceFrozenBanner.vue new file mode 100644 index 00000000..64379083 --- /dev/null +++ b/app/resources/js/components/billing/BalanceFrozenBanner.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/app/resources/js/components/projects/ProjectLimitOverloadDialog.vue b/app/resources/js/components/projects/ProjectLimitOverloadDialog.vue new file mode 100644 index 00000000..d847a516 --- /dev/null +++ b/app/resources/js/components/projects/ProjectLimitOverloadDialog.vue @@ -0,0 +1,70 @@ + + + diff --git a/app/resources/js/layouts/AppLayout.vue b/app/resources/js/layouts/AppLayout.vue index 2233c0b0..072df2e3 100644 --- a/app/resources/js/layouts/AppLayout.vue +++ b/app/resources/js/layouts/AppLayout.vue @@ -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 { await reminders.refreshCounts(); } +async function loadBalanceStatus(): Promise { + 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 });