From 42ebe2e7c67e106e1c1c96e6dc2997c72bfab5db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 25 May 2026 06:23:59 +0300 Subject: [PATCH] =?UTF-8?q?feat(billing-v2-c):=20UI=20=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D1=84=D0=BB=D0=B0=D0=B9=D1=82=20Task=201.10=20=E2=80=94=20?= =?UTF-8?q?=D0=B1=D0=B0=D0=BD=D0=BD=D0=B5=D1=80=20=D0=B7=D0=B0=D0=BC=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B7=D0=BA=D0=B8,=20=D0=B8=D0=BD=D0=B4=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=D1=82=D0=BE=D1=80=20=D1=91=D0=BC=D0=BA=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8,=20=D0=B4=D0=B8=D0=B0=D0=BB=D0=BE=D0=B3=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec C §3.6/§6.2. Бэкенд: GET /api/billing/balance-status (frozen + capacity + required + дефицит ₽/leads), Pest 6. Фронт: BalanceFrozenBanner (в AppLayout, глобально), BalanceCapacityIndicator (в BillingView под балансом), ProjectLimitOverloadDialog (409-перехват в NewProjectDialog: save-blocked/set-zero), tenantStore + api getBalanceStatus. Vitest +18. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/Api/BillingController.php | 98 ++++++++++++++++ app/resources/js/api/billing.ts | 24 ++++ .../billing/BalanceCapacityIndicator.vue | 68 +++++++++++ .../billing/BalanceFrozenBanner.vue | 52 +++++++++ .../projects/ProjectLimitOverloadDialog.vue | 70 ++++++++++++ app/resources/js/layouts/AppLayout.vue | 15 +++ app/resources/js/stores/tenantStore.ts | 43 +++++++ app/resources/js/views/BillingView.vue | 18 ++- .../js/views/projects/NewProjectDialog.vue | 81 +++++++++---- app/routes/web.php | 1 + .../Feature/Billing/BalanceStatusTest.php | 107 ++++++++++++++++++ .../Frontend/BalanceCapacityIndicator.spec.ts | 46 ++++++++ .../Frontend/BalanceFrozenBanner.spec.ts | 48 ++++++++ app/tests/Frontend/BillingView.spec.ts | 4 +- app/tests/Frontend/NewProjectDialog.spec.ts | 38 +++++++ .../ProjectLimitOverloadDialog.spec.ts | 60 ++++++++++ 16 files changed, 751 insertions(+), 22 deletions(-) create mode 100644 app/resources/js/components/billing/BalanceCapacityIndicator.vue create mode 100644 app/resources/js/components/billing/BalanceFrozenBanner.vue create mode 100644 app/resources/js/components/projects/ProjectLimitOverloadDialog.vue create mode 100644 app/resources/js/stores/tenantStore.ts create mode 100644 app/tests/Feature/Billing/BalanceStatusTest.php create mode 100644 app/tests/Frontend/BalanceCapacityIndicator.spec.ts create mode 100644 app/tests/Frontend/BalanceFrozenBanner.spec.ts create mode 100644 app/tests/Frontend/ProjectLimitOverloadDialog.spec.ts 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 });