feat(billing-v2-c): UI префлайт Task 1.10 — баннер заморозки, индикатор ёмкости, диалог перегрузки
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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/страница).
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,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,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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user