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/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 });