Compare commits

...

32 Commits

Author SHA1 Message Date
Дмитрий 1fef2571e8 feat(y360): баланс почты Яндекс 360 — ручной ввод + кнопка Пополнить
Accessibility (Pa11y live) / a11y (push) Waiting to run
SAST — Semgrep / Semgrep SAST scan (push) Waiting to run
email — денежный сервис; сумма вписывается в админке «Система» (Yandex360BalanceStore),
светофор по порогам, кнопка «Открыть оплату»/«Пополнить» → admin.yandex.ru/products.
Робот-скрейпер отклонён (SPA Яндекса враждебен ботам + автопополнение защищает баланс).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 13:34:13 +03:00
Дмитрий 2384e3fb0d deploy(prod): launch-gate ЯДРО на liderra.ru — баланс блокирует запуск, не создание
Accessibility (Pa11y live) / a11y (push) Waiting to run
SAST — Semgrep / Semgrep SAST scan (push) Waiting to run
Ядро фичи для обычных проектов (без автоподбора): единый LaunchBalanceGate под
lockForUpdate(Tenant), ProjectService::create($launch)+setActive, bulk resume
«сколько влезло», ProjectController store/update/toggle, config/billing.php,
ProjectResource.preflight_blocked_at + фронт экранов проектов (сообщения в рублях,
метка «не запущен», группы). Схема БД не менялась (preflight_blocked_at уже был).
Cherry-pick ТОЛЬКО ядра поверх main 609c7ae9; автоподбор НЕ выкатывается.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:09:38 +03:00
Дмитрий 609c7ae955 feat(external): на плитке внешних сервисов — и баланс, и статус
Accessibility (Pa11y live) / a11y (push) Waiting to run
SAST — Semgrep / Semgrep SAST scan (push) Waiting to run
Мини-плашка теперь показывает деньги (или —) И слово статуса (жив/выключено/ok)
рядом, как в детализации. Убрана мёртвая serviceValue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 10:36:31 +03:00
Дмитрий 70ae62af4f fix(external): Баланс = только деньги; живость — в колонку Статус
Accessibility (Pa11y live) / a11y (push) Waiting to run
SAST — Semgrep / Semgrep SAST scan (push) Waiting to run
Баланс/статус различаем по типу сервиса (LIVENESS_ONLY_KEYS), не по null-балансу
(денежный Поставщик с сорванным автологином больше не показывается «выключено»).
Мини-плитка переименована «Балансы сервисов»→«Внешние сервисы».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 10:26:06 +03:00
Дмитрий b7bc52be85 feat(external): фронт — плитка «Внешние сервисы» (баланс + живость)
Accessibility (Pa11y live) / a11y (push) Waiting to run
SAST — Semgrep / Semgrep SAST scan (push) Waiting to run
Статус-текст для сервисов без денежного баланса (жив/не отвечает/выключено),
метки и иконки почты/ЮKassa/Jivo/капчи, обновлённые подписи плитки и дрилла.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:03:53 +03:00
Дмитрий 21e1b7982f feat(external): email-алерт при переходе внешнего сервиса в красный
Edge-trigger: одно письмо на ops-адрес при первом покраснении (баланс на
исходе или сервис упал); повторное красное не спамит. Mailable + blade.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:03:53 +03:00
Дмитрий 156f279c56 feat(external): джоба пишет строки живости внешних сервисов
Стабы fakeProvider/fakeProbe вынесены в tests/Pest.php (нужны в двух файлах).
Старые балансовые тесты отключают пробы живости (useLivenessProbes([])).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:03:52 +03:00
Дмитрий a797ff9e24 feat(external): CaptchaLivenessProbe — статус капчи
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:03:52 +03:00
Дмитрий d3f7e7a995 feat(external): JivoLivenessProbe — живость чата
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:03:51 +03:00
Дмитрий 4a4916268b feat(external): YooKassaLivenessProbe — живость платёжного шлюза
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:03:50 +03:00
Дмитрий 159b520cb8 feat(external): SmtpLivenessProbe — живость почты Yandex 360
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:03:50 +03:00
Дмитрий b05ec70897 feat(external): DTO LivenessReading + интерфейс LivenessProbe
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:03:49 +03:00
Дмитрий 253d1b7f39 feat(ui): тип лица полными словами, зелёные дни недели, скрытие банк-реквизитов у физлица
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
- Настройки→Реквизиты и диалог проекта: «Физлицо»→«Физическое лицо», «Юрлицо»→«Юридическое лицо» (ключи value не тронуты)
- У физлица скрыт блок «Реквизиты для оплаты» (банковских реквизитов нет; оплата по счёту — только для юр/ИП)
- Диалог проекта: выбранные дни недели залиты зелёным #0f6e56 как в ProjectDetailsDrawer

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 13:36:58 +03:00
Дмитрий 093cc8729b docs(ПИЛОТ): снимок 29.06 — фича «оплата по счёту» (Этап 1) выкачена на прод + реквизиты ИП в legal_entities
Accessibility (Pa11y live) / a11y (push) Has been cancelled
2026-06-29 11:47:34 +03:00
Дмитрий cdfae077a3 feat(биллинг): оплата по счёту (Этап 1) — счёт, акт, отметка оплаты
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Клиент сам выставляет PDF-счёт (TopupDialog вкладка «По счёту»), счета и
акты — в отдельной вкладке «Счета». Админ (/admin/invoices) отмечает оплату
одной кнопкой → атомарно зачисляет баланс (BillingTopupService), формирует
Акт (без НДС, saas_upd_documents ДОП) и шлёт клиенту письмо «Счёт оплачен»
с вложением PDF-акта. PDF открываются inline в браузере (ASCII-имя).

- Сервисы InvoiceNumberGenerator/InvoiceService/ActService/InvoicePaymentService/PdfRenderer
- Контроллеры InvoiceController (клиент) + AdminInvoiceController (список+mark-paid)
- Модели SaasInvoice/SaasInvoiceItem/SaasUpdDocument; шаблоны pdf/invoice|act
- Нумерация СЧ-ГГГГ-NNNNN (advisory-lock); просрочка invoices:expire (cron)
- Наименование услуги: «Оплата генерации рекламных лидов»
- Зависимость barryvdh/laravel-dompdf (default_font dejavu sans); схема БД не менялась
- Этап 2 (автомат через ВТБ API) — отдельно, спека/план в docs/superpowers

Тесты: счета 13, Billing 138, фронт зелёные; larastan baseline +6 (Pest false-pos).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:32:21 +03:00
Дмитрий 2281907b8a docs(ПИЛОТ): снимок 28.06 — сквозная сверка прод↔git↔бэкап (1:1) + Тенанты + фронт-стенд зелёный
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Прод приведён к gitea-main 84dfbc85 один-в-один (rsync-checksum, потерь нет);
бэкап цел; экран Тенанты на серверную пагинацию выкачен; 992 фронт-теста зелёные;
деньги t2=1 839 405₽ целы. Остатки не-git на проде объяснены (.bak-precutover до 03.07).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 07:10:10 +03:00
Дмитрий 84dfbc857a test(фронт): привёл стенд в зелёный — 10 протухших спеков под актуальные компоненты
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Все падения — устаревшие ожидания тестов (компоненты менялись намеренно):
SettingsView (роутер+вкладка Реквизиты+события), LegalDoc (реальные доки под ЮKassa),
ProjectsView (BulkActionsBar v-show→isVisible), ErrorView (убран фейк REQ/INC),
PricingTiers (формат «500 ₽»), KanbanCard (costKopecks→«—»), ChangePassword (дата из API),
DealDetail (русские ярлыки статусов), DealsView (RuDateField на v-menu), SupplierIntegration
(window.confirm→v-dialog). Изменены ТОЛЬКО тесты, компоненты не тронуты.
Полный прогон: 127 файлов / 992 теста зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:59:01 +03:00
Дмитрий c92d498b57 feat(админка): экран Тенанты на серверную пагинацию/поиск/фильтры (масштаб 1000+)
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
AdminTenantsView грузил всех тенантов разом и фильтровал в браузере — на 1000
клиентов поиск/чипы видели только первую страницу. Теперь страница из limit/offset
+ v-pagination; поиск (ILIKE), статус (производный trial/overdue/active/suspended)
и тариф — серверные multi-фильтры. AdminTenantsController::index: statuses/tariffs
через CASE/whereIn (статус зеркалит adminTenantsMapper.deriveStatus). Опции тарифов —
отдельным запросом listAdminTariffPlans. Демо локально подтверждено.

Тесты: фронт 34/34 (tenants), бэкенд 13/13 (+2 на statuses/tariffs); baseline getJson 13→15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:06:56 +03:00
Дмитрий 2911f3ac0e docs(ПИЛОТ): снимок 28.06 — починен тихо сломанный биллинг-сторож (RLS) + playwright durable
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Свод за заход «закрывай хвосты»: разбор и фикс preflight-sweep/reminder (no-op с 26.06
из-за RLS-роли очереди), self-heal 4 проектов на проде, деньги t2 целы, playwright в deps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:19:24 +03:00
Дмитрий 75dded78a1 fix(биллинг): sweep и reminder перебирают тенантов через BYPASSRLS + playwright в зависимостях
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Корень: после переезда на Managed PG очередь ходит под ролью crm_app_user (RLS),
и Tenant::query() в BalancePreflightSweepJob/BalanceFrozenReminderJob отдавал 0 строк
без app.current_tenant_id — биллинг-преflight молча стал no-op с 26.06 (ни заморозок,
ни снятия проектных блоков). Перечень тенантов теперь берётся через pgsql_supplier
(BYPASSRLS), модель грузится внутри per-tenant SET LOCAL контекста. Логика проверена
на боевых данных: t25/t26 снимутся, t27/t30 заморозятся.

Playwright рантайма supplier-портала объявлен в dependencies ровно 1.59.0 под
chromium-1217 + package-lock синхронизирован; деплой ставит его npm ci --omit=dev,
durable к чистке node_modules.

Тесты Billing 18/18, pint/phpstan чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:11:36 +03:00
Дмитрий cab0347fd2 merge: Этап B+C — кликабельные группы Заказа + ссылки Здоровья + Открыть всё
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-28 10:24:35 +03:00
Дмитрий 00d32ef182 merge: Этап A — сквозная вложенность Лиды до источника в main
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-28 10:16:14 +03:00
Дмитрий 14bb8a017c merge: выбор периода (свой диапазон) в main
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-28 09:56:15 +03:00
Дмитрий a43f3df4c1 merge: пункт Командный центр в левом меню админки
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-28 08:43:14 +03:00
Дмитрий 7b44e743a4 merge: плитка Клиенты (активность+новые+спящие) в main
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-28 08:29:50 +03:00
Дмитрий 1fe68e7367 merge: баланс поставщика = номера × 20₽ (балансы) в main
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-28 07:55:02 +03:00
Дмитрий 89808c1f47 merge: фикс джобы балансов (свежий builder/итерация) в main
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-28 07:32:59 +03:00
Дмитрий eacaee493f merge: фикс DaData X-Secret + кламп days_left (балансы) в main
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-28 07:26:51 +03:00
Дмитрий 36a27cb22c merge: фича Балансы внешних сервисов (плитка дашборда + кнопки Пополнить) в main для выката
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-28 07:18:31 +03:00
Дмитрий 8e864bf96f merge: фиксы достоверности дашборда в main для выката
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-27 18:58:33 +03:00
Дмитрий 2ecc1d6115 merge: дашборд Командный центр Этапы 1+2 в main для выката
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-27 14:49:49 +03:00
Дмитрий fa7361364d feat: подсказка «Как увеличить количество сделок» в диалоге проекта
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Над «Откуда собирать заявки» добавлена строка-подсказка с tooltip:
лимит распределяется по поставщикам равномерно; даже если не выбирается
полностью — просто увеличить лимит, и сделок придёт больше.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 13:35:29 +03:00
105 changed files with 6962 additions and 618 deletions
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\SaasInvoice;
use Illuminate\Console\Command;
/**
* Помечает просроченные неоплаченные счета статусом overdue (Этап 1 «оплата по счёту»).
* Только issued overdue по expires_at; оплаченные/отменённые не трогаются.
*/
class ExpireInvoicesCommand extends Command
{
protected $signature = 'invoices:expire';
protected $description = 'Помечает просроченные неоплаченные счета статусом overdue';
public function handle(): int
{
SaasInvoice::where('status', SaasInvoice::STATUS_ISSUED)
->where('expires_at', '<', now())
->update(['status' => SaasInvoice::STATUS_OVERDUE]);
return self::SUCCESS;
}
}
@@ -411,6 +411,7 @@ class AdminDashboardController extends Controller
'dadata' => (string) config('services.dadata.topup_url') ?: null,
'supplier' => (string) config('services.supplier.topup_url') ?: null,
'yandex_cloud' => $this->ycTopupUrl(),
'email' => (string) config('services.yandex360.topup_url') ?: null,
default => null,
};
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Billing\Invoice\InvoicePaymentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* SaaS-admin: список счетов + ручная отметка оплаты (Этап 1 «оплата по счёту»).
* Зона saas-admin/admin-db. Зачисление делегируется InvoicePaymentService
* (идемпотентно, под tenant RLS-контекстом).
*/
class AdminInvoiceController extends Controller
{
public function __construct(private readonly InvoicePaymentService $payments) {}
public function index(Request $request): JsonResponse
{
$perPage = min(100, max(10, (int) $request->query('per_page', 25)));
$query = DB::table('saas_invoices as i')
->leftJoin('tenants as t', 't.id', '=', 'i.tenant_id')
->select(
'i.id', 'i.invoice_number', 'i.amount_total', 'i.status',
'i.issued_at', 'i.expires_at', 'i.tenant_id', 't.organization_name as tenant_name', 'i.payer_name'
);
$status = $request->query('status');
if (is_string($status) && in_array($status, ['issued', 'paid', 'overdue', 'cancelled'], true)) {
$query->where('i.status', $status);
}
$search = trim((string) $request->query('search', ''));
if ($search !== '') {
$query->where(function ($q) use ($search) {
$q->where('i.invoice_number', 'ilike', "%{$search}%")
->orWhere('i.payer_name', 'ilike', "%{$search}%")
->orWhere('t.organization_name', 'ilike', "%{$search}%");
});
}
$page = $query->orderByDesc('i.issued_at')->paginate($perPage);
return response()->json([
'data' => array_map(static fn ($r) => (array) $r, $page->items()),
'meta' => [
'current_page' => $page->currentPage(),
'last_page' => $page->lastPage(),
'total' => $page->total(),
'per_page' => $page->perPage(),
],
]);
}
public function markPaid(Request $request, int $id): JsonResponse
{
$this->payments->markPaid($id);
return response()->json(['status' => 'ok']);
}
}
@@ -31,12 +31,21 @@ use Illuminate\Support\Facades\DB;
*/
class AdminSystemSettingsController extends Controller
{
/** Ключи-секреты: значение не отдаём в общий список (хранится зашифрованно). */
private const SENSITIVE_KEYS = ['supplier_webhook_secret'];
/** GET /api/admin/system-settings */
public function index(): JsonResponse
{
return response()->json([
'settings' => SystemSetting::orderBy('key')->get(),
]);
$settings = SystemSetting::orderBy('key')->get()->map(function (SystemSetting $s) {
if (in_array($s->key, self::SENSITIVE_KEYS, true)) {
$s->value = $s->value === '' ? '' : '••• (скрыто)';
}
return $s;
});
return response()->json(['settings' => $settings]);
}
/** PUT /api/admin/system-settings/{key} */
@@ -30,10 +30,14 @@ class AdminTenantsController extends Controller
{
use ResolvesAdminUserId;
/** GET /api/admin/tenants?status=&search=&limit=&offset= */
/** GET /api/admin/tenants?status=&statuses=&tariffs=&search=&limit=&offset= */
public function index(Request $request): JsonResponse
{
$status = (string) $request->query('status', '');
// statuses — производные статусы UI (trial/overdue/active/suspended), csv, multi.
// tariffs — имена тарифов (tariff_plans.name), csv, multi.
$statuses = $this->csvParam($request, 'statuses');
$tariffs = $this->csvParam($request, 'tariffs');
$search = trim((string) $request->query('search', ''));
$limit = max(1, min(500, (int) $request->query('limit', '100')));
$offset = max(0, (int) $request->query('offset', '0'));
@@ -59,8 +63,22 @@ class AdminTenantsController extends Controller
])
->whereNull('tenants.deleted_at');
if ($status !== '') {
$query->where('tenants.status', $status);
// Производный статус — зеркалит adminTenantsMapper.deriveStatus (фронт):
// trial > suspended > overdue > active. Серверная фильтрация нужна для масштаба
// (1000 клиентов): без неё чипы фильтровали бы только загруженную страницу.
if ($statuses !== []) {
$query->whereIn(DB::raw("(CASE
WHEN tenants.is_trial THEN 'trial'
WHEN tenants.status = 'suspended' THEN 'suspended'
WHEN tenants.chargeback_unrecovered_rub > 0 OR tenants.balance_rub < 0 THEN 'overdue'
WHEN tenants.status = 'active' THEN 'active'
ELSE 'suspended'
END)"), $statuses);
} elseif ($status !== '') {
$query->where('tenants.status', $status); // back-compat: фильтр по сырой колонке
}
if ($tariffs !== []) {
$query->whereIn('tariff_plans.name', $tariffs);
}
if ($search !== '') {
$like = '%'.$search.'%';
@@ -451,6 +469,19 @@ class AdminTenantsController extends Controller
];
}
/**
* Разбирает csv-параметр запроса в список непустых trimmed-строк.
*
* @return list<string>
*/
private function csvParam(Request $request, string $key): array
{
return array_values(array_filter(array_map(
'trim',
explode(',', (string) $request->query($key, '')),
)));
}
/**
* Aggregate-stats для page-head: total / active / trial / overdue / revenue.
* Считается отдельным запросом без фильтров (показывает глобальную картину
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\External\Yandex360BalanceStore;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* SaaS-admin Система: ручной ввод баланса Яндекс 360 (почта).
* Владелец периодически вписывает сумму из кабинета (у Яндекса нет API баланса).
*/
class AdminYandex360BalanceController extends Controller
{
public function __construct(private readonly Yandex360BalanceStore $store) {}
/** GET /api/admin/settings/yandex360-balance */
public function show(): JsonResponse
{
return response()->json($this->store->status());
}
/** PUT /api/admin/settings/yandex360-balance — {balance:number|null} */
public function update(Request $request): JsonResponse
{
$raw = $request->input('balance');
$balance = ($raw === null || $raw === '') ? null : (float) $raw;
if ($balance !== null && $balance < 0) {
return response()->json([
'message' => 'Баланс не может быть отрицательным.',
'errors' => ['balance' => ['Введите сумму ≥ 0 или очистите поле.']],
], 422);
}
$this->store->set($balance);
return response()->json($this->store->status());
}
}
@@ -307,7 +307,14 @@ class BillingController extends Controller
$rows = DB::table('saas_invoices')
->where('tenant_id', $tenantId)
->orderBy('issued_at', 'desc')
->get(['id', 'invoice_number', 'amount_total', 'status', 'issued_at', 'pdf_path']);
->get(['id', 'invoice_number', 'amount_total', 'status', 'issued_at', 'expires_at', 'pdf_path']);
// Какие счета уже имеют закрывающий документ (акт) — для кнопки «Скачать акт».
$actInvoiceIds = DB::table('saas_upd_documents')
->where('tenant_id', $tenantId)
->whereNotNull('invoice_id')
->pluck('invoice_id')
->flip();
return response()->json([
'data' => $rows->map(static fn (\stdClass $r): array => [
@@ -316,7 +323,11 @@ class BillingController extends Controller
'amount_total' => $r->amount_total,
'status' => $r->status,
'issued_at' => $r->issued_at,
'expires_at' => $r->expires_at,
'has_pdf' => $r->pdf_path !== null,
'has_act' => isset($actInvoiceIds[$r->id]),
'pdf_url' => $r->pdf_path !== null ? "/api/billing/invoices/{$r->id}/pdf" : null,
'act_url' => isset($actInvoiceIds[$r->id]) ? "/api/billing/invoices/{$r->id}/act" : null,
])->all(),
]);
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\SaasInvoice;
use App\Models\SaasUpdDocument;
use App\Models\User;
use App\Services\Billing\Invoice\InvoiceService;
use App\Services\Billing\Invoice\RequisitesIncompleteException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Клиентские эндпоинты «оплата по счёту» (под middleware auth:sanctum + tenant).
* Создание счёта (самообслуживание), скачивание PDF счёта и акта (tenant-scoped).
*/
class InvoiceController extends Controller
{
public function __construct(private readonly InvoiceService $invoices) {}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'amount_rub' => ['required', 'numeric', 'min:100', 'max:1000000', 'decimal:0,2'],
]);
/** @var User $user */
$user = $request->user();
$amountRub = bcadd((string) $validated['amount_rub'], '0', 2);
try {
$invoice = $this->invoices->create((int) $user->tenant_id, $amountRub, (int) $user->id);
} catch (RequisitesIncompleteException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json(['invoice' => [
'id' => $invoice->id,
'invoice_number' => $invoice->invoice_number,
'amount_total' => $invoice->amount_total,
'pdf_url' => "/api/billing/invoices/{$invoice->id}/pdf",
]], 201);
}
public function pdf(Request $request, int $id): Response
{
/** @var User $user */
$user = $request->user();
$invoice = SaasInvoice::where('id', $id)->where('tenant_id', $user->tenant_id)->firstOrFail();
abort_if($invoice->pdf_path === null || ! Storage::disk('local')->exists($invoice->pdf_path), 404);
return $this->inlinePdf($invoice->pdf_path, 'Schet-'.$invoice->invoice_number);
}
public function act(Request $request, int $id): Response
{
/** @var User $user */
$user = $request->user();
$invoice = SaasInvoice::where('id', $id)->where('tenant_id', $user->tenant_id)->firstOrFail();
$act = SaasUpdDocument::where('invoice_id', $invoice->id)->firstOrFail();
abort_if($act->pdf_path === null || ! Storage::disk('local')->exists($act->pdf_path), 404);
return $this->inlinePdf($act->pdf_path, 'Akt-'.$act->upd_number);
}
/**
* Отдать PDF для просмотра в браузере (inline) с ASCII-безопасным именем
* кириллица в Content-Disposition ломала имя файла в браузере (random GUID).
*/
private function inlinePdf(string $path, string $baseName): Response
{
$content = Storage::disk('local')->get($path);
$filename = Str::ascii($baseName).'.pdf'; // напр. Schet-SCh-2026-00001.pdf
return response($content, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$filename.'"',
]);
}
}
@@ -9,15 +9,14 @@ use App\Http\Requests\BulkProjectActionRequest;
use App\Http\Requests\StoreProjectRequest;
use App\Http\Requests\UpdateProjectRequest;
use App\Http\Resources\ProjectResource;
use App\Jobs\SyncSupplierProjectJob;
use App\Models\Project;
use App\Models\Tenant;
use App\Repositories\PricingTierRepository;
use App\Services\Billing\BalancePreflightService;
use App\Services\Billing\LaunchBalanceGate;
use App\Services\Project\ProjectService;
use App\Services\Requisites\RequisitesService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Проекты tenant'а расширенный API для ProjectsView + NewDealDialog.
@@ -134,35 +133,18 @@ class ProjectController extends Controller
return response()->json(['error' => 'requisites_required'], 422);
}
$forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false);
unset($validated['force_save_blocked']);
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'];
$project = $this->projects->create($tenant, $validated, launch: true);
$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->loadCount('supplierProjects'))], 201);
return response()->json([
'data' => new ProjectResource($project->loadCount('supplierProjects')),
'launch' => [
'launched' => $project->launch_deferred ? 0 : 1,
'deferred' => $project->launch_deferred ? 1 : 0,
'balance' => $project->gate_payload,
],
], 201);
}
/** PATCH /api/projects/{id} */
@@ -171,34 +153,28 @@ class ProjectController extends Controller
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
$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'];
$isLimitRaise = array_key_exists('daily_limit_target', $validated)
&& (int) $validated['daily_limit_target'] > (int) $project->daily_limit_target
&& $project->is_active && $project->preflight_blocked_at === null;
$preflight = $this->runPreflight($tenant, $wouldBeRequired);
if ($isLimitRaise) {
$newLimit = (int) $validated['daily_limit_target'];
$tenantId = $tenant->id;
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);
}
return DB::transaction(function () use ($project, $validated, $tenant, $tenantId, $newLimit): JsonResponse {
Tenant::whereKey($tenantId)->lockForUpdate()->firstOrFail();
if (! $preflight['passes'] && $forceSaveBlocked) {
$validated['preflight_blocked_at'] = now();
}
$gate = app(LaunchBalanceGate::class)->evaluate($tenant, $newLimit, [$project->id]);
if (! $gate->passes) {
return response()->json(['error' => 'balance_insufficient', 'balance' => $gate->toBalancePayload()], 409);
}
$updated = $this->projects->update($project, $validated);
return response()->json(['data' => new ProjectResource($updated->loadCount('supplierProjects'))]);
});
}
$updated = $this->projects->update($project, $validated);
@@ -206,34 +182,6 @@ class ProjectController extends Controller
return response()->json(['data' => new ProjectResource($updated->loadCount('supplierProjects'))]);
}
/**
* @return array{passes: bool, capacity_leads: int, deficit_leads: int}
*/
private function runPreflight(Tenant $tenant, int $requiredLeads): array
{
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
// 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
{
@@ -269,23 +217,13 @@ class ProjectController extends Controller
$request->validate(['is_active' => ['required', 'boolean']]);
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 11).
// paused_at — anchor для SupplierSnapshotGuard grace-расчёта.
$newActive = $request->boolean('is_active');
$project->update([
'is_active' => $newActive,
'paused_at' => $newActive ? null : now(),
]);
$result = $this->projects->setActive($project, $request->boolean('is_active'));
// #10: pause/resume must reach the supplier. The job's group recompute pushes
// status=paused when no active project of the group remains (resume → active).
// G (балансовый блок): заблокированный за нехваткой баланса проект не
// возобновляется/синхронизируется у поставщика (зеркалит create-гард).
if ($project->preflight_blocked_at === null) {
SyncSupplierProjectJob::dispatch($project->id);
if ($result->activate_deferred ?? false) {
return response()->json(['error' => 'balance_insufficient', 'balance' => $result->gate_payload], 409);
}
return response()->json(['data' => new ProjectResource($project->fresh()->loadCount('supplierProjects'))]);
return response()->json(['data' => new ProjectResource($result->loadCount('supplierProjects'))]);
}
/** POST /api/projects/bulk — batch pause/resume/delete/update_regions/update_days/update_limit */
@@ -52,6 +52,8 @@ class ProjectResource extends JsonResource
'last_synced_at' => $this->aggregateLastSyncedAt(),
// H (балансовый блок): проект приостановлен из-за нехватки баланса (read-only для UI).
'balance_blocked' => $this->preflight_blocked_at !== null,
// Task 15: сырое поле — UI отличает «заблокирован при попытке запуска» vs «закончился баланс».
'preflight_blocked_at' => $this->preflight_blocked_at?->toIso8601String(),
'supplier_links' => $this->when(
$request->routeIs('projects.show'),
fn () => $this->getSupplierLinks(),
@@ -51,49 +51,65 @@ final class BalanceFrozenReminderJob implements ShouldQueue
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
Tenant::query()
// Переезд на Managed PG (26.06.2026): очередь под ролью crm_app_user (RLS).
// Список замороженных тенантов брать через дефолтное соединение нельзя — без
// app.current_tenant_id policy tenants_self_isolation отдаёт 0 строк (тот же
// баг, что у BalancePreflightSweepJob). Берём id через pgsql_supplier (BYPASSRLS).
$tenantIds = DB::connection('pgsql_supplier')->table('tenants')
->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);
}
});
->orderBy('id')
->pluck('id');
foreach ($tenantIds as $tenantId) {
$this->processTenant((int) $tenantId, $service, $tiers);
}
}
/**
* @param Collection<int, PricingTier> $tiers
*/
private function processTenant(Tenant $tenant, BalancePreflightService $service, Collection $tiers): void
private function processTenant(int $tenantId, BalancePreflightService $service, Collection $tiers): void
{
// diffInHours округляет — у заморозки на 25h это 25, на 73h это 73 (OK).
$hours = (int) abs(now()->diffInHours($tenant->frozen_by_balance_at, false));
// SET LOCAL внутри транзакции восстанавливает tenant-контекст: и Tenant::find,
// и requiredLeadsForTomorrow() (читает projects) RLS-зависимы. mark()/alreadySent()
// идут через pgsql_supplier (BYPASSRLS) — им контекст не нужен.
DB::transaction(function () use ($tenantId, $service, $tiers): void {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$window = $this->matchWindow($hours);
if ($window === null) {
return; // вне окон reminder/final
}
$tenant = Tenant::find($tenantId);
if ($tenant === null || $tenant->frozen_by_balance_at === null) {
return; // разморожен/удалён между pluck и обработкой.
}
$marker = $window === 'reminder' ? 'reminder_sent' : 'final_sent';
if ($this->alreadySent($tenant->id, $marker)) {
return;
}
// diffInHours округляет — у заморозки на 25h это 25, на 73h это 73 (OK).
$hours = (int) abs(now()->diffInHours($tenant->frozen_by_balance_at, false));
// Re-evaluate для актуального дефицита в тексте письма.
$result = $service->evaluate(
balanceRub: (string) $tenant->balance_rub,
deliveredInMonth: (int) $tenant->delivered_in_month,
requiredLeads: $tenant->requiredLeadsForTomorrow(),
tiers: $tiers,
);
$window = $this->matchWindow($hours);
if ($window === null) {
return; // вне окон reminder/final
}
$mail = $window === 'reminder'
? new BalanceFrozenReminderMail($tenant, $result)
: new BalanceFrozenFinalMail($tenant, $result);
$marker = $window === 'reminder' ? 'reminder_sent' : 'final_sent';
if ($this->alreadySent($tenant->id, $marker)) {
return;
}
Mail::queue($mail);
$this->mark($tenant, $marker, $result);
// 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
@@ -41,25 +41,40 @@ final class BalancePreflightSweepJob implements ShouldQueue
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
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);
}
});
// Переезд на Managed PG (26.06.2026): очередь ходит в БД под ролью crm_app_user
// (RLS). Перечень тенантов брать через ДЕФОЛТНОЕ соединение нельзя — без
// app.current_tenant_id RLS-policy tenants_self_isolation отдаёт 0 строк, и
// sweep молча превращался в no-op (ни заморозок, ни снятия блоков). Берём id
// через pgsql_supplier (BYPASSRLS — системный контекст), как джоба уже делает
// для balance_freeze_log. Дальше per-tenant SET LOCAL восстанавливает контекст.
$tenantIds = DB::connection('pgsql_supplier')->table('tenants')
->whereNull('deleted_at')
->orderBy('id')
->pluck('id');
foreach ($tenantIds as $tenantId) {
$this->evaluateTenant((int) $tenantId, $service, $tiers);
}
}
/**
* @param Collection<int, PricingTier> $tiers
*/
private function evaluateTenant(Tenant $tenant, BalancePreflightService $service, Collection $tiers): void
private function evaluateTenant(int $tenantId, 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);
DB::transaction(function () use ($tenantId, $service, $tiers): void {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// Модель грузим ВНУТРИ контекста — под RLS-ролью без SET LOCAL Tenant::find
// вернёт null (id-isolation policy). После SET LOCAL запись своей компании видна.
$tenant = Tenant::find($tenantId);
if ($tenant === null) {
return; // удалён между pluck и обработкой — пропускаем.
}
$required = $tenant->requiredLeadsForTomorrow();
$result = $service->evaluate(
+89
View File
@@ -4,17 +4,24 @@ declare(strict_types=1);
namespace App\Jobs\External;
use App\Mail\ExternalServiceDownMail;
use App\Services\Dashboard\BalanceHealth;
use App\Services\External\BalanceProvider;
use App\Services\External\CaptchaLivenessProbe;
use App\Services\External\DadataBalanceProvider;
use App\Services\External\JivoLivenessProbe;
use App\Services\External\LivenessProbe;
use App\Services\External\SupplierBalanceProvider;
use App\Services\External\Yandex360BalanceProvider;
use App\Services\External\YandexCloudBalanceProvider;
use App\Services\External\YooKassaLivenessProbe;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
* Ежедневно собирает баланс внешних сервисов и пишет в external_service_balances.
@@ -37,11 +44,49 @@ class RefreshExternalBalancesJob implements ShouldQueue
DadataBalanceProvider::class,
SupplierBalanceProvider::class,
YandexCloudBalanceProvider::class,
Yandex360BalanceProvider::class,
];
}
/** @var array<int,LivenessProbe>|null Подмена списка проб в тестах; null → дефолт. */
private static ?array $livenessOverride = null;
/** @param array<int,LivenessProbe> $probes */
public static function useLivenessProbes(array $probes): void
{
self::$livenessOverride = $probes;
}
public static function resetLivenessProbes(): void
{
self::$livenessOverride = null;
}
/** @return array<int,LivenessProbe> */
private function livenessProbes(): array
{
if (self::$livenessOverride !== null) {
return self::$livenessOverride;
}
return [
app(YooKassaLivenessProbe::class),
app(JivoLivenessProbe::class),
app(CaptchaLivenessProbe::class),
];
}
public function handle(): void
{
// Прежние цвета (для edge-trigger алерта): service_key => light.
$priorLights = DB::connection(self::DB_CONNECTION)
->table('external_service_balances')
->pluck('light', 'service_key')
->all();
/** @var array<int,array{key:string,detail:string}> $newlyRed */
$newlyRed = [];
foreach ($this->providers() as $cls) {
/** @var BalanceProvider $p */
$p = app($cls);
@@ -85,6 +130,46 @@ class RefreshExternalBalancesJob implements ShouldQueue
'updated_at' => now(),
],
);
if ($h['light'] === 'red' && ($priorLights[$key] ?? null) !== 'red') {
$newlyRed[] = ['key' => $key, 'detail' => 'баланс на исходе'];
}
}
// Пробы живости (сервисы без денежного баланса): пишем в ту же таблицу.
foreach ($this->livenessProbes() as $probe) {
$reading = $probe->check(); // не бросает
$key = $probe->serviceKey();
// Свежий builder на каждую итерацию (как в балансовом цикле).
$table = DB::connection(self::DB_CONNECTION)->table('external_service_balances');
$table->updateOrInsert(
['service_key' => $key],
[
'balance_amount' => null,
'currency' => 'RUB',
'daily_spend_estimate' => null,
'days_left' => null,
'light' => $reading->light,
// ok=true у green/red (статус определённый), false у grey (не смогли/не применимо).
'ok' => $reading->light !== 'grey',
// error несёт человеческую подпись для red/grey (для плитки); у green — null.
'error' => $reading->light === 'green' ? null : $reading->detail,
'checked_at' => $reading->checkedAt,
'updated_at' => now(),
],
);
if ($reading->light === 'red' && ($priorLights[$key] ?? null) !== 'red') {
$newlyRed[] = ['key' => $key, 'detail' => $reading->detail];
}
}
// Edge-trigger: одно письмо, если появились новые красные сервисы.
if ($newlyRed !== []) {
$to = (string) config('services.monitoring.alert_email', 'ops@liderra.ru');
Mail::to($to)->send(new ExternalServiceDownMail($newlyRed, now()));
}
}
@@ -104,6 +189,10 @@ class RefreshExternalBalancesJob implements ShouldQueue
(float) config('services.supplier.red_floor_rub', 5000),
(float) config('services.supplier.amber_floor_rub', 15000),
],
'email' => [
(float) config('services.yandex360.red_floor_rub', 500),
(float) config('services.yandex360.amber_floor_rub', 2000),
],
default => [0.0, 0.0],
};
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Carbon\CarbonInterface;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Email-алерт: один или несколько внешних сервисов впервые перешли в «красный»
* (упали или деньги на исходе). Шлётся из RefreshExternalBalancesJob по edge-trigger.
*/
final class ExternalServiceDownMail extends Mailable
{
use Queueable;
use SerializesModels;
/** @param array<int,array{key:string,detail:string}> $services */
public function __construct(
public readonly array $services,
public readonly CarbonInterface $checkedAt,
) {}
public function envelope(): Envelope
{
$names = implode(', ', array_map(fn ($s) => $s['key'], $this->services));
return new Envelope(subject: 'Лидерра: внешний сервис недоступен / баланс на исходе — '.$names);
}
public function content(): Content
{
return new Content(view: 'emails.external_service_down');
}
}
+26
View File
@@ -8,9 +8,11 @@ use App\Models\Tenant;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
/**
* Email-уведомление об оплате тарифного счёта (ТЗ §18.5, событие
@@ -31,6 +33,10 @@ class InvoicePaidNotification extends Mailable
public string $amountRub,
public ?string $invoiceNumber,
public ?string $tariffName,
/** Относительный путь PDF-акта на диске 'local' (для вложения). */
public ?string $actPdfPath = null,
/** Номер акта — для имени файла вложения. */
public ?string $actNumber = null,
) {}
public function envelope(): Envelope
@@ -53,4 +59,24 @@ class InvoicePaidNotification extends Mailable
],
);
}
/**
* Вложение: PDF закрывающего документа (Акт), если он сформирован.
*
* @return array<int, Attachment>
*/
public function attachments(): array
{
if ($this->actPdfPath === null) {
return [];
}
$name = 'Akt-'.Str::ascii((string) $this->actNumber).'.pdf';
return [
Attachment::fromStorageDisk('local', $this->actPdfPath)
->as($name)
->withMime('application/pdf'),
];
}
}
+1 -1
View File
@@ -19,6 +19,6 @@ class LegalEntity extends Model
'code', 'name', 'short_name', 'legal_form', 'inn', 'kpp', 'ogrn',
'okpo', 'legal_address', 'actual_address', 'bank_name', 'bank_account',
'bank_bik', 'bank_corr', 'director_name', 'director_post',
'director_basis', 'vat_mode',
'director_basis', 'vat_mode', 'is_default',
];
}
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
/**
* Счёт на оплату (schema.sql table saas_invoices). RLS по tenant_id.
* Этап 1 «оплата по счёту»: выставляется клиентом, оплачивается банковским
* переводом, отмечается администратором (InvoicePaymentService).
*
* @property int $id
* @property int $tenant_id
* @property int $legal_entity_id
* @property string $invoice_number
* @property string $payer_type
* @property string|null $payer_name
* @property string|null $payer_inn
* @property string|null $payer_kpp
* @property string|null $payer_address
* @property string|null $payer_email
* @property string $amount_net
* @property string|null $vat_rate
* @property string|null $vat_amount
* @property string $amount_total
* @property string|null $payment_purpose
* @property int|null $transaction_id
* @property string|null $pdf_path
* @property string $status
* @property Carbon|null $issued_at
* @property Carbon|null $expires_at
* @property Carbon|null $paid_at
* @property Carbon|null $cancelled_at
*/
class SaasInvoice extends Model
{
public $timestamps = false;
public const STATUS_DRAFT = 'draft';
public const STATUS_ISSUED = 'issued';
public const STATUS_PAID = 'paid';
public const STATUS_OVERDUE = 'overdue';
public const STATUS_CANCELLED = 'cancelled';
protected $fillable = [
'tenant_id', 'legal_entity_id', 'invoice_number',
'payer_type', 'payer_name', 'payer_inn', 'payer_kpp', 'payer_address', 'payer_email',
'amount_net', 'vat_rate', 'vat_amount', 'amount_total', 'payment_purpose',
'transaction_id', 'pdf_path', 'status',
'issued_at', 'expires_at', 'paid_at', 'cancelled_at',
];
protected function casts(): array
{
return [
'amount_net' => 'decimal:2',
'vat_amount' => 'decimal:2',
'amount_total' => 'decimal:2',
'issued_at' => 'datetime',
'expires_at' => 'datetime',
'paid_at' => 'datetime',
'cancelled_at' => 'datetime',
];
}
/** @return HasMany<SaasInvoiceItem, $this> */
public function items(): HasMany
{
return $this->hasMany(SaasInvoiceItem::class, 'invoice_id');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Позиция счёта (schema.sql table saas_invoice_items). RLS косвенно через invoice_id.
*
* @property int $id
* @property int $invoice_id
* @property string $name
* @property string|null $okpd2
* @property string $quantity
* @property string $unit
* @property string $price
* @property string $amount_net
* @property string|null $vat_rate
* @property string|null $vat_amount
* @property string $amount_total
*/
class SaasInvoiceItem extends Model
{
public $timestamps = false;
protected $fillable = [
'invoice_id', 'name', 'okpd2', 'quantity', 'unit',
'price', 'amount_net', 'vat_rate', 'vat_amount', 'amount_total',
];
protected function casts(): array
{
return [
'quantity' => 'decimal:3',
'price' => 'decimal:2',
'amount_net' => 'decimal:2',
'amount_total' => 'decimal:2',
];
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* Закрывающий документ (schema.sql table saas_upd_documents). RLS по tenant_id.
* Для УСН без НДС используем upd_function='ДОП' (передаточный документ без
* счёта-фактуры) формируется как Акт об оказании услуг (ActService).
*
* @property int $id
* @property int $tenant_id
* @property int $legal_entity_id
* @property string $upd_number
* @property string $upd_function
* @property int|null $correction_for
* @property string $buyer_type
* @property string|null $buyer_name
* @property string|null $buyer_inn
* @property string|null $buyer_kpp
* @property string|null $buyer_address
* @property string $amount_net
* @property string|null $vat_rate
* @property string|null $vat_amount
* @property string $amount_total
* @property int|null $invoice_id
* @property int|null $transaction_id
* @property string|null $pdf_path
* @property string $status
* @property Carbon|null $issued_at
*/
class SaasUpdDocument extends Model
{
public $timestamps = false;
protected $table = 'saas_upd_documents';
public const FUNCTION_DOP = 'ДОП';
protected $fillable = [
'tenant_id', 'legal_entity_id', 'upd_number', 'upd_function', 'correction_for',
'buyer_type', 'buyer_name', 'buyer_inn', 'buyer_kpp', 'buyer_address',
'amount_net', 'vat_rate', 'vat_amount', 'amount_total',
'invoice_id', 'transaction_id', 'pdf_path', 'status', 'issued_at',
];
protected function casts(): array
{
return [
'amount_net' => 'decimal:2',
'vat_amount' => 'decimal:2',
'amount_total' => 'decimal:2',
'issued_at' => 'datetime',
];
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing;
final readonly class GateResult
{
public function __construct(
public bool $passes,
public int $capacityLeads,
public int $committedLeads,
public int $requiredLeads,
public int $deficitLeads,
public string $topupRub,
public string $balanceRub,
) {}
/** @return array{current_balance_rub:string,current_capacity_leads:int,would_be_required_leads:int,deficit_leads:int,topup_rub:string} */
public function toBalancePayload(): array
{
return [
'current_balance_rub' => $this->balanceRub,
'current_capacity_leads' => $this->capacityLeads,
'would_be_required_leads' => $this->requiredLeads,
'deficit_leads' => $this->deficitLeads,
'topup_rub' => $this->topupRub,
];
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing\Invoice;
use App\Models\LegalEntity;
use App\Models\SaasInvoice;
use App\Models\SaasUpdDocument;
use Illuminate\Support\Carbon;
/**
* Формирует закрывающий документ (Акт об оказании услуг, без НДС, УСН) по
* оплаченному счёту. Хранится в saas_upd_documents (upd_function=ДОП передаточный
* документ без счёта-фактуры). PDF в приватный storage.
*/
final class ActService
{
public function __construct(private readonly PdfRenderer $pdf) {}
public function createForInvoice(SaasInvoice $invoice, int $transactionId): SaasUpdDocument
{
$seller = LegalEntity::findOrFail($invoice->legal_entity_id);
$now = Carbon::now('Europe/Moscow');
$number = str_replace('СЧ-', 'АКТ-', (string) $invoice->invoice_number);
$act = SaasUpdDocument::create([
'tenant_id' => $invoice->tenant_id,
'legal_entity_id' => $invoice->legal_entity_id,
'upd_number' => $number,
'upd_function' => SaasUpdDocument::FUNCTION_DOP,
'buyer_type' => $invoice->payer_type,
'buyer_name' => $invoice->payer_name,
'buyer_inn' => $invoice->payer_inn,
'buyer_kpp' => $invoice->payer_kpp,
'buyer_address' => $invoice->payer_address,
'amount_net' => $invoice->amount_total,
'vat_rate' => 0,
'vat_amount' => 0,
'amount_total' => $invoice->amount_total,
'invoice_id' => $invoice->id,
'transaction_id' => $transactionId,
'status' => 'issued',
'issued_at' => $now,
]);
$path = $this->pdf->renderToStorage('pdf.act', [
'act' => $act,
'seller' => $seller,
'invoiceNumber' => $invoice->invoice_number,
], "acts/{$act->id}-{$number}.pdf");
$act->pdf_path = $path;
$act->save();
return $act;
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing\Invoice;
use App\Models\SaasInvoice;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Атомарная нумерация счетов: СЧ-ГГГГ-NNNNN, последовательно по legal_entity_id+год.
* Advisory-lock на пару (legal_entity_id, year) сериализует параллельные вызовы;
* UNIQUE (legal_entity_id, invoice_number) в схеме последний барьер от дублей.
* Вызывать ВНУТРИ транзакции (xact-lock держится до COMMIT).
*/
final class InvoiceNumberGenerator
{
public function next(int $legalEntityId, ?Carbon $now = null): string
{
$now ??= Carbon::now('Europe/Moscow');
$year = (int) $now->year;
// Advisory lock на пару чисел (legal_entity_id, year) — освобождается на COMMIT.
DB::statement('SELECT pg_advisory_xact_lock(?, ?)', [$legalEntityId, $year]);
$prefix = sprintf('СЧ-%d-', $year);
$maxNumber = SaasInvoice::query()
->where('legal_entity_id', $legalEntityId)
->where('invoice_number', 'like', $prefix.'%')
->orderByDesc('invoice_number')
->value('invoice_number');
$seq = 1;
if ($maxNumber !== null) {
$seq = ((int) substr((string) $maxNumber, strlen($prefix))) + 1;
}
return sprintf('%s%05d', $prefix, $seq);
}
}
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing\Invoice;
use App\Mail\InvoicePaidNotification;
use App\Models\SaasInvoice;
use App\Models\SaasTransaction;
use App\Models\SaasUpdDocument;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Billing\BillingTopupService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
* Отметка счёта оплаченным: атомарный claim issued→paid (идемпотентно),
* зачисление баланса (BillingTopupService), создание акта, письмо клиенту.
* Зеркалит идемпотентность и RLS-контекст PaymentWebhookController.
*/
final class InvoicePaymentService
{
public function __construct(
private readonly BillingTopupService $topup,
private readonly ActService $acts,
) {}
public function markPaid(int $invoiceId): void
{
$invoice = SaasInvoice::findOrFail($invoiceId);
$credited = DB::transaction(function () use ($invoice): bool {
// RLS-контекст транзакции (PgBouncer-safe SET LOCAL), как в webhook.
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $invoice->tenant_id);
// Атомарно занимаем issued→paid; 0 строк = уже оплачен (дубль/гонка).
$claimed = SaasInvoice::where('id', $invoice->id)
->where('status', SaasInvoice::STATUS_ISSUED)
->update(['status' => SaasInvoice::STATUS_PAID, 'paid_at' => now()]);
if ($claimed === 0) {
return false; // идемпотентный no-op
}
$tx = SaasTransaction::create([
'tenant_id' => $invoice->tenant_id,
'type' => 'topup',
'amount_rub' => $invoice->amount_total,
'gateway_code' => 'bank_transfer',
'payment_method' => 'bank_transfer',
'legal_entity_id' => $invoice->legal_entity_id,
'invoice_id' => $invoice->id,
'status' => 'success',
'description' => 'Оплата по счёту '.$invoice->invoice_number,
'created_at' => now(),
'completed_at' => now(),
]);
$balanceTx = $this->topup->topup((int) $invoice->tenant_id, (string) $invoice->amount_total, null);
$act = $this->acts->createForInvoice($invoice->fresh(), (int) $tx->id);
SaasTransaction::where('id', $tx->id)->update([
'balance_rub_after' => $balanceTx->balance_rub_after,
'balance_transaction_id' => $balanceTx->id,
'upd_id' => $act->id,
]);
SaasInvoice::where('id', $invoice->id)->update(['transaction_id' => $tx->id]);
return true;
});
if (! $credited) {
return;
}
// Письмо — после COMMIT (избегаем отправки при откате транзакции).
// К письму прикладываем PDF-акт (закрывающий документ).
$tenant = Tenant::find($invoice->tenant_id);
$recipient = User::where('tenant_id', $invoice->tenant_id)->orderBy('id')->first();
if ($tenant !== null && $recipient !== null) {
$act = SaasUpdDocument::where('invoice_id', $invoice->id)->first();
Mail::to($recipient->email)->queue(new InvoicePaidNotification(
$recipient,
$tenant,
(string) $invoice->amount_total,
$invoice->invoice_number,
null,
$act?->pdf_path,
$act?->upd_number,
));
}
}
}
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing\Invoice;
use App\Models\LegalEntity;
use App\Models\SaasInvoice;
use App\Models\SaasInvoiceItem;
use App\Models\TenantRequisites;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Создание счёта на пополнение баланса (УСН, без НДС). Вызывается из HTTP под
* middleware tenant (RLS-контекст). Нумерация атомарна. PDF в приватный storage.
*/
final class InvoiceService
{
/** Наименование услуги в счёте/акте (УСН без НДС). */
public const SERVICE_NAME = 'Оплата генерации рекламных лидов';
public function __construct(
private readonly InvoiceNumberGenerator $numbers,
private readonly PdfRenderer $pdf,
) {}
public function create(int $tenantId, string $amountRub, ?int $userId): SaasInvoice
{
$req = TenantRequisites::where('tenant_id', $tenantId)->first();
if ($req === null || blank($req->inn)) {
throw new RequisitesIncompleteException('Заполните реквизиты компании, чтобы выставить счёт.');
}
// «Наш» получатель — юрлицо-оператор по флагу is_default; иначе первое.
$seller = LegalEntity::where('is_default', true)->first()
?? LegalEntity::orderBy('id')->firstOrFail();
$payerEmail = null;
if ($userId !== null) {
$email = User::query()->whereKey($userId)->value('email');
$payerEmail = is_string($email) && $email !== '' ? $email : null;
}
return DB::transaction(function () use ($tenantId, $amountRub, $req, $seller, $payerEmail) {
$now = Carbon::now('Europe/Moscow');
$number = $this->numbers->next((int) $seller->id, $now);
$invoice = SaasInvoice::create([
'tenant_id' => $tenantId,
'legal_entity_id' => $seller->id,
'invoice_number' => $number,
'payer_type' => $req->subject_type === 'individual' ? 'individual' : 'legal',
'payer_name' => $req->legal_name ?? $req->contact_name,
'payer_inn' => $req->inn,
'payer_kpp' => $req->kpp,
'payer_address' => $req->legal_address,
'payer_email' => $payerEmail,
'amount_net' => $amountRub,
'vat_rate' => 0,
'vat_amount' => 0,
'amount_total' => $amountRub,
'payment_purpose' => 'Оплата по счёту '.$number.'. '.self::SERVICE_NAME.'. Без НДС.',
'status' => SaasInvoice::STATUS_ISSUED,
'issued_at' => $now,
'expires_at' => $now->copy()->addWeekdays(5),
]);
SaasInvoiceItem::create([
'invoice_id' => $invoice->id,
'name' => self::SERVICE_NAME,
'quantity' => 1,
'unit' => 'усл.',
'price' => $amountRub,
'amount_net' => $amountRub,
'vat_rate' => 0,
'vat_amount' => 0,
'amount_total' => $amountRub,
]);
$path = $this->pdf->renderToStorage('pdf.invoice', [
'invoice' => $invoice,
'items' => $invoice->items()->get(),
'seller' => $seller,
], "invoices/{$invoice->id}-{$number}.pdf");
$invoice->pdf_path = $path;
$invoice->save();
return $invoice;
});
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing\Invoice;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Support\Facades\Storage;
/**
* Рендер Blade-шаблона в PDF и сохранение в приватный storage (disk 'local').
* Возвращает относительный путь для saas_invoices.pdf_path / saas_upd_documents.pdf_path.
*/
final class PdfRenderer
{
/**
* @param array<string,mixed> $data
*/
public function renderToStorage(string $view, array $data, string $relativePath): string
{
$pdf = Pdf::loadView($view, $data)->setPaper('a4');
Storage::disk('local')->put($relativePath, $pdf->output());
return $relativePath;
}
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing\Invoice;
use RuntimeException;
/**
* Реквизиты компании клиента не заполнены счёт выставить нельзя.
*/
final class RequisitesIncompleteException extends RuntimeException {}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing;
use App\Models\Project;
use App\Models\Tenant;
use App\Repositories\PricingTierRepository;
final class LaunchBalanceGate
{
public function __construct(
private readonly BalancePreflightService $preflight = new BalancePreflightService,
private readonly BalanceToLeadsConverter $converter = new BalanceToLeadsConverter,
) {}
/**
* @param int[] $excludeProjectIds проекты, чьи лимиты НЕ учитывать в committed (напр. сам активируемый)
*/
public function evaluate(Tenant $tenant, int $additionalLeads, array $excludeProjectIds = []): GateResult
{
$balanceRub = (string) $tenant->balance_rub;
$deliveredInMonth = (int) $tenant->delivered_in_month;
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
$committed = (int) Project::where('tenant_id', $tenant->id)
->where('is_active', true)
->whereNull('preflight_blocked_at')
->when($excludeProjectIds !== [], fn ($q) => $q->whereNotIn('id', $excludeProjectIds))
->sum('daily_limit_target');
$required = $committed + max(0, $additionalLeads);
if ($tiers->isEmpty()) {
$failClosed = (bool) config('billing.launch_requires_active_tiers', false);
return new GateResult(
passes: ! $failClosed,
capacityLeads: $failClosed ? 0 : PHP_INT_MAX,
committedLeads: $committed,
requiredLeads: $required,
deficitLeads: $failClosed ? $required : 0,
topupRub: '0.00',
balanceRub: $balanceRub,
);
}
$result = $this->preflight->evaluate($balanceRub, $deliveredInMonth, $required, $tiers);
return new GateResult(
passes: $result->passes,
capacityLeads: $result->capacityLeads,
committedLeads: $committed,
requiredLeads: $required,
deficitLeads: $result->deficitLeads,
topupRub: $this->topupRub($balanceRub, $deliveredInMonth, $tiers, $result->deficitLeads),
balanceRub: $balanceRub,
);
}
/** Сколько ₽ пополнить, чтобы дефицит-лиды поместились (по цене текущей ступени). */
private function topupRub(string $balanceRub, int $deliveredInMonth, $tiers, int $deficitLeads): string
{
if ($deficitLeads <= 0) {
return '0.00';
}
$priceRub = $this->converter->convert($balanceRub, $deliveredInMonth, $tiers)['current_tier']['price_rub'] ?? '0.00';
return bcmul($priceRub, (string) $deficitLeads, 2);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
/**
* Живость капчи (Yandex SmartCaptcha). Оплата за вызов активно НЕ пингуем.
* driver=null/'' (выключена) grey «выключена»; иначе green «включена».
*/
class CaptchaLivenessProbe implements LivenessProbe
{
public function serviceKey(): string
{
return 'captcha';
}
public function check(): LivenessReading
{
$driver = (string) config('services.captcha.driver', 'null');
if ($driver === '' || $driver === 'null') {
return LivenessReading::unknown('captcha', 'выключена');
}
return LivenessReading::alive('captcha', 'включена');
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
use Illuminate\Support\Facades\Http;
/**
* Живость чата JivoSite: HTTP GET виджет-скрипта по widget_id. 200 жив.
* Нет widget_id grey. Денег не тратит (публичный статик).
*/
class JivoLivenessProbe implements LivenessProbe
{
public function serviceKey(): string
{
return 'jivosite';
}
public function check(): LivenessReading
{
$id = (string) config('services.jivosite.widget_id');
if ($id === '') {
return LivenessReading::unknown('jivosite', 'widget_id не задан');
}
try {
$tpl = (string) config('services.jivosite.widget_url_template', 'https://code.jivo.ru/widget/{id}');
$url = str_replace('{id}', $id, $tpl);
$resp = Http::timeout(5)->get($url);
if ($resp->ok()) {
return LivenessReading::alive('jivosite', 'виджет доступен');
}
return LivenessReading::down('jivosite', 'HTTP '.$resp->status());
} catch (\Throwable $e) {
return LivenessReading::down('jivosite', $e->getMessage());
}
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
/**
* Переходник «жив ли внешний сервис» (для сервисов без денежного баланса).
* check() НЕ бросает любую ошибку заворачивает в LivenessReading::down()/unknown().
* Параллель к BalanceProvider (тот про деньги, этот про доступность).
*/
interface LivenessProbe
{
/** email | yookassa | jivosite | captcha */
public function serviceKey(): string;
public function check(): LivenessReading;
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
use Illuminate\Support\Carbon;
/**
* Снимок «жив ли сервис» результат LivenessProbe. Иммутабельный.
* Проба НЕ бросает наружу: недоступность заворачивает в self::down(),
* невозможность проверить (сервис выключен) в self::unknown().
*
* light: green = ответил ок; red = определённо не отвечает; grey = проверить нельзя.
*/
final class LivenessReading
{
public function __construct(
public readonly string $serviceKey,
public readonly string $light, // green|red|grey
public readonly string $detail, // человеческая подпись: «жив» / «не отвечает: timeout» / «выключена»
public readonly Carbon $checkedAt,
) {}
public static function alive(string $key, string $detail = 'жив'): self
{
return new self($key, 'green', $detail, now());
}
public static function down(string $key, string $detail): self
{
return new self($key, 'red', mb_substr($detail, 0, 500), now());
}
public static function unknown(string $key, string $detail): self
{
return new self($key, 'grey', mb_substr($detail, 0, 500), now());
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
/**
* Баланс Яндекс 360 (почта) РУЧНОЙ ввод. У Яндекса нет API баланса, а робот-скрейпер
* админки хрупкий (обфусцированный SPA) владелец вписывает сумму вручную (решение 02.07);
* баланс сам не кончится (в кабинете включено автопополнение). Ключ сервиса email.
* fetch() НЕ бросает: не задан fail() (серый + подсказка ввести в админке).
*/
class Yandex360BalanceProvider implements BalanceProvider
{
public function __construct(private readonly Yandex360BalanceStore $store) {}
public function serviceKey(): string
{
return 'email';
}
public function fetch(): BalanceReading
{
$balance = $this->store->get();
if ($balance === null) {
return BalanceReading::fail('email', 'Баланс почты не задан — введите вручную в админке (Система)');
}
return BalanceReading::ok('email', $balance, 'RUB', null);
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
use App\Models\SystemSetting;
/**
* Ручной баланс Яндекс 360 (почта): владелец периодически вписывает сумму из кабинета
* (у Яндекса нет API баланса, а робот-скрейпер хрупкий решение владельца 02.07).
* Хранится числом в system_settings.yandex360_manual_balance (не секрет).
*/
class Yandex360BalanceStore
{
private const KEY = 'yandex360_manual_balance';
/** Сумма в рублях или null (не задана). */
public function get(): ?float
{
$row = SystemSetting::find(self::KEY);
if ($row === null || (string) $row->value === '') {
return null;
}
return is_numeric($row->value) ? (float) $row->value : null;
}
/** Сохранить сумму. null или пусто → очистить. */
public function set(?float $balance): void
{
$value = $balance === null ? '' : (string) $balance;
SystemSetting::updateOrCreate(
['key' => self::KEY],
['value' => $value, 'type' => 'decimal', 'updated_at' => now()],
);
}
/** @return array{balance:?float,updated_at:?string} */
public function status(): array
{
$row = SystemSetting::find(self::KEY);
$set = $row !== null && (string) $row->value !== '' && is_numeric($row->value);
return [
'balance' => $set ? (float) $row->value : null,
'updated_at' => $set && $row->updated_at ? $row->updated_at->toIso8601String() : null,
];
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
use Illuminate\Support\Facades\Http;
/**
* Живость платёжного шлюза ЮKassa: GET /v3/me под Basic-авторизацией магазина
* (shopId + секретный ключ). 200 жив. Денег не тратит (справочный эндпоинт).
* Нет ключей grey (нечего проверять).
*/
class YooKassaLivenessProbe implements LivenessProbe
{
public function serviceKey(): string
{
return 'yookassa';
}
public function check(): LivenessReading
{
$shopId = (string) config('services.yookassa.shop_id');
$secret = (string) config('services.yookassa.secret_key');
if ($shopId === '' || $secret === '') {
return LivenessReading::unknown('yookassa', 'ключи ЮKassa не заданы');
}
try {
$url = rtrim((string) config('services.yookassa.api_url', 'https://api.yookassa.ru/v3'), '/').'/me';
$resp = Http::withBasicAuth($shopId, $secret)->timeout(5)->acceptJson()->get($url);
if ($resp->ok()) {
return LivenessReading::alive('yookassa', 'шлюз отвечает');
}
return LivenessReading::down('yookassa', 'HTTP '.$resp->status());
} catch (\Throwable $e) {
return LivenessReading::down('yookassa', $e->getMessage());
}
}
}
+118 -38
View File
@@ -12,6 +12,7 @@ use App\Models\Tenant;
use App\Repositories\PricingTierRepository;
use App\Services\Audit\OperationsLogger;
use App\Services\Billing\BalancePreflightService;
use App\Services\Billing\LaunchBalanceGate;
use App\Services\NotificationService;
use App\Services\Supplier\SupplierProjectGrouping;
use Illuminate\Http\Exceptions\HttpResponseException;
@@ -282,6 +283,47 @@ class ProjectService
}
}
public function setActive(Project $project, bool $active): Project
{
return DB::transaction(function () use ($project, $active) {
Tenant::whereKey($project->tenant_id)->lockForUpdate()->firstOrFail();
if (! $active) {
$project->update(['is_active' => false, 'paused_at' => now()]);
SyncSupplierProjectJob::dispatch($project->id);
$fresh = $project->fresh();
$fresh->activate_deferred = false;
$fresh->gate_payload = null;
$fresh->syncOriginal();
return $fresh;
}
$tenant = Tenant::findOrFail($project->tenant_id);
$gate = app(LaunchBalanceGate::class)
->evaluate($tenant, (int) $project->daily_limit_target, [$project->id]);
if (! $gate->passes) {
$project->update(['preflight_blocked_at' => now()]); // остаётся на паузе
$fresh = $project->fresh();
$fresh->activate_deferred = true;
$fresh->gate_payload = $gate->toBalancePayload();
$fresh->syncOriginal();
return $fresh;
}
$project->update(['is_active' => true, 'paused_at' => null, 'preflight_blocked_at' => null]);
SyncSupplierProjectJob::dispatch($project->id);
$fresh = $project->fresh();
$fresh->activate_deferred = false;
$fresh->gate_payload = null;
$fresh->syncOriginal();
return $fresh;
});
}
public function triggerSync(Project $project): void
{
// G (балансовый блок): ручная «Синхронизировать» не отправляет заблокированный проект.
@@ -354,25 +396,38 @@ class ProjectService
/**
* Pause/resume + supplier sync per affected project (#10).
*
* Without the dispatch, pause never reached the supplier (status stayed active).
* The job's group recompute then pushes status=paused when no active project of
* the group remains, or rebalances the order when some siblings are still active.
* Pause: mass-update (без гейта) + синк per id.
* Resume: по одному через setActive (кумулятивный гейт «сколько влезло»).
* paused_at anchor для SupplierSnapshotGuard grace-расчёта. Mass-update НЕ
* триггерит model events, поэтому для паузы пишем явно в одном UPDATE.
*/
private function bulkPauseResume($query, bool $isActive): array
{
$ids = (clone $query)->pluck('id')->all();
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 11).
// paused_at — anchor для SupplierSnapshotGuard grace-расчёта. Mass-update НЕ
// триггерит model events, поэтому пишем явно в одном UPDATE.
$updated = $query->update([
'is_active' => $isActive,
'paused_at' => $isActive ? null : DB::raw('NOW()'),
]);
foreach ($ids as $id) {
SyncSupplierProjectJob::dispatch((int) $id);
if (! $isActive) {
// Пауза — без гейта, как раньше (mass-update + синк per id).
$ids = (clone $query)->pluck('id')->all();
$updated = $query->update(['is_active' => false, 'paused_at' => DB::raw('NOW()')]);
foreach ($ids as $id) {
SyncSupplierProjectJob::dispatch((int) $id);
}
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
}
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
// Возобновление — «сколько влезло»: по одному через setActive (кумулятивный гейт).
$projects = (clone $query)->get();
$updated = 0;
$skipped = [];
foreach ($projects as $project) {
$r = $this->setActive($project, true);
if ($r->is_active) {
$updated++;
} else {
$skipped[] = ['id' => $project->id, 'reason' => 'balance_insufficient'];
}
}
return ['updated' => $updated, 'skipped' => $skipped, 'warnings' => []];
}
private function bulkSimpleUpdate($query, array $update): array
@@ -617,13 +672,12 @@ class ProjectService
}
}
public function create(Tenant $tenant, array $data): Project
public function create(Tenant $tenant, array $data, bool $launch = true): Project
{
// Лимита по числу проектов нет — ограничение только по балансу/заказанным
// лидам (балансовый префлайт в ProjectController::store). Прежний гейт
// лидам (балансовый гейт на запуске). Прежний гейт
// tenants.limits['max_projects'] убран как противоречащий правилу продукта.
$data['tenant_id'] = $tenant->id;
$data['is_active'] = true;
$data['regions'] = $data['regions'] ?? [];
// Plan 6 dual-write: regions[] источник истины; region_mask/mode — legacy для
// PhonePrefixService / LeadRouter, удаляются в Plan 6.5 после переключения читателей.
@@ -633,29 +687,55 @@ class ProjectService
$this->assertNameUnique($tenant->id, (string) $data['name']);
$this->assertSourceUnique($tenant->id, $data);
$project = Project::create($data);
return DB::transaction(function () use ($tenant, $data, $launch) {
/** @var Tenant $lockedTenant */
$lockedTenant = Tenant::whereKey($tenant->id)->lockForUpdate()->firstOrFail();
$this->ops->record(
tenantId: $project->tenant_id,
userId: auth()->id(),
entityType: 'project',
entityId: $project->id,
event: 'project.created',
payloadBefore: null,
payloadAfter: $project->toArray(),
ip: request()->ip(),
userAgent: request()->userAgent(),
);
$gate = null;
if ($launch) {
$gate = app(LaunchBalanceGate::class)
->evaluate($lockedTenant, (int) $data['daily_limit_target']);
}
$launched = $launch && ($gate !== null && $gate->passes);
// Заблокированный по балансу проект (preflight_blocked_at, Spec C §3.4) НЕ
// заказываем у поставщика — зеркалит фильтр
// BalancePreflightSweepJob::dispatchSupplierSyncIfOnline (->whereNull('preflight_blocked_at')).
// Без гарда продавленный force_save_blocked-проект всё равно уезжал к поставщику
// полным daily_limit_target, хотя лидов он не получает (слепок его исключает).
if ($project->preflight_blocked_at === null) {
SyncSupplierProjectJob::dispatch($project->id);
}
$data['is_active'] = $launched;
// Не запущен из-за баланса → durable-метка «удержан» (не заказываем).
// Черновик (launch=false) → обычная пауза, без метки.
$data['preflight_blocked_at'] = ($launch && ! $launched) ? now() : null;
if (! $launched) {
$data['paused_at'] = now();
}
return $project->fresh();
$project = Project::create($data);
$this->ops->record(
tenantId: $project->tenant_id,
userId: auth()->id(),
entityType: 'project',
entityId: $project->id,
event: 'project.created',
payloadBefore: null,
payloadAfter: $project->toArray(),
ip: request()->ip(),
userAgent: request()->userAgent(),
);
// Заказ поставщику — только реально активный и не-удержанный проект.
// Зеркалит фильтр BalancePreflightSweepJob::dispatchSupplierSyncIfOnline.
if ($project->is_active && $project->preflight_blocked_at === null) {
SyncSupplierProjectJob::dispatch($project->id);
}
$fresh = $project->fresh();
$fresh->launch_deferred = $launch && ! $launched;
$fresh->gate_payload = ($launch && ! $launched && $gate !== null)
? $gate->toBalancePayload()
: null;
// syncOriginal(): помечаем transient-поля как «оригинальные», чтобы Eloquent
// не пытался их персистировать при последующем update() на том же объекте.
$fresh->syncOriginal();
return $fresh;
});
}
}
+1
View File
@@ -7,6 +7,7 @@
"license": "MIT",
"require": {
"php": "^8.3",
"barryvdh/laravel-dompdf": "^3.1",
"laravel/framework": "^13.7",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^3.0",
+523 -144
View File
@@ -4,8 +4,85 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "10306f01cb35d564d5004d2202f0c7b3",
"content-hash": "da84c833d162bd54a2eff0f338eead8a",
"packages": [
{
"name": "barryvdh/laravel-dompdf",
"version": "v3.1.2",
"source": {
"type": "git",
"url": "https://github.com/barryvdh/laravel-dompdf.git",
"reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/ee3b72b19ccdf57d0243116ecb2b90261344dedc",
"reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc",
"shasum": ""
},
"require": {
"dompdf/dompdf": "^3.0",
"illuminate/support": "^9|^10|^11|^12|^13.0",
"php": "^8.1"
},
"require-dev": {
"larastan/larastan": "^2.7|^3.0",
"orchestra/testbench": "^7|^8|^9.16|^10|^11.0",
"phpro/grumphp": "^2.5",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"PDF": "Barryvdh\\DomPDF\\Facade\\Pdf",
"Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf"
},
"providers": [
"Barryvdh\\DomPDF\\ServiceProvider"
]
},
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-4": {
"Barryvdh\\DomPDF\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "A DOMPDF Wrapper for Laravel",
"keywords": [
"dompdf",
"laravel",
"pdf"
],
"support": {
"issues": "https://github.com/barryvdh/laravel-dompdf/issues",
"source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.2"
},
"funding": [
{
"url": "https://fruitcake.nl",
"type": "custom"
},
{
"url": "https://github.com/barryvdh",
"type": "github"
}
],
"time": "2026-02-21T08:51:10+00:00"
},
{
"name": "brick/math",
"version": "0.14.8",
@@ -456,6 +533,161 @@
],
"time": "2024-02-05T11:56:58+00:00"
},
{
"name": "dompdf/dompdf",
"version": "v3.1.5",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"shasum": ""
},
"require": {
"dompdf/php-font-lib": "^1.0.0",
"dompdf/php-svg-lib": "^1.0.0",
"ext-dom": "*",
"ext-mbstring": "*",
"masterminds/html5": "^2.0",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"ext-gd": "*",
"ext-json": "*",
"ext-zip": "*",
"mockery/mockery": "^1.3",
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "^3.5",
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
},
"suggest": {
"ext-gd": "Needed to process images",
"ext-gmagick": "Improves image processing performance",
"ext-imagick": "Improves image processing performance",
"ext-zlib": "Needed for pdf stream compression"
},
"type": "library",
"autoload": {
"psr-4": {
"Dompdf\\": "src/"
},
"classmap": [
"lib/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1"
],
"authors": [
{
"name": "The Dompdf Community",
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
}
],
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
"source": "https://github.com/dompdf/dompdf/tree/v3.1.5"
},
"time": "2026-03-03T13:54:37+00:00"
},
{
"name": "dompdf/php-font-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-font-lib.git",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
},
"type": "library",
"autoload": {
"psr-4": {
"FontLib\\": "src/FontLib"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "The FontLib Community",
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse, export and make subsets of different types of font files.",
"homepage": "https://github.com/dompdf/php-font-lib",
"support": {
"issues": "https://github.com/dompdf/php-font-lib/issues",
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
},
"time": "2026-01-20T14:10:26+00:00"
},
{
"name": "dompdf/php-svg-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-svg-lib.git",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabberworm/php-css-parser": "^8.4 || ^9.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
},
"type": "library",
"autoload": {
"psr-4": {
"Svg\\": "src/Svg"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "The SvgLib Community",
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse and export to PDF SVG files.",
"homepage": "https://github.com/dompdf/php-svg-lib",
"support": {
"issues": "https://github.com/dompdf/php-svg-lib/issues",
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
},
"time": "2026-01-02T16:01:13+00:00"
},
{
"name": "dragonmantank/cron-expression",
"version": "v3.6.0",
@@ -2357,6 +2589,73 @@
},
"time": "2022-12-02T22:17:43+00:00"
},
{
"name": "masterminds/html5",
"version": "2.10.1",
"source": {
"type": "git",
"url": "https://github.com/Masterminds/html5-php.git",
"reference": "fd5018f6815fff903946d0564977b44ce8010e29"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29",
"reference": "fd5018f6815fff903946d0564977b44ce8010e29",
"shasum": ""
},
"require": {
"ext-dom": "*",
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.7-dev"
}
},
"autoload": {
"psr-4": {
"Masterminds\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Matt Butcher",
"email": "technosophos@gmail.com"
},
{
"name": "Matt Farina",
"email": "matt@mattfarina.com"
},
{
"name": "Asmir Mustafic",
"email": "goetas@gmail.com"
}
],
"description": "An HTML5 parser and serializer.",
"homepage": "http://masterminds.github.io/html5-php",
"keywords": [
"HTML5",
"dom",
"html",
"parser",
"querypath",
"serializer",
"xml"
],
"support": {
"issues": "https://github.com/Masterminds/html5-php/issues",
"source": "https://github.com/Masterminds/html5-php/tree/2.10.1"
},
"time": "2026-06-23T18:43:15+00:00"
},
{
"name": "monolog/monolog",
"version": "3.10.0",
@@ -4017,6 +4316,86 @@
},
"time": "2025-12-14T04:43:48+00:00"
},
{
"name": "sabberworm/php-css-parser",
"version": "v9.4.0",
"source": {
"type": "git",
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "1.4.0",
"phpstan/extension-installer": "1.4.3",
"phpstan/phpstan": "1.12.33 || 2.2.2",
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.16",
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11",
"phpunit/phpunit": "8.5.52",
"rawr/phpunit-data-provider": "3.3.1",
"rector/rector": "1.2.10 || 2.4.6",
"rector/type-perfect": "1.0.0 || 2.1.3",
"squizlabs/php_codesniffer": "4.0.1",
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3"
},
"suggest": {
"ext-mbstring": "for parsing UTF-8 CSS"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "9.5.x-dev"
}
},
"autoload": {
"files": [
"src/Rule/Rule.php",
"src/RuleSet/RuleContainer.php"
],
"psr-4": {
"Sabberworm\\CSS\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Raphael Schweikert"
},
{
"name": "Oliver Klee",
"email": "github@oliverklee.de"
},
{
"name": "Jake Hotson",
"email": "jake.github@qzdesign.co.uk"
}
],
"description": "Parser for CSS Files written in PHP",
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
"keywords": [
"css",
"parser",
"stylesheet"
],
"support": {
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0"
},
"time": "2026-06-18T15:10:53+00:00"
},
{
"name": "symfony/clock",
"version": "v7.4.8",
@@ -6606,6 +6985,149 @@
],
"time": "2026-03-30T13:44:50+00:00"
},
{
"name": "thecodingmachine/safe",
"version": "v3.4.0",
"source": {
"type": "git",
"url": "https://github.com/thecodingmachine/safe.git",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^10",
"squizlabs/php_codesniffer": "^3.2"
},
"type": "library",
"autoload": {
"files": [
"lib/special_cases.php",
"generated/apache.php",
"generated/apcu.php",
"generated/array.php",
"generated/bzip2.php",
"generated/calendar.php",
"generated/classobj.php",
"generated/com.php",
"generated/cubrid.php",
"generated/curl.php",
"generated/datetime.php",
"generated/dir.php",
"generated/eio.php",
"generated/errorfunc.php",
"generated/exec.php",
"generated/fileinfo.php",
"generated/filesystem.php",
"generated/filter.php",
"generated/fpm.php",
"generated/ftp.php",
"generated/funchand.php",
"generated/gettext.php",
"generated/gmp.php",
"generated/gnupg.php",
"generated/hash.php",
"generated/ibase.php",
"generated/ibmDb2.php",
"generated/iconv.php",
"generated/image.php",
"generated/imap.php",
"generated/info.php",
"generated/inotify.php",
"generated/json.php",
"generated/ldap.php",
"generated/libxml.php",
"generated/lzf.php",
"generated/mailparse.php",
"generated/mbstring.php",
"generated/misc.php",
"generated/mysql.php",
"generated/mysqli.php",
"generated/network.php",
"generated/oci8.php",
"generated/opcache.php",
"generated/openssl.php",
"generated/outcontrol.php",
"generated/pcntl.php",
"generated/pcre.php",
"generated/pgsql.php",
"generated/posix.php",
"generated/ps.php",
"generated/pspell.php",
"generated/readline.php",
"generated/rnp.php",
"generated/rpminfo.php",
"generated/rrd.php",
"generated/sem.php",
"generated/session.php",
"generated/shmop.php",
"generated/sockets.php",
"generated/sodium.php",
"generated/solr.php",
"generated/spl.php",
"generated/sqlsrv.php",
"generated/ssdeep.php",
"generated/ssh2.php",
"generated/stream.php",
"generated/strings.php",
"generated/swoole.php",
"generated/uodbc.php",
"generated/uopz.php",
"generated/url.php",
"generated/var.php",
"generated/xdiff.php",
"generated/xml.php",
"generated/xmlrpc.php",
"generated/yaml.php",
"generated/yaz.php",
"generated/zip.php",
"generated/zlib.php"
],
"classmap": [
"lib/DateTime.php",
"lib/DateTimeImmutable.php",
"lib/Exceptions/",
"generated/Exceptions/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
"support": {
"issues": "https://github.com/thecodingmachine/safe/issues",
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
},
"funding": [
{
"url": "https://github.com/OskarStark",
"type": "github"
},
{
"url": "https://github.com/shish",
"type": "github"
},
{
"url": "https://github.com/silasjoisten",
"type": "github"
},
{
"url": "https://github.com/staabm",
"type": "github"
}
],
"time": "2026-02-04T18:08:13+00:00"
},
{
"name": "tijsverkoyen/css-to-inline-styles",
"version": "v2.4.0",
@@ -15471,149 +15993,6 @@
},
"time": "2026-02-17T17:25:14+00:00"
},
{
"name": "thecodingmachine/safe",
"version": "v3.4.0",
"source": {
"type": "git",
"url": "https://github.com/thecodingmachine/safe.git",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^10",
"squizlabs/php_codesniffer": "^3.2"
},
"type": "library",
"autoload": {
"files": [
"lib/special_cases.php",
"generated/apache.php",
"generated/apcu.php",
"generated/array.php",
"generated/bzip2.php",
"generated/calendar.php",
"generated/classobj.php",
"generated/com.php",
"generated/cubrid.php",
"generated/curl.php",
"generated/datetime.php",
"generated/dir.php",
"generated/eio.php",
"generated/errorfunc.php",
"generated/exec.php",
"generated/fileinfo.php",
"generated/filesystem.php",
"generated/filter.php",
"generated/fpm.php",
"generated/ftp.php",
"generated/funchand.php",
"generated/gettext.php",
"generated/gmp.php",
"generated/gnupg.php",
"generated/hash.php",
"generated/ibase.php",
"generated/ibmDb2.php",
"generated/iconv.php",
"generated/image.php",
"generated/imap.php",
"generated/info.php",
"generated/inotify.php",
"generated/json.php",
"generated/ldap.php",
"generated/libxml.php",
"generated/lzf.php",
"generated/mailparse.php",
"generated/mbstring.php",
"generated/misc.php",
"generated/mysql.php",
"generated/mysqli.php",
"generated/network.php",
"generated/oci8.php",
"generated/opcache.php",
"generated/openssl.php",
"generated/outcontrol.php",
"generated/pcntl.php",
"generated/pcre.php",
"generated/pgsql.php",
"generated/posix.php",
"generated/ps.php",
"generated/pspell.php",
"generated/readline.php",
"generated/rnp.php",
"generated/rpminfo.php",
"generated/rrd.php",
"generated/sem.php",
"generated/session.php",
"generated/shmop.php",
"generated/sockets.php",
"generated/sodium.php",
"generated/solr.php",
"generated/spl.php",
"generated/sqlsrv.php",
"generated/ssdeep.php",
"generated/ssh2.php",
"generated/stream.php",
"generated/strings.php",
"generated/swoole.php",
"generated/uodbc.php",
"generated/uopz.php",
"generated/url.php",
"generated/var.php",
"generated/xdiff.php",
"generated/xml.php",
"generated/xmlrpc.php",
"generated/yaml.php",
"generated/yaz.php",
"generated/zip.php",
"generated/zlib.php"
],
"classmap": [
"lib/DateTime.php",
"lib/DateTimeImmutable.php",
"lib/Exceptions/",
"generated/Exceptions/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
"support": {
"issues": "https://github.com/thecodingmachine/safe/issues",
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
},
"funding": [
{
"url": "https://github.com/OskarStark",
"type": "github"
},
{
"url": "https://github.com/shish",
"type": "github"
},
{
"url": "https://github.com/silasjoisten",
"type": "github"
},
{
"url": "https://github.com/staabm",
"type": "github"
}
],
"time": "2026-02-04T18:08:13+00:00"
},
{
"name": "theseer/tokenizer",
"version": "2.0.1",
+9
View File
@@ -0,0 +1,9 @@
<?php
return [
// Требовать активные pricing_tiers на СЕГОДНЯ для запуска проекта.
// true (прод) → нет тарифа = запуск запрещён (fail-closed, чтобы не уехать
// к поставщику при несконфигурированном биллинге). false (dev/тесты) →
// сохраняем прежний safe-fallback «безлимит».
'launch_requires_active_tiers' => (bool) env('BILLING_LAUNCH_REQUIRES_ACTIVE_TIERS', false),
];
+301
View File
@@ -0,0 +1,301 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Settings
|--------------------------------------------------------------------------
|
| Set some default values. It is possible to add all defines that can be set
| in dompdf_config.inc.php. You can also override the entire config file.
|
*/
'show_warnings' => false, // Throw an Exception on warnings from dompdf
'public_path' => null, // Override the public path if needed
/*
* Dejavu Sans font is missing glyphs for converted entities, turn it off if you need to show and £.
*/
'convert_entities' => true,
'options' => [
/**
* The location of the DOMPDF font directory
*
* The location of the directory where DOMPDF will store fonts and font metrics
* Note: This directory must exist and be writable by the webserver process.
* *Please note the trailing slash.*
*
* Notes regarding fonts:
* Additional .afm font metrics can be added by executing load_font.php from command line.
*
* Only the original "Base 14 fonts" are present on all pdf viewers. Additional fonts must
* be embedded in the pdf file or the PDF may not display correctly. This can significantly
* increase file size unless font subsetting is enabled. Before embedding a font please
* review your rights under the font license.
*
* Any font specification in the source HTML is translated to the closest font available
* in the font directory.
*
* The pdf standard "Base 14 fonts" are:
* Courier, Courier-Bold, Courier-BoldOblique, Courier-Oblique,
* Helvetica, Helvetica-Bold, Helvetica-BoldOblique, Helvetica-Oblique,
* Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic,
* Symbol, ZapfDingbats.
*/
'font_dir' => storage_path('fonts'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
/**
* The location of the DOMPDF font cache directory
*
* This directory contains the cached font metrics for the fonts used by DOMPDF.
* This directory can be the same as DOMPDF_FONT_DIR
*
* Note: This directory must exist and be writable by the webserver process.
*/
'font_cache' => storage_path('fonts'),
/**
* The location of a temporary directory.
*
* The directory specified must be writeable by the webserver process.
* The temporary directory is required to download remote images and when
* using the PDFLib back end.
*/
'temp_dir' => sys_get_temp_dir(),
/**
* ==== IMPORTANT ====
*
* dompdf's "chroot": Prevents dompdf from accessing system files or other
* files on the webserver. All local files opened by dompdf must be in a
* subdirectory of this directory. DO NOT set it to '/' since this could
* allow an attacker to use dompdf to read any files on the server. This
* should be an absolute path.
* This is only checked on command line call by dompdf.php, but not by
* direct class use like:
* $dompdf = new DOMPDF(); $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output();
*/
'chroot' => realpath(base_path()),
/**
* Protocol whitelist
*
* Protocols and PHP wrappers allowed in URIs, and the validation rules
* that determine if a resouce may be loaded. Full support is not guaranteed
* for the protocols/wrappers specified
* by this array.
*
* @var array
*/
'allowed_protocols' => [
'data://' => ['rules' => []],
'file://' => ['rules' => []],
'http://' => ['rules' => []],
'https://' => ['rules' => []],
],
/**
* Operational artifact (log files, temporary files) path validation
*/
'artifactPathValidation' => null,
/**
* @var string
*/
'log_output_file' => null,
/**
* Whether to enable font subsetting or not.
*/
'enable_font_subsetting' => false,
/**
* The PDF rendering backend to use
*
* Valid settings are 'PDFLib', 'CPDF' (the bundled R&OS PDF class), 'GD' and
* 'auto'. 'auto' will look for PDFLib and use it if found, or if not it will
* fall back on CPDF. 'GD' renders PDFs to graphic files.
* {@link * Canvas_Factory} ultimately determines which rendering class to
* instantiate based on this setting.
*
* Both PDFLib & CPDF rendering backends provide sufficient rendering
* capabilities for dompdf, however additional features (e.g. object,
* image and font support, etc.) differ between backends. Please see
* {@link PDFLib_Adapter} for more information on the PDFLib backend
* and {@link CPDF_Adapter} and lib/class.pdf.php for more information
* on CPDF. Also see the documentation for each backend at the links
* below.
*
* The GD rendering backend is a little different than PDFLib and
* CPDF. Several features of CPDF and PDFLib are not supported or do
* not make any sense when creating image files. For example,
* multiple pages are not supported, nor are PDF 'objects'. Have a
* look at {@link GD_Adapter} for more information. GD support is
* experimental, so use it at your own risk.
*
* @link http://www.pdflib.com
* @link http://www.ros.co.nz/pdf
* @link http://www.php.net/image
*/
'pdf_backend' => 'CPDF',
/**
* html target media view which should be rendered into pdf.
* List of types and parsing rules for future extensions:
* http://www.w3.org/TR/REC-html40/types.html
* screen, tty, tv, projection, handheld, print, braille, aural, all
* Note: aural is deprecated in CSS 2.1 because it is replaced by speech in CSS 3.
* Note, even though the generated pdf file is intended for print output,
* the desired content might be different (e.g. screen or projection view of html file).
* Therefore allow specification of content here.
*/
'default_media_type' => 'screen',
/**
* The default paper size.
*
* North America standard is "letter"; other countries generally "a4"
*
* @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.)
*/
'default_paper_size' => 'a4',
/**
* The default paper orientation.
*
* The orientation of the page (portrait or landscape).
*
* @var string
*/
'default_paper_orientation' => 'portrait',
/**
* The default font family
*
* Used if no suitable fonts can be found. This must exist in the font folder.
*
* @var string
*/
'default_font' => 'dejavu sans',
/**
* Image DPI setting
*
* This setting determines the default DPI setting for images and fonts. The
* DPI may be overridden for inline images by explictly setting the
* image's width & height style attributes (i.e. if the image's native
* width is 600 pixels and you specify the image's width as 72 points,
* the image will have a DPI of 600 in the rendered PDF. The DPI of
* background images can not be overridden and is controlled entirely
* via this parameter.
*
* For the purposes of DOMPDF, pixels per inch (PPI) = dots per inch (DPI).
* If a size in html is given as px (or without unit as image size),
* this tells the corresponding size in pt.
* This adjusts the relative sizes to be similar to the rendering of the
* html page in a reference browser.
*
* In pdf, always 1 pt = 1/72 inch
*
* Rendering resolution of various browsers in px per inch:
* Windows Firefox and Internet Explorer:
* SystemControl->Display properties->FontResolution: Default:96, largefonts:120, custom:?
* Linux Firefox:
* about:config *resolution: Default:96
* (xorg screen dimension in mm and Desktop font dpi settings are ignored)
*
* Take care about extra font/image zoom factor of browser.
*
* In images, <img> size in pixel attribute, img css style, are overriding
* the real image dimension in px for rendering.
*
* @var int
*/
'dpi' => 96,
/**
* Enable embedded PHP
*
* If this setting is set to true then DOMPDF will automatically evaluate embedded PHP contained
* within <script type="text/php"> ... </script> tags.
*
* ==== IMPORTANT ==== Enabling this for documents you do not trust (e.g. arbitrary remote html pages)
* is a security risk.
* Embedded scripts are run with the same level of system access available to dompdf.
* Set this option to false (recommended) if you wish to process untrusted documents.
* This setting may increase the risk of system exploit.
* Do not change this settings without understanding the consequences.
* Additional documentation is available on the dompdf wiki at:
* https://github.com/dompdf/dompdf/wiki
*
* @var bool
*/
'enable_php' => false,
/**
* Enable inline JavaScript
*
* If this setting is set to true then DOMPDF will automatically insert JavaScript code contained
* within <script type="text/javascript"> ... </script> tags as written into the PDF.
* NOTE: This is PDF-based JavaScript to be executed by the PDF viewer,
* not browser-based JavaScript executed by Dompdf.
*
* @var bool
*/
'enable_javascript' => true,
/**
* Enable remote file access
*
* If this setting is set to true, DOMPDF will access remote sites for
* images and CSS files as required.
*
* ==== IMPORTANT ====
* This can be a security risk, in particular in combination with isPhpEnabled and
* allowing remote html code to be passed to $dompdf = new DOMPDF(); $dompdf->load_html(...);
* This allows anonymous users to download legally doubtful internet content which on
* tracing back appears to being downloaded by your server, or allows malicious php code
* in remote html pages to be executed by your server with your account privileges.
*
* This setting may increase the risk of system exploit. Do not change
* this settings without understanding the consequences. Additional
* documentation is available on the dompdf wiki at:
* https://github.com/dompdf/dompdf/wiki
*
* @var bool
*/
'enable_remote' => false,
/**
* List of allowed remote hosts
*
* Each value of the array must be a valid hostname.
*
* This will be used to filter which resources can be loaded in combination with
* isRemoteEnabled. If enable_remote is FALSE, then this will have no effect.
*
* Leave to NULL to allow any remote host.
*
* @var array|null
*/
'allowed_remote_hosts' => null,
/**
* A ratio applied to the fonts height to be more like browsers' line height
*/
'font_height_ratio' => 1.1,
/**
* Use the HTML5 Lib parser
*
* @deprecated This feature is now always on in dompdf 2.x
*
* @var bool
*/
'enable_html5_parser' => true,
],
];
+17
View File
@@ -101,6 +101,7 @@ return [
],
'jivosite' => [
'widget_id' => env('JIVO_WIDGET_ID'),
'widget_url_template' => env('JIVO_WIDGET_URL_TEMPLATE', 'https://code.jivo.ru/widget/{id}'),
],
// Платёжный шлюз ЮKassa. webhook_ip_allowlist — CSV IP/CIDR из env (defense-in-depth
@@ -108,10 +109,26 @@ return [
// опубликованными ЮKassa подсетями: 185.71.76.0/27,185.71.77.0/27,77.75.153.0/25,
// 77.75.154.128/25,77.75.156.11,77.75.156.35,2a02:5180::/32.
'yookassa' => [
'shop_id' => env('YOOKASSA_SHOP_ID'),
'secret_key' => env('YOOKASSA_SECRET_KEY'),
'api_url' => env('YOOKASSA_API_URL', 'https://api.yookassa.ru/v3'),
'webhook_ip_allowlist' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('YOOKASSA_WEBHOOK_IPS', '')),
))),
],
// Куда слать алерты о недоступности/исходе денег внешних сервисов.
'monitoring' => [
'alert_email' => env('MONITORING_ALERT_EMAIL', env('SUPPLIER_ALERT_EMAIL', 'ops@liderra.ru')),
],
// Баланс Яндекс 360 (почта) — РУЧНОЙ ввод (см. Yandex360BalanceProvider). Пороги
// светофора — как у dadata; topup_url — страница «Оплата и тариф» в кабинете Яндекс 360.
'yandex360' => [
'red_floor_rub' => (int) env('YANDEX360_RED_FLOOR_RUB', 500),
'amber_floor_rub' => (int) env('YANDEX360_AMBER_FLOOR_RUB', 2000),
'topup_url' => env('YANDEX360_TOPUP_URL', 'https://admin.yandex.ru/products'),
],
];
+46 -1
View File
@@ -5,7 +5,8 @@
"packages": {
"": {
"dependencies": {
"lucide-vue-next": "^1.0.0"
"lucide-vue-next": "^1.0.0",
"playwright": "1.59.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -7787,6 +7788,50 @@
"@vue/devtools-kit": "^7.7.9"
}
},
"node_modules/playwright": {
"version": "1.59.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.0.tgz",
"integrity": "sha512-wihGScriusvATUxmhfENxg0tj1vHEFeIwxlnPFKQTOQVd7aG08mUfvvniRP/PtQOC+2Bs52kBOC/Up1jTXeIbw==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0.tgz",
"integrity": "sha512-PW/X/IoZ6BMUUy8rpwHEZ8Kc0IiLIkgKYGNFaMs5KmQhcfLILNx9yCQD0rnWeWfz1PNeqcFP1BsihQhDOBCwZw==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
+2 -1
View File
@@ -50,6 +50,7 @@
"vuetify": "^3.12.5"
},
"dependencies": {
"lucide-vue-next": "^1.0.0"
"lucide-vue-next": "^1.0.0",
"playwright": "1.59.0"
}
}
+32 -1
View File
@@ -657,7 +657,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 13
count: 15
path: tests/Feature/AdminTenantsIndexTest.php
-
@@ -3227,3 +3227,34 @@ parameters:
identifier: argument.type
count: 1
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Billing/AdminInvoiceIndexTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Billing/AdminInvoiceIndexTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Billing/ExpireInvoicesTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Billing/InvoiceCreateTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Billing/InvoiceCreateTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:get\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Billing/InvoiceCreateTest.php
+58
View File
@@ -124,6 +124,10 @@ interface AdminTenantsStats {
export interface ListAdminTenantsParams {
status?: string;
/** Производные статусы UI (trial/overdue/active/suspended), csv — серверный multi-фильтр. */
statuses?: string;
/** Имена тарифов (tariff_plans.name), csv — серверный multi-фильтр. */
tariffs?: string;
search?: string;
limit?: number;
offset?: number;
@@ -308,6 +312,24 @@ export async function listSystemSettings(): Promise<SystemSetting[]> {
return data.settings;
}
export interface Yandex360BalanceStatus {
balance: number | null;
updated_at: string | null;
}
/** Ручной баланс Яндекс 360 (почта): текущее значение + когда обновляли. */
export async function getYandex360Balance(): Promise<Yandex360BalanceStatus> {
const { data } = await apiClient.get<Yandex360BalanceStatus>('/api/admin/settings/yandex360-balance');
return data;
}
/** Сохранить ручной баланс Яндекс 360 (null — очистить). Возвращает новый статус. */
export async function setYandex360Balance(balance: number | null): Promise<Yandex360BalanceStatus> {
await ensureCsrfCookie();
const { data } = await apiClient.put<Yandex360BalanceStatus>('/api/admin/settings/yandex360-balance', { balance });
return data;
}
export interface UpdateSystemSettingPayload {
value: string;
reason: string; // ≥30 chars
@@ -572,3 +594,39 @@ export async function executePdErasure(id: number, adminUserId?: number): Promis
const { data } = await apiClient.post<EraseSubjectResult>(`/api/admin/pd-subject-requests/${id}/erase`, payload);
return data;
}
// --- Оплата по счёту (Этап 1): список счетов + ручная отметка оплаты ---
export interface AdminInvoiceRow {
id: number;
invoice_number: string;
amount_total: string;
status: string;
issued_at: string;
expires_at: string | null;
tenant_id: number;
tenant_name: string | null;
payer_name: string | null;
}
export interface ListAdminInvoicesParams {
status?: string;
search?: string;
page?: number;
per_page?: number;
}
export interface ListAdminInvoicesResponse {
data: AdminInvoiceRow[];
meta: { current_page: number; last_page: number; total: number; per_page: number };
}
export async function listAdminInvoices(params: ListAdminInvoicesParams = {}): Promise<ListAdminInvoicesResponse> {
const { data } = await apiClient.get<ListAdminInvoicesResponse>('/api/admin/invoices', { params });
return data;
}
export async function markInvoicePaid(id: number): Promise<void> {
await ensureCsrfCookie();
await apiClient.post(`/api/admin/invoices/${id}/mark-paid`);
}
+22 -1
View File
@@ -73,7 +73,19 @@ export interface BillingInvoice {
amount_total: string;
status: string;
issued_at: string;
expires_at: string | null;
has_pdf: boolean;
has_act: boolean;
pdf_url: string | null;
act_url: string | null;
}
/** Ответ POST /api/billing/invoices — созданный счёт. */
export interface CreatedInvoice {
id: number;
invoice_number: string;
amount_total: string;
pdf_url: string;
}
/** GET /api/billing/transactions — пагинированная история транзакций. */
@@ -82,12 +94,21 @@ export async function getTransactions(params: { page?: number; type?: string }):
return data;
}
/** GET /api/billing/invoices — счета тенанта (real-but-empty до Б-1). */
/** GET /api/billing/invoices — счета тенанта. */
export async function getInvoices(): Promise<{ data: BillingInvoice[] }> {
const { data } = await apiClient.get<{ data: BillingInvoice[] }>('/api/billing/invoices');
return data;
}
/** POST /api/billing/invoices — выставить счёт по реквизитам тенанта (оплата по счёту). */
export async function createInvoice(amountRub: number): Promise<CreatedInvoice> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ invoice: CreatedInvoice }>('/api/billing/invoices', {
amount_rub: amountRub,
});
return data.invoice;
}
/**
* Результат POST /api/billing/topup — две формы:
* • заглушка (флаг ВЫКЛ): transaction + balance_rub (мгновенное зачисление);
@@ -61,7 +61,7 @@ defineExpose({ load, invoices });
</v-alert>
<div v-else-if="invoices.length === 0" class="empty pa-8 text-center text-medium-emphasis">
Счета появятся после первой оплаты.
Здесь появятся выставленные вами счета на оплату.
</div>
<ul v-else class="invoices-list pa-2 ma-0">
@@ -72,9 +72,30 @@ defineExpose({ load, invoices });
<span class="sub">{{ statusLabel(inv.status) }}</span>
</span>
<span class="inv-amount num">{{ formatPlain(Number(inv.amount_total)) }}</span>
<v-btn variant="text" size="small" prepend-icon="mdi-file-pdf-box" :disabled="!inv.has_pdf">
PDF
</v-btn>
<span class="inv-actions">
<v-btn
variant="text"
size="small"
prepend-icon="mdi-file-pdf-box"
:href="inv.pdf_url ?? undefined"
target="_blank"
:disabled="!inv.has_pdf"
:data-testid="`inv-pdf-${inv.id}`"
>
Счёт
</v-btn>
<v-btn
v-if="inv.has_act"
variant="text"
size="small"
prepend-icon="mdi-file-document-check-outline"
:href="inv.act_url ?? undefined"
target="_blank"
:data-testid="`inv-act-${inv.id}`"
>
Акт
</v-btn>
</span>
</li>
</ul>
</v-card>
@@ -141,4 +162,9 @@ defineExpose({ load, invoices });
font-weight: 500;
color: #081319;
}
.inv-actions {
display: flex;
gap: 4px;
justify-content: flex-end;
}
</style>
@@ -1,21 +1,24 @@
<script setup lang="ts">
/**
* TopupDialog — диалог пополнения рублёвого баланса (audit E1).
* TopupDialog — диалог пополнения рублёвого баланса.
*
* MVP-stub: POST /api/billing/topup кредитует баланс немедленно (без
* платёжного шлюза — реальная оплата post-Б-1). При успехе эмитит
* `success` с новым балансом и закрывается.
* Два способа:
* • «Карта» — POST /api/billing/topup (заглушка мгновенного зачисления ИЛИ
* редирект на ЮKassa, если флаг billing_yookassa_enabled ВКЛ).
* • «По счёту» (для юрлиц) — POST /api/billing/invoices: формирует PDF-счёт по
* реквизитам тенанта, баланс пополнится после ручной отметки оплаты админом.
*/
import { ref, computed, watch } from 'vue';
import { topup } from '../../api/billing';
import { topup, createInvoice } from '../../api/billing';
import { extractErrorMessage, extractValidationErrors } from '../../api/client';
import { redirectTo } from '../../utils/redirect';
const model = defineModel<boolean>({ required: true });
const emit = defineEmits<{ success: [balanceRub: string] }>();
const emit = defineEmits<{ success: [balanceRub: string]; invoiced: [invoiceNumber: string] }>();
const PRESETS = [1000, 5000, 10000, 25000];
const method = ref<'card' | 'invoice'>('card');
const amount = ref<number | null>(null);
const submitting = ref(false);
const errorMsg = ref<string | null>(null);
@@ -29,12 +32,14 @@ const amountError = computed<string | null>(() => {
const canSubmit = computed(() => Number.isFinite(amount.value) && amountError.value === null && !submitting.value);
// Сброс состояния при каждом открытии диалога (паттерн ReminderDialog/
// NewDealDialog) — нет префилла прошлой суммы и нет всплытия устаревшей ошибки.
const submitLabel = computed(() => (method.value === 'invoice' ? 'Сформировать счёт' : 'Пополнить'));
// Сброс состояния при каждом открытии диалога — нет префилла прошлой суммы/ошибки.
watch(model, (open) => {
if (open) {
amount.value = null;
errorMsg.value = null;
method.value = 'card';
}
});
@@ -42,11 +47,26 @@ function setPreset(value: number): void {
amount.value = value;
}
function openPdf(url: string): void {
if (typeof window !== 'undefined' && typeof window.open === 'function') {
window.open(url, '_blank');
}
}
async function submit(): Promise<void> {
if (!canSubmit.value || amount.value === null) return;
submitting.value = true;
errorMsg.value = null;
try {
if (method.value === 'invoice') {
const invoice = await createInvoice(amount.value);
openPdf(invoice.pdf_url);
emit('invoiced', invoice.invoice_number);
model.value = false;
amount.value = null;
return;
}
const res = await topup(amount.value);
// Реальный шлюз (флаг ВКЛ): редирект на страницу оплаты ЮKassa.
if (res.confirmation_url) {
@@ -71,7 +91,7 @@ function close(): void {
errorMsg.value = null;
}
defineExpose({ amount, submit, canSubmit, errorMsg });
defineExpose({ method, amount, submit, canSubmit, errorMsg });
</script>
<template>
@@ -79,6 +99,18 @@ defineExpose({ amount, submit, canSubmit, errorMsg });
<v-card>
<v-card-title class="text-h6">Пополнить баланс</v-card-title>
<v-card-text>
<v-btn-toggle
v-model="method"
mandatory
density="comfortable"
color="primary"
class="mb-4"
data-testid="topup-method"
>
<v-btn value="card">Картой</v-btn>
<v-btn value="invoice">По счёту (для юрлиц)</v-btn>
</v-btn-toggle>
<v-text-field
v-model.number="amount"
type="number"
@@ -95,8 +127,16 @@ defineExpose({ amount, submit, canSubmit, errorMsg });
</v-chip>
</div>
<v-alert type="info" variant="tonal" density="compact" class="mt-2">
Платёжный шлюз подключается после регистрации юр. лица на текущем этапе баланс пополняется сразу.
<v-alert
v-if="method === 'invoice'"
type="info"
variant="tonal"
density="compact"
class="mt-2"
>
Счёт сформируется по реквизитам вашей компании. Оплатите его банковским переводом
баланс пополнится после поступления денег. Закрывающий документ (Акт) сформируется
автоматически после оплаты.
</v-alert>
<v-alert v-if="errorMsg" type="error" variant="tonal" density="compact" class="mt-3" role="alert">
@@ -107,7 +147,7 @@ defineExpose({ amount, submit, canSubmit, errorMsg });
<v-spacer />
<v-btn variant="text" :disabled="submitting" @click="close">Отмена</v-btn>
<v-btn color="primary" variant="flat" :loading="submitting" :disabled="!canSubmit" @click="submit">
Пополнить
{{ submitLabel }}
</v-btn>
</v-card-actions>
</v-card>
@@ -102,6 +102,19 @@ async function runBulk(payload: Parameters<typeof store.bulkUpdate>[0]) {
const withDeals = result.skipped.filter((s) => s.reason === 'has_deals').length;
const balanceInsufficient = result.skipped.filter((s) => s.reason === 'balance_insufficient').length;
const belowDelivered = result.skipped.filter((s) => s.reason === 'below_delivered_today').length;
// Для действия «Возобновить» с балансовым блоком — специальный тост со сводкой запуска.
if (payload.action === 'resume' && balanceInsufficient > 0) {
const parts: string[] = [`Запущено ${result.updated}, отложено ${balanceInsufficient} — не хватает баланса`];
// Остальные причины добавляем следом, если есть.
if (supplierLocked > 0) parts.push(`${supplierLocked} — мы уже начали сбор лидов на завтра`);
if (withDeals > 0) parts.push(`${withDeals} — по проекту есть сделки`);
if (belowDelivered > 0) parts.push(`${belowDelivered} — лимит ниже уже доставленных сегодня лидов`);
skipToastText.value = parts.join('; ') + '.';
skipToastOpen.value = true;
return;
}
const groups: string[] = [];
if (supplierLocked > 0) {
groups.push(
@@ -79,7 +79,16 @@
</div>
<div v-else class="text-caption text-medium-emphasis mb-2">На паузе</div>
<v-chip :color="syncStatusColor" size="x-small" variant="tonal">
<v-tooltip v-if="project.preflight_blocked_at" location="top">
<template #activator="{ props: tProps }">
<v-chip :color="syncStatusColor" size="x-small" variant="tonal" v-bind="tProps" data-testid="balance-blocked-badge">
<v-icon start size="x-small">{{ syncStatusIcon }}</v-icon>
{{ syncStatusLabel }}
</v-chip>
</template>
Пополните баланс, чтобы запустить проект
</v-tooltip>
<v-chip v-else :color="syncStatusColor" size="x-small" variant="tonal">
<v-icon start size="x-small">{{ syncStatusIcon }}</v-icon>
{{ syncStatusLabel }}
</v-chip>
@@ -116,25 +125,31 @@ const progressPercent = computed(() => {
return limit > 0 ? Math.min(100, Math.round((props.project.delivered_today / limit) * 100)) : 0;
});
const progressColor = computed(() => (progressPercent.value >= 90 ? 'success' : 'primary'));
// H (балансовый блок): заблокированный за нехваткой баланса проект имеет приоритет над
// обычным sync-статусом — клиент должен сразу видеть причину «приостановки».
const syncStatusLabel = computed(() =>
props.project.balance_blocked
? 'Приостановлен — не хватает баланса'
: ({ ok: 'Собирает заявки', pending: 'Готовим к запуску', failed: 'Ошибка — напишите в поддержку' })[props.project.sync_status],
);
const syncStatusIcon = computed(() =>
props.project.balance_blocked
? 'mdi-cash-remove'
: ({ ok: 'mdi-check-circle', pending: 'mdi-clock-outline', failed: 'mdi-alert-circle' })[
props.project.sync_status
],
);
const syncStatusColor = computed(() =>
props.project.balance_blocked
? 'error'
: ({ ok: 'success', pending: 'warning', failed: 'error' })[props.project.sync_status],
);
// H / Task 15: приоритет статусов:
// 1. preflight_blocked_at != null → «Не запущен — не хватает баланса» (не запустился при попытке)
// 2. balance_blocked → «Приостановлен — не хватает баланса» (закончился баланс в процессе)
// 3. обычный sync_status
const syncStatusLabel = computed(() => {
if (props.project.preflight_blocked_at) {
return 'Не запущен — не хватает баланса';
}
if (props.project.balance_blocked) {
return 'Приостановлен — не хватает баланса';
}
return ({ ok: 'Собирает заявки', pending: 'Готовим к запуску', failed: 'Ошибка — напишите в поддержку' })[props.project.sync_status];
});
const syncStatusIcon = computed(() => {
if (props.project.preflight_blocked_at || props.project.balance_blocked) {
return 'mdi-cash-remove';
}
return ({ ok: 'mdi-check-circle', pending: 'mdi-clock-outline', failed: 'mdi-alert-circle' })[props.project.sync_status];
});
const syncStatusColor = computed(() => {
if (props.project.preflight_blocked_at || props.project.balance_blocked) {
return 'error';
}
return ({ ok: 'success', pending: 'warning', failed: 'error' })[props.project.sync_status];
});
</script>
<style scoped>
@@ -1,13 +1,13 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import axios from 'axios';
import type { Project } from '../../stores/projectsStore';
import type { Project, BalancePayload } from '../../stores/projectsStore';
import { useProjectsStore } from '../../stores/projectsStore';
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import { formatLeadDate, firstLeadDate } from '../../utils/leadDate';
const props = defineProps<{ project: Project | null }>();
const emit = defineEmits<{ close: []; saved: [appliesFrom: string | null] }>();
const emit = defineEmits<{ close: []; saved: [appliesFrom: string | null]; 'balance-insufficient': [balance: BalancePayload] }>();
interface FormState {
name: string;
@@ -106,7 +106,13 @@ const sourceConfirmText = computed(() => {
async function onPause(): Promise<void> {
if (!props.project) return;
await store.toggleActive(props.project);
const result = await store.toggleActive(props.project);
if (result?.deferred) {
// Task 14: 409 balance_insufficient при попытке возобновить — не закрываем drawer,
// сообщаем родителю показать диалог пополнения баланса.
emit('balance-insufficient', result.balance);
return;
}
// #4: после паузы/возобновления панель и галочка должны исчезнуть (как у Save и Delete).
emit('close');
}
@@ -222,7 +228,19 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
<aside class="project-details-drawer" :class="{ open: project !== null }">
<div v-if="project" class="pdd-content">
<header class="pdd-head">
<div class="pdd-title">{{ project.name }}</div>
<div class="pdd-head-left">
<div class="pdd-title">{{ project.name }}</div>
<v-chip
v-if="project.preflight_blocked_at"
color="error"
size="x-small"
variant="tonal"
class="ml-2"
data-testid="drawer-balance-blocked-badge"
>
Не запущен не хватает баланса
</v-chip>
</div>
<button class="pdd-close" data-testid="pdd-close" aria-label="Закрыть" @click="$emit('close')">
<v-icon size="20" icon="mdi-close" />
</button>
@@ -436,6 +454,13 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
padding: 16px 20px;
border-bottom: 1px solid var(--liderra-line, #e6e2d6);
}
.pdd-head-left {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
}
.pdd-title {
font-weight: 600;
font-size: 16px;
@@ -1,19 +1,23 @@
<script setup lang="ts">
/**
* Диалог перегрузки лимита (Billing v2 Spec C §6.2, Task 1.10).
* Диалог «Проект не запущен» (Billing v2 Spec C §6.2, Task 12).
*
* Открывается, когда POST/PATCH /api/projects вернул 409 `balance_insufficient`.
* Показывает дефицит и предлагает три исхода:
* - «Сохранить и приостановить» → save-blocked (родитель пере-сабмитит с
* force_save_blocked=true → проект создаётся с preflight_blocked_at);
* - «Поставить лимит 0» → set-zero (родитель ставит daily_limit_target=0);
* - «Отмена» → закрытие без сохранения.
* Сообщает: «Проект создан, но НЕ запущен — не хватает баланса».
* Предлагает три исхода:
* - «Пополнить баланс» (data-test="topup") → закрыть + перейти в /billing;
* - «Уменьшить объём» (data-test="reduce") → set-zero (родитель ставит daily_limit_target=0);
* - «Понятно» (data-test="close") → закрыть без действий.
*
* Backward compat: события save-blocked и set-zero сохранены.
*/
export interface OverloadPayload {
current_balance_rub: string;
current_capacity_leads: number;
would_be_required_leads: number;
deficit_leads: number;
/** Task 12: сумма пополнения в рублях, рассчитанная бэкендом */
topup_rub?: string;
}
import { useRouter } from 'vue-router';
@@ -29,7 +33,7 @@ const emit = defineEmits<{
'set-zero': [];
}>();
// Косяк 06: «Пополнить» прямо из окна перегрузки — закрываем окно и ведём в биллинг.
// Косяк 06: «Пополнить» прямо из окна — закрываем и ведём в биллинг.
const router = useRouter();
function goTopup(): void {
emit('update:modelValue', false);
@@ -40,29 +44,31 @@ function goTopup(): void {
<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-title>Проект не запущен</v-card-title>
<v-card-text>
<p>
У вас {{ payload.current_balance_rub }} = {{ payload.current_capacity_leads }} лидов по текущему
тарифу.
Проект создан, но <strong>не запущен</strong> на балансе недостаточно средств для запуска.
</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 или уменьшите лимиты других проектов.
Чтобы запустить пополните примерно на
<strong>{{ payload.topup_rub ?? payload.deficit_leads }} </strong>
или уменьшите объём.
</p>
<p class="text-caption text-medium-emphasis mt-1">
Текущий баланс: {{ payload.current_balance_rub }}
({{ payload.current_capacity_leads }} лидов), нужно {{ payload.would_be_required_leads }} лидов.
</p>
</v-card-text>
<v-card-actions class="flex-wrap justify-end ga-2">
<v-btn variant="text" data-testid="overload-cancel" @click="$emit('update:modelValue', false)">
Отмена
<v-btn variant="text" data-test="close" 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 variant="text" data-test="reduce" data-testid="overload-set-zero" @click="$emit('set-zero')">
Уменьшить объём
</v-btn>
<v-btn variant="text" data-testid="overload-save-blocked" @click="$emit('save-blocked')">
Сохранить и приостановить
<v-btn color="primary" variant="flat" data-test="topup" data-testid="overload-topup" @click="goTopup">
Пополнить баланс
</v-btn>
<v-btn color="primary" variant="flat" data-testid="overload-topup" @click="goTopup"> Пополнить баланс </v-btn>
</v-card-actions>
</v-card>
</v-dialog>
+1
View File
@@ -29,6 +29,7 @@ const navItems: NavItem[] = [
{ title: 'Тенанты', icon: 'mdi-account-group-outline', to: '/admin/tenants' },
{ title: 'Лиды', icon: 'mdi-target', to: '/admin/leads' },
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/admin/billing' },
{ title: 'Счета', icon: 'mdi-file-document-outline', to: '/admin/invoices' },
{ title: 'Тарифная сетка', icon: 'mdi-tag-arrow-right', to: '/admin/pricing-tiers' },
{ title: 'Цены поставщиков', icon: 'mdi-currency-rub', to: '/admin/supplier-prices' },
{ title: 'Инциденты', icon: 'mdi-alert-outline', to: '/admin/incidents' },
+6
View File
@@ -222,6 +222,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/admin/AdminBillingView.vue'),
meta: { layout: 'admin', title: 'Биллинг', requiresAuth: true, devIndex: 23, devLabel: 'Admin Billing' },
},
{
path: '/admin/invoices',
name: 'admin-invoices',
component: () => import('../views/admin/AdminInvoicesView.vue'),
meta: { layout: 'admin', title: 'Счета', requiresAuth: true, devLabel: 'Admin Invoices' },
},
{
path: '/admin/leads',
name: 'admin-leads',
+25 -3
View File
@@ -21,6 +21,10 @@ export interface Project {
last_synced_at?: string | null;
// H (балансовый блок): проект приостановлен из-за нехватки баланса (preflight_blocked_at).
balance_blocked?: boolean;
// Task 15: сырое поле preflight_blocked_at для различения «заблокирован при попытке запуска»
// vs «баланс закончился во время работы» (balance_blocked). Если не null — проект попытался
// запуститься, но баланса не хватило.
preflight_blocked_at?: string | null;
// Блокировка смены источника (спека 2026-06-22-project-source-edit-lock-ux).
source_locked?: boolean;
source_unlock_at?: string | null;
@@ -29,6 +33,15 @@ export interface Project {
source_change_message?: string | null;
}
// Task 14: payload, возвращаемый бэкендом при 409 balance_insufficient на toggle-active.
export interface BalancePayload {
current_balance_rub: string;
current_capacity_leads: number;
would_be_required_leads: number;
deficit_leads: number;
topup_rub: string;
}
export const useProjectsStore = defineStore('projects', () => {
const items = ref<Project[]>([]);
const total = ref(0);
@@ -105,9 +118,18 @@ export const useProjectsStore = defineStore('projects', () => {
await fetch();
}
async function toggleActive(project: Project) {
await axios.patch(`/api/projects/${project.id}/toggle-active`, { is_active: !project.is_active });
await fetch();
async function toggleActive(project: Project): Promise<{ deferred: true; balance: BalancePayload } | void> {
try {
await axios.patch(`/api/projects/${project.id}/toggle-active`, { is_active: !project.is_active });
await fetch();
} catch (e: unknown) {
const err = e as { response?: { status?: number; data?: { error?: string; balance?: BalancePayload } } };
if (err.response?.status === 409 && err.response?.data?.error === 'balance_insufficient') {
// НЕ меняем is_active в сторе. Возвращаем payload для показа диалога.
return { deferred: true, balance: err.response.data.balance! };
}
throw e; // другие ошибки — пробрасываем как обычно
}
}
function toggleSelect(id: number) {
+22 -5
View File
@@ -9,7 +9,7 @@
* Sprint 5C (E4): pending-баннер убран — платёжного шлюза нет (Б-1), реального состояния «платёж в обработке» в БД не существует.
* TopupDialog «Пополнить баланс» — Task 5 (E1).
*/
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, nextTick } from 'vue';
import BalanceCard from '../components/billing/BalanceCard.vue';
import TierPricesPanel from '../components/billing/TierPricesPanel.vue';
import TransactionsTable from '../components/billing/TransactionsTable.vue';
@@ -21,7 +21,7 @@ import { getWallet, type Wallet } from '../api/billing';
import { extractErrorMessage } from '../api/client';
import { useTenantStore } from '../stores/tenantStore';
const activeView = ref<'overview' | 'charges'>('overview');
const activeView = ref<'overview' | 'charges' | 'invoices'>('overview');
const tenant = useTenantStore();
const wallet = ref<Wallet | null>(null);
@@ -32,6 +32,9 @@ const topupSnackbar = ref(false);
// Возврат с платёжной страницы шлюза (?topup=return): баланс зачислится по webhook.
const paymentReturn = ref(false);
const txTableRef = ref<InstanceType<typeof TransactionsTable> | null>(null);
const invoicesTableRef = ref<InstanceType<typeof InvoicesTable> | null>(null);
const invoiceSnackbar = ref(false);
const invoiceMsg = ref('');
const walletRub = computed(() => Number(wallet.value?.balance_rub ?? 0));
const affordableLeads = computed(() => wallet.value?.affordable_leads ?? 0);
@@ -65,6 +68,16 @@ async function onTopupSuccess(): Promise<void> {
txTableRef.value?.refresh();
}
async function onInvoiced(invoiceNumber: string): Promise<void> {
// Счёт выставлен — переключаемся на вкладку «Счета», обновляем список и тост.
topupOpen.value = false;
invoiceMsg.value = `Счёт ${invoiceNumber} сформирован. Файл открыт в новой вкладке.`;
invoiceSnackbar.value = true;
activeView.value = 'invoices';
await nextTick();
await invoicesTableRef.value?.load();
}
onMounted(() => {
paymentReturn.value = new URLSearchParams(window.location.search).get('topup') === 'return';
void loadWallet();
@@ -105,6 +118,7 @@ defineExpose({ loadWallet, wallet, topupOpen });
<v-tabs v-model="activeView" color="primary" class="mt-4">
<v-tab value="overview">Обзор</v-tab>
<v-tab value="charges">Списания</v-tab>
<v-tab value="invoices">Счета</v-tab>
</v-tabs>
<v-tabs-window v-model="activeView">
@@ -132,19 +146,22 @@ defineExpose({ loadWallet, wallet, topupOpen });
<TierPricesPanel :tiers="tiersPreview" :current-tier-no="currentTierNo" />
<TransactionsTable ref="txTableRef" />
<InvoicesTable />
</template>
</v-tabs-window-item>
<v-tabs-window-item value="charges">
<ChargesTab />
</v-tabs-window-item>
<v-tabs-window-item value="invoices">
<InvoicesTable ref="invoicesTableRef" />
</v-tabs-window-item>
</v-tabs-window>
<TopupDialog v-model="topupOpen" @success="onTopupSuccess" />
<TopupDialog v-model="topupOpen" @success="onTopupSuccess" @invoiced="onInvoiced" />
<v-snackbar v-model="topupSnackbar" color="success" :timeout="4000"> Баланс пополнен. </v-snackbar>
<v-snackbar v-model="invoiceSnackbar" color="success" :timeout="6000">{{ invoiceMsg }}</v-snackbar>
</v-container>
</template>
+33 -3
View File
@@ -147,7 +147,7 @@
:selected="store.selectedIds.has(project.id)"
@toggle-select="store.toggleSelect"
@edit="openEdit"
@toggle-active="store.toggleActive"
@toggle-active="onToggleActive"
@sync-now="(p: Project) => store.syncNow(p.id)"
@delete="(p: Project) => store.del(p.id)"
/>
@@ -171,9 +171,19 @@
Vuetify телепортируется в body и виден даже при скрытом баре. -->
<BulkActionsBar v-show="store.selectedIds.size >= 2" />
<ProjectDetailsDrawer :project="singleSelectedProject" @close="onDrawerClose" @saved="onDrawerSaved" />
<ProjectDetailsDrawer
:project="singleSelectedProject"
@close="onDrawerClose"
@saved="onDrawerSaved"
@balance-insufficient="onDrawerBalanceInsufficient"
/>
<NewProjectDialog v-model="createOpen" mode="create" @saved="onProjectSaved" />
<EditProjectDialog v-model="editOpen" :project="editing" @saved="onProjectSaved" />
<ProjectLimitOverloadDialog
v-model="balanceDialogOpen"
:payload="balanceDialogPayload"
@set-zero="balanceDialogOpen = false"
/>
<v-snackbar
v-model="savedSnackbarOpen"
@@ -188,12 +198,13 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, computed } from 'vue';
import { useProjectsStore, type Project } from '../stores/projectsStore';
import { useProjectsStore, type Project, type BalancePayload } from '../stores/projectsStore';
import ProjectCard from '../components/projects/ProjectCard.vue';
import ProjectDetailsDrawer from '../components/projects/ProjectDetailsDrawer.vue';
import BulkActionsBar from '../components/projects/BulkActionsBar.vue';
import NewProjectDialog from './projects/NewProjectDialog.vue';
import EditProjectDialog from './projects/EditProjectDialog.vue';
import ProjectLimitOverloadDialog from '../components/projects/ProjectLimitOverloadDialog.vue';
import { REGIONS } from '../constants/regions';
import { formatAppliesFromMessage } from '../composables/appliesFromMessage';
@@ -202,6 +213,10 @@ const createOpen = ref(false);
const editOpen = ref(false);
const editing = ref<Project | null>(null);
// Task 14: диалог «не хватает баланса» при попытке возобновить проект.
const balanceDialogOpen = ref(false);
const balanceDialogPayload = ref<BalancePayload | null>(null);
// Тост «Сохранено» после правки проекта. Если правка задела slepok-чувствительные
// поля (regions / delivery_days_mask / daily_limit_target / источник), backend
// возвращает applies_from = N.21:00 МСК — показываем расширенное сообщение.
@@ -238,6 +253,21 @@ const singleSelectedProject = computed<Project | null>(() => {
return store.items.find((p: Project) => p.id === id) ?? null;
});
// Task 14: обработчик toggle-active с перехватом 409 balance_insufficient.
async function onToggleActive(project: Project): Promise<void> {
const result = await store.toggleActive(project);
if (result?.deferred) {
balanceDialogPayload.value = result.balance;
balanceDialogOpen.value = true;
}
}
// Task 14: обработчик события balance-insufficient от ProjectDetailsDrawer.
function onDrawerBalanceInsufficient(balance: BalancePayload): void {
balanceDialogPayload.value = balance;
balanceDialogOpen.value = true;
}
function onDrawerClose(): void {
store.clearSelection();
}
@@ -103,11 +103,19 @@ const SERVICE_LABELS: Record<string, string> = {
dadata: 'DaData',
supplier: 'Поставщик',
yandex_cloud: 'Yandex Cloud',
email: 'Почта',
yookassa: 'ЮKassa',
jivosite: 'JivoSite',
captcha: 'Капча',
};
const SERVICE_ICONS: Record<string, string> = {
dadata: '🧭',
supplier: '📦',
yandex_cloud: '☁️',
email: '✉️',
yookassa: '💳',
jivosite: '💬',
captcha: '🛡',
};
function serviceLabel(key: string): string {
@@ -122,6 +130,33 @@ function daysLeftLabel(days: number | null): string {
return days === null ? '—' : `~${days} дн.`;
}
/** Сервисы БЕЗ денежного баланса — следим только за живостью (не за деньгами). */
const LIVENESS_ONLY_KEYS = new Set(['yookassa', 'jivosite', 'captcha']);
function isLivenessOnly(key: string): boolean {
return LIVENESS_ONLY_KEYS.has(key);
}
/** Слово о доступности сервиса по цвету светофора. */
function livenessWord(light: string): string {
if (light === 'green') return 'жив';
if (light === 'red') return 'не отвечает';
return 'выключено'; // grey
}
type ServiceRow = { service_key: string; balance_amount: string | null; light: string; ok: boolean };
/** Колонка «Баланс» (и плитка, и детализация) — ТОЛЬКО деньги; у сервисов-живости всегда «—». */
function serviceMoney(s: ServiceRow): string {
if (isLivenessOnly(s.service_key)) return '—';
return s.ok && s.balance_amount !== null ? rub(s.balance_amount) : '—';
}
/** Колонка «Статус» в детализации — доступность/здоровье сервиса. */
function serviceStatus(s: ServiceRow): string {
if (isLivenessOnly(s.service_key)) return livenessWord(s.light);
return s.ok ? 'ok' : 'не удалось обновить';
}
/** Подпись светофора Клиентов на плитке. */
function clientsLightLabel(): string {
const d = summary.value?.clients.dormant ?? 0;
@@ -520,7 +555,7 @@ defineExpose({ period, dateFrom, dateTo, showCustom, selected, summary, finance,
<v-card-text>
<div class="d-flex align-center mb-3">
<span class="tile__ico">💳</span>
<span class="text-subtitle-1 font-weight-bold ml-2">Балансы сервисов</span>
<span class="text-subtitle-1 font-weight-bold ml-2">Внешние сервисы</span>
<v-chip
:color="lightColor(summary?.balances.light ?? 'grey')"
size="small"
@@ -541,8 +576,9 @@ defineExpose({ period, dateFrom, dateTo, showCustom, selected, summary, finance,
<span class="d-flex align-center ga-2">
<span
class="num font-weight-bold"
:class="{ 'text-error': s.light === 'red' }"
>{{ s.ok ? rub(s.balance_amount) : 'нет данных' }}</span>
:class="{ 'text-error': !isLivenessOnly(s.service_key) && s.light === 'red' }"
>{{ serviceMoney(s) }}</span>
<span class="text-caption text-medium-emphasis">{{ serviceStatus(s) }}</span>
<v-icon :color="lightColor(s.light)" size="11" icon="mdi-circle" />
</span>
</div>
@@ -870,12 +906,13 @@ v-for="g in supply?.groups ?? []" :key="g.signal_type + '|' + g.identifier"
<!-- DRILL: БАЛАНСЫ СЕРВИСОВ -->
<v-card v-else-if="selected === 'balances'" variant="outlined" class="drill mt-5" data-testid="drill-balances">
<v-card-title class="drill__head">💳 Балансы внешних сервисов детали</v-card-title>
<v-card-title class="drill__head">🌐 Внешние сервисы баланс и доступность</v-card-title>
<v-card-text>
<v-alert variant="tonal" density="compact" class="mb-4" type="info">
Баланс платных сервисов проверяется раз в сутки (06:30 МСК). Светофор: 🔴 мало денег
или хватит меньше 3 дней, 🟡 меньше 7 дней, не удалось обновить.
Кнопка «Пополнить» открывает страницу оплаты сервиса.
Внешние сервисы проверяются раз в сутки (06:30 МСК): у платных остаток денег,
у остальных жив ли сервис. Светофор: 🔴 упал / мало денег / хватит меньше 3 дней,
🟡 меньше 7 дней, не удалось проверить или выключен. При переходе в 🔴 приходит
письмо на ops-адрес.
</v-alert>
<v-table density="compact">
<thead>
@@ -890,10 +927,10 @@ v-for="g in supply?.groups ?? []" :key="g.signal_type + '|' + g.identifier"
<tbody>
<tr v-for="s in balances?.services ?? []" :key="s.service_key">
<td>{{ serviceIcon(s.service_key) }} {{ serviceLabel(s.service_key) }}</td>
<td class="text-right num" :class="{ 'text-error': s.light === 'red' }">
{{ s.ok ? rub(s.balance_amount) : '—' }}
<td class="text-right num" :class="{ 'text-error': s.balance_amount !== null && s.light === 'red' }">
{{ serviceMoney(s) }}
</td>
<td class="text-right num">{{ s.ok ? daysLeftLabel(s.days_left) : '—' }}</td>
<td class="text-right num">{{ s.balance_amount !== null && s.ok ? daysLeftLabel(s.days_left) : '—' }}</td>
<td class="text-center">
<v-chip
:color="lightColor(s.light)"
@@ -901,7 +938,7 @@ v-for="g in supply?.groups ?? []" :key="g.signal_type + '|' + g.identifier"
variant="tonal"
:title="s.error ?? ''"
>
{{ s.ok ? 'ok' : 'не удалось обновить' }}
{{ serviceStatus(s) }}
</v-chip>
</td>
<td class="text-right">
@@ -0,0 +1,262 @@
<script setup lang="ts">
/**
* AdminInvoicesView — SaaS-admin экран «Счета» (Этап 1 «оплата по счёту»).
* Серверная пагинация/поиск/фильтр по статусу. Кнопка «Отметить оплаченным»
* у выставленных счетов открывает диалог подтверждения → markInvoicePaid →
* зачисление баланса + формирование Акта на бэкенде.
*/
import { ref, onMounted, watch } from 'vue';
import {
listAdminInvoices,
markInvoicePaid,
type AdminInvoiceRow,
} from '../../api/admin';
import { extractErrorMessage } from '../../api/client';
const STATUS_LABELS: Record<string, string> = {
draft: 'Черновик',
issued: 'Выставлен',
paid: 'Оплачен',
overdue: 'Просрочен',
cancelled: 'Отменён',
};
const STATUS_COLORS: Record<string, string> = {
issued: 'info',
paid: 'success',
overdue: 'warning',
cancelled: 'grey',
draft: 'grey',
};
const STATUS_FILTERS = [
{ value: '', title: 'Все статусы' },
{ value: 'issued', title: 'Выставленные' },
{ value: 'paid', title: 'Оплаченные' },
{ value: 'overdue', title: 'Просроченные' },
{ value: 'cancelled', title: 'Отменённые' },
];
const rows = ref<AdminInvoiceRow[]>([]);
const loading = ref(true);
const loadError = ref<string | null>(null);
const page = ref(1);
const perPage = ref(25);
const total = ref(0);
const lastPage = ref(1);
const search = ref('');
const filterStatus = ref('');
const confirmOpen = ref(false);
const confirmRow = ref<AdminInvoiceRow | null>(null);
const marking = ref(false);
const snackbar = ref(false);
const snackMsg = ref('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
function statusLabel(s: string): string {
return STATUS_LABELS[s] ?? s;
}
function statusColor(s: string): string {
return STATUS_COLORS[s] ?? 'grey';
}
function formatDate(iso: string | null): string {
return iso ? new Date(iso).toLocaleDateString('ru-RU', { timeZone: 'Europe/Moscow' }) : '—';
}
function formatAmount(v: string): string {
return new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 2 }).format(Number(v));
}
async function load(): Promise<void> {
loading.value = true;
loadError.value = null;
try {
const res = await listAdminInvoices({
status: filterStatus.value || undefined,
search: search.value || undefined,
page: page.value,
per_page: perPage.value,
});
rows.value = res.data;
total.value = res.meta.total;
lastPage.value = res.meta.last_page;
} catch (e) {
loadError.value = extractErrorMessage(e, 'Не удалось загрузить счета.');
} finally {
loading.value = false;
}
}
function goPage(p: number): void {
page.value = p;
void load();
}
watch(search, () => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
page.value = 1;
void load();
}, 400);
});
watch(filterStatus, () => {
page.value = 1;
void load();
});
function askMarkPaid(row: AdminInvoiceRow): void {
confirmRow.value = row;
confirmOpen.value = true;
}
async function doMarkPaid(): Promise<void> {
if (confirmRow.value === null) return;
marking.value = true;
try {
await markInvoicePaid(confirmRow.value.id);
snackMsg.value = `Счёт ${confirmRow.value.invoice_number} отмечен оплаченным, баланс зачислен.`;
snackbar.value = true;
confirmOpen.value = false;
confirmRow.value = null;
await load();
} catch (e) {
snackMsg.value = extractErrorMessage(e, 'Не удалось отметить оплату.');
snackbar.value = true;
} finally {
marking.value = false;
}
}
onMounted(load);
defineExpose({ rows, page, perPage, total, search, filterStatus, goPage, load });
</script>
<template>
<v-container class="invoices-admin" fluid>
<div class="page-head mb-4">
<h1 class="text-h5 page-title ma-0">Счета</h1>
</div>
<div class="filters mb-4">
<v-text-field
v-model="search"
label="Поиск по номеру, клиенту, плательщику"
density="comfortable"
hide-details
clearable
prepend-inner-icon="mdi-magnify"
style="max-width: 420px"
/>
<v-select
v-model="filterStatus"
:items="STATUS_FILTERS"
item-title="title"
item-value="value"
label="Статус"
density="comfortable"
hide-details
style="max-width: 220px"
/>
</div>
<v-card variant="outlined">
<div v-if="loading" class="py-10 d-flex justify-center">
<v-progress-circular indeterminate color="primary" />
</div>
<v-alert v-else-if="loadError" type="error" variant="tonal" class="ma-4" role="alert">
{{ loadError }}
</v-alert>
<div v-else-if="rows.length === 0" class="py-10 text-center text-medium-emphasis">
Счетов не найдено.
</div>
<v-table v-else>
<thead>
<tr>
<th>Дата</th>
<th>Номер</th>
<th>Клиент</th>
<th>Плательщик</th>
<th class="text-right">Сумма, </th>
<th>Статус</th>
<th>Оплатить до</th>
<th class="text-right">Действие</th>
</tr>
</thead>
<tbody>
<tr v-for="r in rows" :key="r.id">
<td class="num">{{ formatDate(r.issued_at) }}</td>
<td class="num">{{ r.invoice_number }}</td>
<td>{{ r.tenant_name ?? '—' }}</td>
<td>{{ r.payer_name ?? '—' }}</td>
<td class="text-right num">{{ formatAmount(r.amount_total) }}</td>
<td><v-chip :color="statusColor(r.status)" size="small" variant="tonal">{{ statusLabel(r.status) }}</v-chip></td>
<td class="num">{{ formatDate(r.expires_at) }}</td>
<td class="text-right">
<v-btn
v-if="r.status === 'issued' || r.status === 'overdue'"
color="success"
size="small"
variant="flat"
:data-testid="`mark-paid-${r.id}`"
@click="askMarkPaid(r)"
>
Отметить оплаченным
</v-btn>
</td>
</tr>
</tbody>
</v-table>
<div v-if="lastPage > 1" class="d-flex justify-center py-4">
<v-pagination
:model-value="page"
:length="lastPage"
:total-visible="7"
@update:model-value="goPage"
/>
</div>
</v-card>
<v-dialog v-model="confirmOpen" max-width="460">
<v-card v-if="confirmRow">
<v-card-title class="text-h6">Подтверждение оплаты</v-card-title>
<v-card-text>
Отметить счёт <b>{{ confirmRow.invoice_number }}</b> на сумму
<b>{{ formatAmount(confirmRow.amount_total) }} </b>
(клиент: {{ confirmRow.tenant_name ?? confirmRow.payer_name ?? '—' }}) как оплаченный?
Баланс клиента будет пополнен, сформируется Акт.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" :disabled="marking" @click="confirmOpen = false">Отмена</v-btn>
<v-btn color="success" variant="flat" :loading="marking" @click="doMarkPaid">Подтверждаю</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar v-model="snackbar" :timeout="5000" color="success">{{ snackMsg }}</v-snackbar>
</v-container>
</template>
<style scoped>
.page-title {
font-variation-settings: 'opsz' 24;
letter-spacing: -0.015em;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
}
</style>
@@ -88,7 +88,40 @@ function onSettingUpdated(payload: { key: string; value: string; updated_at: str
}
}
defineExpose({ settingsState, editOpen, editSetting, openEdit, onSettingUpdated, loadSettings, loading, fetchError });
// ─── Ручной баланс Яндекс 360 (почта) ───────────────────────────────────────
const y360Input = ref<string>('');
const y360Status = ref<adminApi.Yandex360BalanceStatus>({ balance: null, updated_at: null });
const y360Saving = ref(false);
const y360Error = ref<string | null>(null);
const Y360_TOPUP_URL = 'https://admin.yandex.ru/products';
async function loadY360Status() {
try {
y360Status.value = await adminApi.getYandex360Balance();
y360Input.value = y360Status.value.balance !== null ? String(y360Status.value.balance) : '';
} catch {
// тихо: статус не критичен для экрана
}
}
async function saveY360Balance() {
y360Saving.value = true;
y360Error.value = null;
try {
const val = y360Input.value.trim() === '' ? null : Number(y360Input.value.replace(',', '.'));
y360Status.value = await adminApi.setYandex360Balance(val);
} catch (err) {
y360Error.value = extractErrorMessage(err, 'Не удалось сохранить баланс.');
} finally {
y360Saving.value = false;
}
}
onMounted(() => {
loadY360Status();
});
defineExpose({ settingsState, editOpen, editSetting, openEdit, onSettingUpdated, loadSettings, loading, fetchError, y360Input, y360Status, saveY360Balance });
</script>
<template>
@@ -105,6 +138,41 @@ defineExpose({ settingsState, editOpen, editSetting, openEdit, onSettingUpdated,
<code>saas_admin_audit_log</code> с hash-chain (OPEN-И-15).
</v-alert>
<v-card variant="outlined" class="mb-4" data-testid="y360-balance-card">
<v-card-title class="text-subtitle-1"> Баланс почты (Яндекс 360)</v-card-title>
<v-card-text>
<v-alert type="info" variant="tonal" density="compact" class="mb-3">
У Яндекс 360 нет автоматического способа узнать баланс, поэтому впишите сумму вручную
она из кабинета Яндекс 360 «Оплата и тариф» (строка «Баланс»). Обновляйте по мере надобности;
на плитке она покажется со светофором. Кнопка «Открыть оплату» ведёт прямо в кабинет.
</v-alert>
<div class="mb-2 text-body-2">
Текущий баланс:
<strong v-if="y360Status.balance !== null" class="text-success">{{ y360Status.balance }} </strong>
<strong v-else class="text-medium-emphasis">не задан</strong>
<span v-if="y360Status.updated_at" class="text-medium-emphasis">
· обновлён {{ formatDate(y360Status.updated_at) }}</span>
</div>
<v-alert v-if="y360Error" type="warning" variant="tonal" density="compact" class="mb-2">
{{ y360Error }}
</v-alert>
<div class="d-flex align-center ga-3 flex-wrap">
<v-text-field
v-model="y360Input"
label="Баланс, ₽"
type="number"
density="compact"
hide-details
style="max-width: 200px"
/>
<v-btn color="primary" :loading="y360Saving" @click="saveY360Balance">Сохранить</v-btn>
<v-btn variant="text" :href="Y360_TOPUP_URL" target="_blank" prepend-icon="mdi-open-in-new">
Открыть оплату
</v-btn>
</div>
</v-card-text>
</v-card>
<v-alert
v-if="fetchError"
type="warning"
@@ -2,18 +2,21 @@
/**
* Админка → Тенанты. Список всех тенантов SaaS с балансами/тарифами/MRR.
*
* Sprint 4 Phase B/1 (audit O-refactor-04 хвост): UI-блоки выделены в
* components/admin/tenants/{TenantsStatsHeader,TenantsFilters,TenantsTable}.
* State (filterStatuses/filterTariffs/clearFilters/tenantsState/stats и др.)
* остаётся в этом view ради `defineExpose`-контракта, который Vitest тесты
* используют для прямого доступа.
* Масштаб (28.06.2026): серверная пагинация + серверные фильтры (search/статус/тариф).
* Раньше грузили всех разом и фильтровали в браузере — на 1000 клиентов это не
* «смотрибельно» (поиск/чипы видели только первую страницу). Теперь:
* - страница из `limit/offset` (perPage), счётчик `total` с сервера → v-pagination;
* - поиск (org/subdomain/email ILIKE) — серверный, debounce 400мс;
* - статус (производный trial/overdue/active/suspended) и тариф — серверные multi.
* Бэкенд: AdminTenantsController::index (statuses/tariffs/search/limit/offset/total).
*
* State (filterStatuses/filterTariffs/clearFilters/tenantsState/stats и др.) остаётся
* в этом view ради `defineExpose`-контракта Vitest-тестов.
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_admin.html секция #page-tenants.
* По схеме v8.7 §3 (tenants table) + ТЗ §22 (админка).
*
* Click по строке → /admin/tenants/{code} (карточка тенанта).
*/
import { computed, onMounted, reactive, ref } from 'vue';
import { onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { type AdminTenant, type TenantStatus } from '../../composables/mockTenants';
import { mapApiAdminTenant } from '../../composables/adminTenantsMapper';
@@ -35,34 +38,93 @@ const stats = reactive({ total: 0, active: 0, trial: 0, overdue: 0, monthlyReven
const loading = ref(false);
const fetchError = ref(false);
async function loadTenants() {
const search = ref('');
const filterStatuses = ref<TenantStatus[]>([]);
const filterTariffs = ref<string[]>([]);
const availableTariffs = ref<string[]>([]);
// Серверная пагинация.
const page = ref(1);
const perPage = ref(25);
const total = ref(0);
const totalPages = () => Math.max(1, Math.ceil(total.value / perPage.value));
async function loadTenants(): Promise<void> {
loading.value = true;
fetchError.value = false;
try {
const res = await adminApi.listAdminTenants();
const res = await adminApi.listAdminTenants({
search: search.value.trim(),
statuses: filterStatuses.value.join(','),
tariffs: filterTariffs.value.join(','),
limit: perPage.value,
offset: (page.value - 1) * perPage.value,
});
const mapped = res.tenants.map((t) => mapApiAdminTenant(t));
tenantsState.splice(0, tenantsState.length, ...mapped);
total.value = res.total;
stats.total = res.stats.total;
stats.active = res.stats.active;
stats.trial = res.stats.trial;
stats.overdue = res.stats.overdue;
} catch {
fetchError.value = true;
tenantsState.splice(0, tenantsState.length);
} finally {
loading.value = false;
}
}
onMounted(loadTenants);
// Опции тарифов для дропдауна — отдельным запросом (на странице видна только часть
// тенантов, поэтому список тарифов нельзя выводить из загруженного набора).
async function loadTariffOptions(): Promise<void> {
try {
const plans = await adminApi.listAdminTariffPlans();
availableTariffs.value = Array.from(new Set(plans.map((p) => p.name))).sort();
} catch {
// дропдаун останется пустым — не критично для основного списка.
}
}
// Поиск — debounce 400мс (планшет: печатает → ищет, без кнопки «Найти»).
let searchTimer: ReturnType<typeof setTimeout> | null = null;
watch(search, () => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
page.value = 1;
void loadTenants();
}, 400);
});
// Фильтры — сразу перезагрузка с 1-й страницы.
watch(
[filterStatuses, filterTariffs],
() => {
page.value = 1;
void loadTenants();
},
{ deep: true },
);
function goPage(p: number): void {
page.value = p;
void loadTenants();
}
onMounted(() => {
void loadTariffOptions();
void loadTenants();
});
usePolling(loadTenants);
function openTenantDetail(t: AdminTenant) {
function openTenantDetail(t: AdminTenant): void {
router.push({ name: 'admin-tenant-detail', params: { code: t.code } });
}
const search = ref('');
const filterStatuses = ref<TenantStatus[]>([]);
const filterTariffs = ref<string[]>([]);
function clearFilters(): void {
filterStatuses.value = [];
filterTariffs.value = [];
}
const impersonationOpen = ref(false);
const impersonationTenant = ref<AdminTenant | null>(null);
@@ -70,21 +132,14 @@ const impersonationTenant = ref<AdminTenant | null>(null);
const balanceDialogOpen = ref(false);
const balanceTarget = ref<AdminTenant | null>(null);
const availableTariffs = computed(() => Array.from(new Set(tenantsState.map((t) => t.tariff))).sort());
function clearFilters() {
filterStatuses.value = [];
filterTariffs.value = [];
}
const ADMIN_USER_ID = 1;
function openImpersonation(tenant: AdminTenant) {
function openImpersonation(tenant: AdminTenant): void {
impersonationTenant.value = tenant;
impersonationOpen.value = true;
}
function openBalanceDialog(tenant: AdminTenant) {
function openBalanceDialog(tenant: AdminTenant): void {
balanceTarget.value = tenant;
balanceDialogOpen.value = true;
}
@@ -106,22 +161,12 @@ defineExpose({
loading,
fetchError,
loadTenants,
});
const filteredTenants = computed<AdminTenant[]>(() => {
const q = search.value.trim().toLowerCase();
const statuses = new Set(filterStatuses.value);
const tariffs = new Set(filterTariffs.value);
return tenantsState.filter((t) => {
if (statuses.size > 0 && !statuses.has(t.status)) return false;
if (tariffs.size > 0 && !tariffs.has(t.tariff)) return false;
if (q) {
const haystack = `${t.name} ${t.inn} ${t.code}`.toLowerCase();
if (!haystack.includes(q)) return false;
}
return true;
});
search,
page,
perPage,
total,
availableTariffs,
goPage,
});
</script>
@@ -153,12 +198,24 @@ const filteredTenants = computed<AdminTenant[]>(() => {
/>
<TenantsTable
:tenants="filteredTenants"
:tenants="tenantsState"
@row-click="openTenantDetail"
@impersonate="openImpersonation"
@edit-balance="openBalanceDialog"
/>
<div class="d-flex align-center justify-space-between mt-3 flex-wrap ga-2">
<span class="text-medium-emphasis text-body-2">Всего: {{ total }}</span>
<v-pagination
v-model="page"
:length="totalPages()"
:total-visible="7"
density="compact"
data-testid="tenants-pager"
@update:model-value="goPage"
/>
</div>
<ImpersonationDialog v-model="impersonationOpen" :tenant="impersonationTenant" :requested-by="ADMIN_USER_ID" />
<TenantBalanceDialog
@@ -80,7 +80,7 @@
class="mb-4"
data-testid="np-lead-banner"
>
📣 Лидерра поставит проект в сбор сразу после создания. Первые лиды пойдут с {{ leadStart }}.
📣 Если баланса хватает проект запустится сразу. Иначе будет создан на паузе, и вы сможете пополнить баланс. Первые лиды пойдут с {{ leadStart }}.
</v-alert>
<div class="d-flex align-center mb-3 text-body-2 text-medium-emphasis" data-testid="np-boost-hint">
@@ -309,7 +309,6 @@
<ProjectLimitOverloadDialog
v-model="overloadOpen"
:payload="overloadPayload"
@save-blocked="onOverloadSaveBlocked"
@set-zero="onOverloadSetZero"
/>
@@ -614,7 +613,16 @@ async function persist(extra: Record<string, unknown> = {}): Promise<void> {
// Backend кладёт applies_from только когда правка задела slepok-чувствительные поля.
appliesFrom = data?.data?.applies_from ?? null;
} else {
await apiClient.post('/api/projects', body);
const { data } = await apiClient.post('/api/projects', body);
// Create всегда 201. Если launch.deferred=1 проект создан, но не запущен (баланс).
if (data?.launch?.deferred) {
// Проект создан закрываем форму и показываем диалог перегрузки.
emit('saved', null);
close();
overloadPayload.value = data.launch.balance ?? null;
overloadOpen.value = true;
return;
}
// Create НЕ генерирует applies_from (новый проект сразу попадает в snapshot).
}
overloadOpen.value = false;
@@ -630,9 +638,11 @@ async function persist(extra: Record<string, unknown> = {}): Promise<void> {
step.value = 'requisites';
void initCreateStep();
}
// Spec C §6.2: лимит превышает баланс открываем диалог перегрузки.
// Spec C §6.2: лимит превышает баланс при UPDATE (edit-режим) открываем диалог перегрузки.
// Payload вложен в поле balance (новый контракт update-409).
// CREATE не возвращает 409 по балансу используется launch.deferred в success-ветке выше.
else if (err.response?.status === 409 && err.response.data?.error === 'balance_insufficient') {
overloadPayload.value = err.response.data as OverloadPayloadShape;
overloadPayload.value = (err.response.data?.balance ?? null) as OverloadPayloadShape | null;
overloadOpen.value = true;
} else if (err.response?.status === 422 && err.response.data?.errors) {
Object.assign(errors, err.response.data.errors);
@@ -683,10 +693,6 @@ async function submit() {
}
// 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;
@@ -37,9 +37,9 @@ const lookupMessage = ref('');
const lookupError = ref(false);
const subjectTypes = [
{ value: 'individual', label: 'Физлицо' },
{ value: 'individual', label: 'Физическое лицо' },
{ value: 'sole_proprietor', label: 'ИП' },
{ value: 'legal_entity', label: 'Юрлицо' },
{ value: 'legal_entity', label: 'Юридическое лицо' },
];
const requiresInn = computed(
@@ -49,8 +49,10 @@ const requiresInn = computed(
const isLegalEntity = computed(() => form.subject_type === 'legal_entity');
const isSoleProprietor = computed(() => form.subject_type === 'sole_proprietor');
// Блок платёжных реквизитов виден, как только выбран тип лица.
const showPayment = computed(() => form.subject_type !== null);
// Блок платёжных реквизитов виден для ИП и юрлица; у физлица банковских реквизитов нет.
const showPayment = computed(
() => form.subject_type !== null && form.subject_type !== 'individual',
);
// КПП только юрлицо; ОГРН/ОГРНИП и юр.адрес юрлицо и ИП; банк всегда (когда showPayment).
const showKpp = computed(() => isLegalEntity.value);
const showOgrn = computed(() => isLegalEntity.value || isSoleProprietor.value);
@@ -0,0 +1,7 @@
<p>Проверка внешних сервисов в {{ $checkedAt->format('d.m.Y H:i') }} (МСК) выявила проблемы:</p>
<ul>
@foreach ($services as $s)
<li><strong>{{ $s['key'] }}</strong> {{ $s['detail'] }}</li>
@endforeach
</ul>
<p>Проверьте баланс / доступность сервиса в админке Лидерры (плитка «Внешние сервисы»).</p>
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<style>
body { font-family: 'dejavu sans'; font-size: 11px; color: #000; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #000; padding: 4px; text-align: left; }
th { background: #eee; }
h1 { font-size: 15px; margin: 12px 0; }
.right { text-align: right; }
.sign { margin-top: 30px; }
.sign td { border: none; padding: 8px 4px; }
</style>
</head>
<body>
<h1>Акт {{ $act->upd_number }} от {{ \Illuminate\Support\Carbon::parse($act->issued_at)->format('d.m.Y') }}</h1>
<p><b>Исполнитель:</b> {{ $seller->name }}, ИНН {{ $seller->inn }}{{ $seller->kpp ? ', КПП '.$seller->kpp : '' }}</p>
<p><b>Заказчик:</b> {{ $act->buyer_name }}, ИНН {{ $act->buyer_inn }}{{ $act->buyer_kpp ? ', КПП '.$act->buyer_kpp : '' }}</p>
<p><b>Основание:</b> счёт {{ $invoiceNumber }}</p>
<table>
<tr><th></th><th>Наименование услуги</th><th>Сумма</th></tr>
<tr><td>1</td><td>Оплата генерации рекламных лидов</td><td>{{ number_format((float) $act->amount_total, 2, '.', ' ') }} </td></tr>
</table>
<p class="right"><b>Всего оказано услуг на сумму: {{ number_format((float) $act->amount_total, 2, '.', ' ') }} </b><br>Без НДС</p>
<p>Вышеперечисленные услуги оказаны полностью и в срок. Заказчик претензий по объёму, качеству и срокам оказания услуг не имеет.</p>
<table class="sign">
<tr>
<td style="width:50%">Исполнитель<br><br>_______________ / {{ $seller->director_name ?? $seller->name }}</td>
<td style="width:50%">Заказчик<br><br>_______________ / {{ $act->buyer_name }}</td>
</tr>
</table>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<style>
body { font-family: 'dejavu sans'; font-size: 11px; color: #000; }
table { width: 100%; border-collapse: collapse; }
.bank td { border: 1px solid #000; padding: 4px; vertical-align: top; }
.items th, .items td { border: 1px solid #000; padding: 4px; text-align: left; }
.items th { background: #eee; }
h1 { font-size: 15px; margin: 12px 0; }
.right { text-align: right; }
.muted { color: #555; }
</style>
</head>
<body>
<table class="bank">
<tr>
<td rowspan="2" style="width:55%">{{ $seller->bank_name }}</td>
<td style="width:15%">БИК</td>
<td>{{ $seller->bank_bik }}</td>
</tr>
<tr>
<td>Сч. </td>
<td>{{ $seller->bank_corr }}</td>
</tr>
<tr>
<td>Получатель<br>{{ $seller->name }}<br>ИНН {{ $seller->inn }} {{ $seller->kpp ? 'КПП '.$seller->kpp : '' }}</td>
<td>Сч. </td>
<td>{{ $seller->bank_account }}</td>
</tr>
</table>
<h1>Счёт на оплату {{ $invoice->invoice_number }} от {{ \Illuminate\Support\Carbon::parse($invoice->issued_at)->format('d.m.Y') }}</h1>
<p><b>Поставщик (Исполнитель):</b> {{ $seller->name }}, ИНН {{ $seller->inn }}{{ $seller->kpp ? ', КПП '.$seller->kpp : '' }}{{ $seller->legal_address ? ', '.$seller->legal_address : '' }}</p>
<p><b>Покупатель (Заказчик):</b> {{ $invoice->payer_name }}, ИНН {{ $invoice->payer_inn }}{{ $invoice->payer_kpp ? ', КПП '.$invoice->payer_kpp : '' }}{{ $invoice->payer_address ? ', '.$invoice->payer_address : '' }}</p>
<table class="items">
<tr><th></th><th>Наименование</th><th>Кол-во</th><th>Ед.</th><th>Цена</th><th>Сумма</th></tr>
@foreach($items as $i => $it)
<tr>
<td>{{ $i + 1 }}</td>
<td>{{ $it->name }}</td>
<td>{{ (int) $it->quantity }}</td>
<td>{{ $it->unit }}</td>
<td>{{ number_format((float) $it->price, 2, '.', ' ') }}</td>
<td>{{ number_format((float) $it->amount_total, 2, '.', ' ') }}</td>
</tr>
@endforeach
</table>
<p class="right"><b>Итого: {{ number_format((float) $invoice->amount_total, 2, '.', ' ') }} </b><br>Без НДС</p>
<p><b>Назначение платежа:</b> {{ $invoice->payment_purpose }}</p>
<p class="muted">Оплатить до: {{ \Illuminate\Support\Carbon::parse($invoice->expires_at)->format('d.m.Y') }}</p>
</body>
</html>
+8
View File
@@ -104,6 +104,14 @@ Schedule::command('billing:preflight-sweep')
->onSuccess(fn () => $hb->recordRunResult('billing:preflight-sweep', true, null, null))
->onFailure(fn () => $hb->recordRunResult('billing:preflight-sweep', false, 'Command failed', null));
// Этап 1 «оплата по счёту»: просроченные неоплаченные счета → overdue.
// 03:40 МСК — после ночных ретеншен-задач, вне пиковых часов.
Schedule::command('invoices:expire')
->dailyAt('03:40')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('invoices:expire', true, null, null))
->onFailure(fn () => $hb->recordRunResult('invoices:expire', false, 'Command failed', null));
// Billing v2 Spec C §3.7: повторные письма заморозки (reminder +1д, final +3д).
// Идёт ПОСЛЕ основного sweep — если sweep только что заморозил тенанта, окно reminder
// (24h+) ещё не открылось, повторного письма в тот же день не будет (correct).
+13
View File
@@ -139,6 +139,11 @@ Route::middleware(['saas-admin', 'admin-db'])->group(function () {
// SaaS-admin → Биллинг: aggregates пополнений/списаний за текущий месяц.
Route::get('/api/admin/billing', 'App\Http\Controllers\Api\AdminBillingController@index');
// SaaS-admin → Счета: список выставленных счетов + ручная отметка оплаты (Этап 1).
Route::get('/api/admin/invoices', 'App\Http\Controllers\Api\AdminInvoiceController@index');
Route::post('/api/admin/invoices/{id}/mark-paid', 'App\Http\Controllers\Api\AdminInvoiceController@markPaid')
->whereNumber('id');
// Sprint 3D (G4): SaaS-admin billing row-actions — приостановка/возврат/смена тарифа.
Route::get('/api/admin/billing/tariff-plans', 'App\Http\Controllers\Api\AdminBillingController@tariffPlans');
Route::patch('/api/admin/billing/tenants/{id}/status', 'App\Http\Controllers\Api\AdminBillingController@updateStatus')
@@ -166,6 +171,10 @@ Route::middleware(['saas-admin', 'admin-db'])->group(function () {
Route::put('/{key}', 'App\Http\Controllers\Api\AdminSystemSettingsController@update')->where('key', '[a-z0-9_\.]+');
});
// SaaS-admin → Система: ручной баланс Яндекс 360 (почта).
Route::get('/api/admin/settings/yandex360-balance', 'App\Http\Controllers\Api\AdminYandex360BalanceController@show');
Route::put('/api/admin/settings/yandex360-balance', 'App\Http\Controllers\Api\AdminYandex360BalanceController@update');
// SaaS-admin → Биллинг: ввод секретных ключей платёжного шлюза (config зашифрован).
Route::put('/api/admin/payment-gateways/{code}', 'App\Http\Controllers\Api\AdminPaymentGatewayController@update')
->where('code', '[a-z0-9_]+');
@@ -238,6 +247,9 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing')->group(fun
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');
Route::post('/invoices', 'App\Http\Controllers\Api\InvoiceController@store');
Route::get('/invoices/{id}/pdf', 'App\Http\Controllers\Api\InvoiceController@pdf')->whereNumber('id');
Route::get('/invoices/{id}/act', 'App\Http\Controllers\Api\InvoiceController@act')->whereNumber('id');
});
// API-ключи тенанта (audit D2/D3/J5). RLS на api_keys требует tenant middleware.
@@ -381,6 +393,7 @@ Route::view('/import', 'welcome'); // Sprint 4 — CSV-импорт истори
Route::view('/admin', 'welcome');
Route::view('/admin/tenants', 'welcome');
Route::view('/admin/billing', 'welcome');
Route::view('/admin/invoices', 'welcome');
Route::view('/admin/incidents', 'welcome');
Route::view('/admin/system', 'welcome');
Route::view('/admin/pricing-tiers', 'welcome');
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use App\Services\External\Yandex360BalanceStore;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function () {
DB::table('system_settings')->where('key', 'yandex360_manual_balance')->delete();
});
it('GET отдаёт balance/updated_at (пусто по умолчанию)', function () {
$this->getJson('/api/admin/settings/yandex360-balance')
->assertOk()
->assertJson(['balance' => null, 'updated_at' => null]);
});
it('PUT сохраняет баланс', function () {
$this->putJson('/api/admin/settings/yandex360-balance', ['balance' => 1464.31])
->assertOk()
->assertJson(['balance' => 1464.31]);
expect(app(Yandex360BalanceStore::class)->get())->toBe(1464.31);
});
it('PUT с пустым значением очищает', function () {
app(Yandex360BalanceStore::class)->set(500.0);
$this->putJson('/api/admin/settings/yandex360-balance', ['balance' => ''])->assertOk();
expect(app(Yandex360BalanceStore::class)->get())->toBeNull();
});
it('PUT отвергает отрицательный баланс', function () {
$this->putJson('/api/admin/settings/yandex360-balance', ['balance' => -5])
->assertStatus(422);
});
@@ -170,6 +170,51 @@ test('GET /api/admin/tenants mrr_rub=null если current_tariff_id отсут
expect($r->json('tenants.0.mrr_rub'))->toBeNull();
});
test('GET /api/admin/tenants фильтрует по statuses (производный статус, multi)', function () {
// active: не trial, status=active, balance>=0, chargeback=0
Tenant::factory()->create(['organization_name' => 'AC', 'status' => 'active', 'is_trial' => false, 'balance_rub' => '100', 'chargeback_unrecovered_rub' => '0']);
// trial: is_trial=true (приоритет выше всех)
Tenant::factory()->create(['organization_name' => 'TR', 'status' => 'active', 'is_trial' => true, 'balance_rub' => '0']);
// overdue: не trial, balance<0
Tenant::factory()->create(['organization_name' => 'OV', 'status' => 'active', 'is_trial' => false, 'balance_rub' => '-50', 'chargeback_unrecovered_rub' => '0']);
// suspended: status=suspended
Tenant::factory()->create(['organization_name' => 'SU', 'status' => 'suspended', 'is_trial' => false, 'balance_rub' => '100', 'chargeback_unrecovered_rub' => '0']);
$r = $this->getJson('/api/admin/tenants?statuses=overdue,trial');
$names = collect($r->json('tenants'))->pluck('organization_name')->all();
expect($r->json('total'))->toBe(2);
expect($names)->toContain('TR');
expect($names)->toContain('OV');
expect($names)->not->toContain('AC');
expect($names)->not->toContain('SU');
});
test('GET /api/admin/tenants фильтрует по tariffs (имя тарифа, multi)', function () {
$mk = fn (string $name): int => (int) DB::table('tariff_plans')->insertGetId([
'code' => 'tp_'.bin2hex(random_bytes(4)),
'name' => $name,
'billing_model' => 'monthly',
'price_monthly' => 990.00,
'is_active' => true,
'is_public' => true,
'sort_order' => 1,
'created_at' => now(),
]);
$proId = $mk('Pro');
$teamId = $mk('Команда');
$p = Tenant::factory()->create(['organization_name' => 'P1']);
DB::table('tenants')->where('id', $p->id)->update(['current_tariff_id' => $proId]);
$k = Tenant::factory()->create(['organization_name' => 'K1']);
DB::table('tenants')->where('id', $k->id)->update(['current_tariff_id' => $teamId]);
$r = $this->getJson('/api/admin/tenants?tariffs=Pro');
expect($r->json('total'))->toBe(1);
expect($r->json('tenants.0.organization_name'))->toBe('P1');
});
test('GET /api/admin/tenants поддерживает limit + offset', function () {
foreach (range(1, 5) as $i) {
Tenant::factory()->create([
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
use App\Models\LegalEntity;
use App\Models\SaasInvoice;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Storage;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
function adminSeedInvoice(int $tenantId, int $legalEntityId, string $status, string $amount, string $number): SaasInvoice
{
return SaasInvoice::create([
'tenant_id' => $tenantId, 'legal_entity_id' => $legalEntityId, 'invoice_number' => $number,
'payer_type' => 'legal', 'payer_name' => 'ООО Клиент', 'payer_inn' => '5000000000',
'amount_net' => $amount, 'amount_total' => $amount, 'status' => $status,
'issued_at' => now(), 'expires_at' => now()->addDays(5),
]);
}
it('GET /api/admin/invoices отдаёт выставленные счета с пагинацией', function () {
$tenant = Tenant::factory()->create();
$le = LegalEntity::create(['code' => 'al_'.uniqid(), 'name' => 'ИП', 'legal_form' => 'IP', 'inn' => '770000000010', 'is_default' => true]);
adminSeedInvoice($tenant->id, $le->id, 'issued', '100.00', 'СЧ-2026-01001');
adminSeedInvoice($tenant->id, $le->id, 'issued', '200.00', 'СЧ-2026-01002');
adminSeedInvoice($tenant->id, $le->id, 'paid', '300.00', 'СЧ-2026-01003');
$this->getJson('/api/admin/invoices?status=issued')
->assertOk()
->assertJsonStructure(['data' => [['id', 'invoice_number', 'amount_total', 'status', 'tenant_id']], 'meta' => ['total']])
->assertJsonPath('meta.total', 2);
});
it('POST /api/admin/invoices/{id}/mark-paid зачисляет баланс и ставит paid', function () {
Storage::fake('local');
$tenant = Tenant::factory()->create(['balance_rub' => '0.00']);
User::factory()->create(['tenant_id' => $tenant->id]);
$le = LegalEntity::create(['code' => 'al2_'.uniqid(), 'name' => 'ИП', 'legal_form' => 'IP', 'inn' => '770000000011', 'is_default' => true]);
$invoice = adminSeedInvoice($tenant->id, $le->id, 'issued', '700.00', 'СЧ-2026-01010');
$this->postJson("/api/admin/invoices/{$invoice->id}/mark-paid")->assertOk();
expect((string) $tenant->fresh()->balance_rub)->toBe('700.00')
->and(SaasInvoice::find($invoice->id)->status)->toBe('paid');
});
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use App\Models\LegalEntity;
use App\Models\SaasInvoice;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);
function expSeed(int $tenantId, int $leId, string $status, string $number, $expiresAt): SaasInvoice
{
return SaasInvoice::create([
'tenant_id' => $tenantId, 'legal_entity_id' => $leId, 'invoice_number' => $number,
'payer_type' => 'legal', 'amount_net' => '100.00', 'amount_total' => '100.00',
'status' => $status, 'issued_at' => now()->subDays(10), 'expires_at' => $expiresAt,
]);
}
it('помечает overdue только просроченные неоплаченные счета', function () {
$t = Tenant::factory()->create();
$le = LegalEntity::create(['code' => 'exp_'.uniqid(), 'name' => 'ИП', 'legal_form' => 'IP', 'inn' => '770000000020']);
$stale = expSeed($t->id, $le->id, 'issued', 'СЧ-2026-02001', now()->subDay());
$fresh = expSeed($t->id, $le->id, 'issued', 'СЧ-2026-02002', now()->addDay());
$paid = expSeed($t->id, $le->id, 'paid', 'СЧ-2026-02003', now()->subDay());
$this->artisan('invoices:expire')->assertExitCode(0);
expect(SaasInvoice::find($stale->id)->status)->toBe('overdue')
->and(SaasInvoice::find($fresh->id)->status)->toBe('issued')
->and(SaasInvoice::find($paid->id)->status)->toBe('paid');
});
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
use App\Models\LegalEntity;
use App\Models\SaasInvoice;
use App\Models\Tenant;
use App\Models\TenantRequisites;
use App\Models\User;
use App\Services\Billing\Invoice\InvoiceService;
use App\Services\Billing\Invoice\RequisitesIncompleteException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Storage;
uses(DatabaseTransactions::class);
function makeSellerLe(): LegalEntity
{
return LegalEntity::create([
'code' => 'seller_'.uniqid(), 'name' => 'ИП Лидерра', 'legal_form' => 'IP',
'inn' => '770000000001', 'bank_name' => 'ВТБ', 'bank_bik' => '044525187',
'bank_account' => '40802810000000000001', 'bank_corr' => '30101810700000000187',
'is_default' => true,
]);
}
function makeClientRequisites(int $tenantId): TenantRequisites
{
return TenantRequisites::create([
'tenant_id' => $tenantId,
'subject_type' => 'legal_entity',
'contact_name' => 'Иван Клиентов',
'contact_phone' => '+79150000000',
'inn' => '5000000000',
'legal_name' => 'ООО Клиент',
'kpp' => '500001001',
'legal_address' => 'г. Москва, ул. Пример, 1',
'bank_account' => '40702810000000000002',
]);
}
it('создаёт счёт issued с позицией, без НДС, номером и PDF', function () {
Storage::fake('local');
$tenant = Tenant::factory()->create();
makeSellerLe();
makeClientRequisites($tenant->id);
$invoice = app(InvoiceService::class)->create($tenant->id, '1500.00', null);
expect($invoice->status)->toBe(SaasInvoice::STATUS_ISSUED)
->and((string) $invoice->amount_total)->toBe('1500.00')
->and((float) $invoice->vat_amount)->toBe(0.0)
->and($invoice->invoice_number)->toStartWith('СЧ-')
->and($invoice->pdf_path)->not->toBeNull()
->and($invoice->payer_name)->toBe('ООО Клиент')
->and($invoice->items()->count())->toBe(1)
->and($invoice->payment_purpose)->toContain($invoice->invoice_number);
Storage::disk('local')->assertExists($invoice->pdf_path);
});
it('бросает доменную ошибку если реквизиты клиента не заполнены', function () {
$tenant = Tenant::factory()->create();
makeSellerLe();
app(InvoiceService::class)->create($tenant->id, '1500.00', null);
})->throws(RequisitesIncompleteException::class);
it('POST /api/billing/invoices создаёт счёт и возвращает 201 с pdf-ссылкой', function () {
Storage::fake('local');
$tenant = Tenant::factory()->create();
makeSellerLe();
makeClientRequisites($tenant->id);
$this->actingAs(User::factory()->create(['tenant_id' => $tenant->id]));
$this->postJson('/api/billing/invoices', ['amount_rub' => 2000])
->assertStatus(201)
->assertJsonStructure(['invoice' => ['id', 'invoice_number', 'amount_total', 'pdf_url']]);
});
it('POST /api/billing/invoices без реквизитов → 422', function () {
Storage::fake('local');
$tenant = Tenant::factory()->create();
makeSellerLe();
$this->actingAs(User::factory()->create(['tenant_id' => $tenant->id]));
$this->postJson('/api/billing/invoices', ['amount_rub' => 2000])->assertStatus(422);
});
it('GET /api/billing/invoices/{id}/pdf скачивает PDF своего счёта', function () {
Storage::fake('local');
$tenant = Tenant::factory()->create();
makeSellerLe();
makeClientRequisites($tenant->id);
$this->actingAs(User::factory()->create(['tenant_id' => $tenant->id]));
$invoice = app(InvoiceService::class)->create($tenant->id, '2000.00', null);
$this->get("/api/billing/invoices/{$invoice->id}/pdf")
->assertOk()
->assertHeader('content-type', 'application/pdf');
});
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use App\Mail\InvoicePaidNotification;
use App\Models\LegalEntity;
use App\Models\SaasInvoice;
use App\Models\SaasTransaction;
use App\Models\SaasUpdDocument;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Billing\Invoice\InvoicePaymentService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
uses(DatabaseTransactions::class);
function seedPaidScenario(string $balance, string $amount): array
{
$tenant = Tenant::factory()->create(['balance_rub' => $balance]);
User::factory()->create(['tenant_id' => $tenant->id]);
$le = LegalEntity::create([
'code' => 'mp_'.uniqid(), 'name' => 'ИП Лидерра', 'legal_form' => 'IP',
'inn' => '770000000099', 'is_default' => true,
]);
$invoice = SaasInvoice::create([
'tenant_id' => $tenant->id, 'legal_entity_id' => $le->id,
'invoice_number' => 'СЧ-2026-00777', 'payer_type' => 'legal', 'payer_name' => 'ООО К',
'payer_inn' => '5000000000', 'amount_net' => $amount, 'amount_total' => $amount,
'status' => SaasInvoice::STATUS_ISSUED, 'issued_at' => now(), 'expires_at' => now()->addDays(5),
]);
return [$tenant, $invoice];
}
it('mark-paid зачисляет баланс, ставит paid, создаёт акт и шлёт письмо', function () {
Storage::fake('local');
Mail::fake();
[$tenant, $invoice] = seedPaidScenario('100.00', '1500.00');
app(InvoicePaymentService::class)->markPaid($invoice->id);
$invoice->refresh();
$tenant->refresh();
expect($invoice->status)->toBe(SaasInvoice::STATUS_PAID)
->and($invoice->paid_at)->not->toBeNull()
->and((string) $tenant->balance_rub)->toBe('1600.00')
->and(SaasTransaction::where('invoice_id', $invoice->id)->where('status', 'success')->count())->toBe(1)
->and(SaasUpdDocument::where('invoice_id', $invoice->id)->count())->toBe(1);
$actPath = SaasUpdDocument::where('invoice_id', $invoice->id)->value('pdf_path');
Mail::assertQueued(InvoicePaidNotification::class, fn ($mail) => $mail->actPdfPath === $actPath
&& count($mail->attachments()) === 1);
});
it('повторный mark-paid идемпотентен — баланс не удваивается, второй акт не создаётся', function () {
Storage::fake('local');
Mail::fake();
[$tenant, $invoice] = seedPaidScenario('0.00', '500.00');
$svc = app(InvoicePaymentService::class);
$svc->markPaid($invoice->id);
$svc->markPaid($invoice->id);
$tenant->refresh();
expect((string) $tenant->balance_rub)->toBe('500.00')
->and(SaasUpdDocument::where('invoice_id', $invoice->id)->count())->toBe(1);
});
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
use App\Models\LegalEntity;
use App\Models\SaasInvoice;
use App\Models\Tenant;
use App\Services\Billing\Invoice\InvoiceNumberGenerator;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;
uses(DatabaseTransactions::class);
function makeLeForNumbering(): LegalEntity
{
return LegalEntity::create([
'code' => 'le_num_'.uniqid(), 'name' => 'ИП Тест', 'legal_form' => 'IP', 'inn' => '770000000000',
]);
}
function seedNumberingInvoice(int $tenantId, int $legalEntityId, string $number, string $issuedAt): SaasInvoice
{
return SaasInvoice::create([
'tenant_id' => $tenantId,
'legal_entity_id' => $legalEntityId,
'invoice_number' => $number,
'payer_type' => 'legal',
'amount_net' => '100.00',
'amount_total' => '100.00',
'status' => SaasInvoice::STATUS_ISSUED,
'issued_at' => $issuedAt,
'expires_at' => $issuedAt,
]);
}
it('первый счёт юрлица за год получает номер -00001', function () {
$le = makeLeForNumbering();
$num = (new InvoiceNumberGenerator)->next($le->id, Carbon::parse('2026-06-29 12:00:00'));
expect($num)->toBe('СЧ-2026-00001');
});
it('следующий номер инкрементируется по существующим счетам того же юрлица/года', function () {
$tenant = Tenant::factory()->create();
$le = makeLeForNumbering();
seedNumberingInvoice($tenant->id, $le->id, 'СЧ-2026-00007', '2026-03-01 00:00:00');
$num = (new InvoiceNumberGenerator)->next($le->id, Carbon::parse('2026-06-29 12:00:00'));
expect($num)->toBe('СЧ-2026-00008');
});
it('нумерация изолирована по юрлицу', function () {
$tenant = Tenant::factory()->create();
$leA = makeLeForNumbering();
$leB = makeLeForNumbering();
seedNumberingInvoice($tenant->id, $leA->id, 'СЧ-2026-00042', '2026-02-01 00:00:00');
expect((new InvoiceNumberGenerator)->next($leB->id, Carbon::parse('2026-06-29 12:00:00')))
->toBe('СЧ-2026-00001');
});
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
use App\Jobs\External\RefreshExternalBalancesJob;
use App\Mail\ExternalServiceDownMail;
use App\Services\External\BalanceReading;
use App\Services\External\DadataBalanceProvider;
use App\Services\External\LivenessReading;
use App\Services\External\SupplierBalanceProvider;
use App\Services\External\YandexCloudBalanceProvider;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
afterEach(fn () => RefreshExternalBalancesJob::resetLivenessProbes());
function stubBalanceProvidersOk(): void
{
app()->instance(DadataBalanceProvider::class, fakeProvider('dadata', BalanceReading::ok('dadata', 9000, 'RUB', 100)));
app()->instance(SupplierBalanceProvider::class, fakeProvider('supplier', BalanceReading::ok('supplier', 90000, 'RUB', null)));
app()->instance(YandexCloudBalanceProvider::class, fakeProvider('yandex_cloud', BalanceReading::ok('yandex_cloud', 90000, 'RUB', 100)));
}
it('шлёт одно письмо, когда сервис впервые покраснел', function () {
Mail::fake();
stubBalanceProvidersOk();
RefreshExternalBalancesJob::useLivenessProbes([
fakeProbe('jivosite', LivenessReading::down('jivosite', 'HTTP 500')),
]);
(new RefreshExternalBalancesJob)->handle();
Mail::assertSent(ExternalServiceDownMail::class, 1);
});
it('не шлёт письмо, если сервис уже был красным (без спама)', function () {
Mail::fake();
stubBalanceProvidersOk();
// Предзаливаем строку: jivosite уже красный.
DB::connection('pgsql_supplier')->table('external_service_balances')->updateOrInsert(
['service_key' => 'jivosite'],
['light' => 'red', 'ok' => true, 'currency' => 'RUB', 'checked_at' => now(), 'updated_at' => now()],
);
RefreshExternalBalancesJob::useLivenessProbes([
fakeProbe('jivosite', LivenessReading::down('jivosite', 'HTTP 500')),
]);
(new RefreshExternalBalancesJob)->handle();
Mail::assertNothingSent();
});
it('не шлёт письмо, когда всё зелёное', function () {
Mail::fake();
stubBalanceProvidersOk();
RefreshExternalBalancesJob::useLivenessProbes([
fakeProbe('jivosite', LivenessReading::alive('jivosite', 'ок')),
]);
(new RefreshExternalBalancesJob)->handle();
Mail::assertNothingSent();
});
+44 -19
View File
@@ -3,35 +3,31 @@
declare(strict_types=1);
use App\Jobs\External\RefreshExternalBalancesJob;
use App\Services\External\BalanceProvider;
use App\Services\External\BalanceReading;
use App\Services\External\DadataBalanceProvider;
use App\Services\External\LivenessReading;
use App\Services\External\SupplierBalanceProvider;
use App\Services\External\Yandex360BalanceProvider;
use App\Services\External\YandexCloudBalanceProvider;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class); // запись идёт через pgsql_supplier
/** Стаб-провайдер с заранее заданным результатом. */
function fakeProvider(string $key, BalanceReading $reading): BalanceProvider
{
return new class($key, $reading) implements BalanceProvider
{
public function __construct(private string $key, private BalanceReading $reading) {}
beforeEach(function () {
Mail::fake(); // этот файл про запись балансов, не про письма (алерт — в ExternalServiceDownAlertTest)
// email — денежный сервис (Yandex 360). Дефолтный стаб (зелёный, выше порогов); тесты могут переопределить.
app()->instance(
Yandex360BalanceProvider::class,
fakeProvider('email', BalanceReading::ok('email', 5000, 'RUB', null)),
);
});
public function serviceKey(): string
{
return $this->key;
}
afterEach(fn () => RefreshExternalBalancesJob::resetLivenessProbes());
public function fetch(): BalanceReading
{
return $this->reading;
}
};
}
// Стабы fakeProvider()/fakeProbe() — глобальные хелперы в tests/Pest.php.
it('пишет балансы трёх сервисов + считает светофор', function () {
config()->set('services.yandex_cloud.red_floor_rub', 1000);
@@ -40,11 +36,12 @@ it('пишет балансы трёх сервисов + считает све
app()->instance(DadataBalanceProvider::class, fakeProvider('dadata', BalanceReading::ok('dadata', 4500, 'RUB', 100)));
app()->instance(SupplierBalanceProvider::class, fakeProvider('supplier', BalanceReading::ok('supplier', 50000, 'RUB', null)));
app()->instance(YandexCloudBalanceProvider::class, fakeProvider('yandex_cloud', BalanceReading::ok('yandex_cloud', -540.48, 'RUB', 600)));
RefreshExternalBalancesJob::useLivenessProbes([]); // этот тест — только про балансы
(new RefreshExternalBalancesJob)->handle();
$rows = DB::connection('pgsql_supplier')->table('external_service_balances')->get()->keyBy('service_key');
expect($rows)->toHaveCount(3);
expect($rows)->toHaveCount(4); // dadata/supplier/yandex_cloud + email (денежный)
expect((float) $rows['yandex_cloud']->balance_amount)->toBe(-540.48);
expect($rows['yandex_cloud']->light)->toBe('red'); // минус < red_floor
expect((bool) $rows['yandex_cloud']->ok)->toBeTrue();
@@ -55,18 +52,20 @@ it('повторный запуск обновляет строки, а не п
app()->instance(DadataBalanceProvider::class, fakeProvider('dadata', BalanceReading::ok('dadata', 4500, 'RUB', 100)));
app()->instance(SupplierBalanceProvider::class, fakeProvider('supplier', BalanceReading::fail('supplier', 'таймаут')));
app()->instance(YandexCloudBalanceProvider::class, fakeProvider('yandex_cloud', BalanceReading::ok('yandex_cloud', 42000, 'RUB', 600)));
RefreshExternalBalancesJob::useLivenessProbes([]); // этот тест — только про балансы
(new RefreshExternalBalancesJob)->handle();
(new RefreshExternalBalancesJob)->handle(); // второй прогон не должен бросить UniqueConstraint
$rows = DB::connection('pgsql_supplier')->table('external_service_balances')->get();
expect($rows)->toHaveCount(3); // строк по-прежнему 3, без дублей
expect($rows)->toHaveCount(4); // dadata/supplier/yandex_cloud + email, без дублей
});
it('упавший провайдер не роняет джобу и сохраняет ошибку, остальные пишутся', function () {
app()->instance(DadataBalanceProvider::class, fakeProvider('dadata', BalanceReading::fail('dadata', 'HTTP 403')));
app()->instance(SupplierBalanceProvider::class, fakeProvider('supplier', BalanceReading::ok('supplier', 50000, 'RUB', null)));
app()->instance(YandexCloudBalanceProvider::class, fakeProvider('yandex_cloud', BalanceReading::ok('yandex_cloud', 42000, 'RUB', 600)));
RefreshExternalBalancesJob::useLivenessProbes([]); // этот тест — только про балансы
(new RefreshExternalBalancesJob)->handle();
@@ -76,3 +75,29 @@ it('упавший провайдер не роняет джобу и сохра
expect((bool) $rows['supplier']->ok)->toBeTrue();
expect((bool) $rows['yandex_cloud']->ok)->toBeTrue();
});
it('пишет строки живости: balance_amount NULL, цвет из пробы', function () {
// Балансовые провайдеры — заглушки-ок, чтобы не ходить в сеть.
app()->instance(DadataBalanceProvider::class, fakeProvider('dadata', BalanceReading::ok('dadata', 4500, 'RUB', 100)));
app()->instance(SupplierBalanceProvider::class, fakeProvider('supplier', BalanceReading::ok('supplier', 50000, 'RUB', null)));
app()->instance(YandexCloudBalanceProvider::class, fakeProvider('yandex_cloud', BalanceReading::ok('yandex_cloud', 42000, 'RUB', 600)));
// email — денежный (переопределяем дефолтный стаб из beforeEach конкретной суммой).
app()->instance(Yandex360BalanceProvider::class, fakeProvider('email', BalanceReading::ok('email', 777, 'RUB', null)));
RefreshExternalBalancesJob::useLivenessProbes([
fakeProbe('jivosite', LivenessReading::down('jivosite', 'HTTP 500')),
fakeProbe('captcha', LivenessReading::unknown('captcha', 'выключена')),
]);
(new RefreshExternalBalancesJob)->handle();
$rows = DB::connection('pgsql_supplier')->table('external_service_balances')->get()->keyBy('service_key');
expect($rows)->toHaveCount(6); // 4 деньги (вкл. email) + 2 живость
expect((float) $rows['email']->balance_amount)->toBe(777.0);
expect((bool) $rows['email']->ok)->toBeTrue();
expect($rows['jivosite']->light)->toBe('red');
expect((bool) $rows['jivosite']->ok)->toBeTrue(); // ok=true: статус свежий и определённый (упал)
expect($rows['jivosite']->error)->toContain('500');
expect($rows['captcha']->light)->toBe('grey');
expect((bool) $rows['captcha']->ok)->toBeFalse(); // grey = не смогли/не применимо
});
@@ -112,7 +112,7 @@ describe('AdminDashboardView.vue', () => {
expect(text).toContain('Здоровье портала');
expect(text).toContain('Лиды');
expect(text).toContain('Заказ у поставщика');
expect(text).toContain('Балансы сервисов');
expect(text).toContain('Внешние сервисы');
expect(text).toContain('Клиенты');
});
@@ -0,0 +1,64 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import AdminInvoicesView from '../../resources/js/views/admin/AdminInvoicesView.vue';
import * as adminApi from '../../resources/js/api/admin';
const oneIssued = {
data: [
{
id: 5,
invoice_number: 'СЧ-2026-00005',
amount_total: '700.00',
status: 'issued',
issued_at: '2026-06-29',
expires_at: null,
tenant_id: 2,
tenant_name: 'ООО Клиент',
payer_name: 'ООО Клиент',
},
],
meta: { total: 1, current_page: 1, last_page: 1, per_page: 25 },
};
describe('AdminInvoicesView', () => {
beforeEach(() => vi.clearAllMocks());
it('рендерит счёт и его статус', async () => {
vi.spyOn(adminApi, 'listAdminInvoices').mockResolvedValue(oneIssued);
const w = mount(AdminInvoicesView, { global: { plugins: [createVuetify()] } });
await flushPromises();
expect(w.text()).toContain('СЧ-2026-00005');
expect(w.text()).toContain('Выставлен');
});
it('«Отметить оплаченным» открывает диалог и зовёт markInvoicePaid после подтверждения', async () => {
vi.spyOn(adminApi, 'listAdminInvoices').mockResolvedValue(oneIssued);
const spy = vi.spyOn(adminApi, 'markInvoicePaid').mockResolvedValue();
const w = mount(AdminInvoicesView, {
global: {
plugins: [createVuetify()],
stubs: {
VDialog: {
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
props: ['modelValue'],
},
},
},
});
await flushPromises();
const btn = w.find('[data-testid="mark-paid-5"]');
expect(btn.exists()).toBe(true);
await btn.trigger('click');
await w.vm.$nextTick();
const confirm = w.findAll('button').find((b) => b.text().includes('Подтверждаю'));
expect(confirm).toBeTruthy();
await confirm!.trigger('click');
await flushPromises();
expect(spy).toHaveBeenCalledWith(5);
});
});
@@ -51,8 +51,9 @@ describe('AdminPricingTiersView', () => {
it('renders 7 tier rows from /api/admin/pricing-tiers', async () => {
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
expect(wrapper.text()).toContain('500.00');
expect(wrapper.text()).toContain('250.00');
// fmtRub форматирует как «500 ₽» (целое, без хвостовых нулей), не «500.00».
expect(wrapper.text()).toContain('500 ₽');
expect(wrapper.text()).toContain('250 ₽');
});
it('shows "все свыше" for tier 7 with leads_in_tier=null', async () => {
@@ -44,18 +44,36 @@ describe('AdminSupplierIntegrationView — manual queue section', () => {
expect(text).toContain('B1');
});
it('clicking «Отметить выполнено» calls resolve endpoint', async () => {
it('clicking «Отметить выполнено» → подтверждение в диалоге → calls resolve endpoint', async () => {
(axios.post as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { resolved: true, external_id: 700123 },
});
vi.spyOn(window, 'confirm').mockReturnValue(true);
const wrapper = mount(AdminSupplierIntegrationView, { global: { plugins: [vuetify] } });
// window.confirm заменён на v-dialog (UI-аудит). Стабим VDialog passthrough,
// чтобы контент диалога рендерился инлайн и кнопка «Подтверждаю» была кликабельна.
const wrapper = mount(AdminSupplierIntegrationView, {
global: {
plugins: [vuetify],
stubs: {
VDialog: {
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
props: ['modelValue'],
},
},
},
});
await new Promise((r) => setTimeout(r, 50));
// Клик по «Отметить выполнено» открывает диалог подтверждения (askResolve).
const btn = wrapper.find('[data-testid="resolve-1"]');
expect(btn.exists()).toBe(true);
await btn.trigger('click');
await wrapper.vm.$nextTick();
// Подтверждаем в диалоге → doResolve → POST.
const confirmBtn = wrapper.findAll('button').find((b) => b.text().includes('Подтверждаю'));
expect(confirmBtn).toBeTruthy();
await confirmBtn!.trigger('click');
expect(axios.post).toHaveBeenCalledWith(expect.stringContaining('/manual-queue/1/resolve'));
});
+50 -40
View File
@@ -5,21 +5,22 @@ import { createRouter, createMemoryHistory } from 'vue-router';
import AdminTenantsView from '../../resources/js/views/admin/AdminTenantsView.vue';
import { MOCK_STATS, MOCK_TENANTS, type AdminTenant } from '../../resources/js/composables/mockTenants';
// Мокаем api/admin: listAdminTenants возвращает пустой ответ
// smoke-тесты затем seed'ят tenantsState/stats напрямую через vm (defineExpose).
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
return {
...orig,
listAdminTenants: vi.fn().mockResolvedValue({
tenants: [],
total: 0,
limit: 100,
offset: 0,
stats: { total: 0, active: 0, trial: 0, overdue: 0 },
}),
};
});
// listAdminTenants мокаем пустым ответом — smoke/render-тесты затем seed'ят
// tenantsState/stats напрямую через vm (defineExpose). Серверные фильтры/пагинация:
// фильтр-тесты проверяют, что view зовёт listAdminTenants с правильными параметрами.
vi.mock('../../resources/js/api/admin', () => ({
listAdminTenants: vi.fn().mockResolvedValue({
tenants: [],
total: 0,
limit: 25,
offset: 0,
stats: { total: 0, active: 0, trial: 0, overdue: 0 },
}),
listAdminTariffPlans: vi.fn().mockResolvedValue([
{ id: 1, name: 'Команда', price_monthly: '990.00' },
{ id: 2, name: 'Pro', price_monthly: '2990.00' },
]),
}));
beforeEach(() => {
vi.clearAllMocks();
@@ -28,7 +29,6 @@ beforeEach(() => {
describe('AdminTenantsView.vue', () => {
/** Монтирует view, ждёт mount-цикл, затем seed'ит state фикстурами. */
const factory = async () => {
// useRouter() в AdminTenantsView требует router-context в тестах.
const router = createRouter({
history: createMemoryHistory(),
routes: [
@@ -41,18 +41,19 @@ describe('AdminTenantsView.vue', () => {
const wrapper = mount(AdminTenantsView, {
global: {
plugins: [createVuetify(), router],
// ImpersonationDialog + TenantBalanceDialog stubим — внутри используют api/admin axios.
stubs: { ImpersonationDialog: true, TenantBalanceDialog: true },
},
});
await flushPromises();
// Seed state напрямую через defineExpose — имитирует успешную загрузку с теми же фикстурами.
// Seed state напрямую через defineExpose — имитирует успешную загрузку страницы.
const vm = wrapper.vm as unknown as {
tenantsState: AdminTenant[];
stats: typeof MOCK_STATS;
total: number;
};
vm.tenantsState.splice(0, vm.tenantsState.length, ...MOCK_TENANTS.map((t) => ({ ...t })));
Object.assign(vm.stats, MOCK_STATS);
vm.total = MOCK_TENANTS.length;
await wrapper.vm.$nextTick();
return wrapper;
};
@@ -86,7 +87,7 @@ describe('AdminTenantsView.vue', () => {
});
});
it('рендерит все 7 mock-tenants', async () => {
it('рендерит все 7 mock-tenants текущей страницы', async () => {
const wrapper = await factory();
const rows = wrapper.findAll('tbody tr');
expect(rows.length).toBe(MOCK_TENANTS.length);
@@ -130,14 +131,14 @@ describe('AdminTenantsView.vue', () => {
expect(input.attributes('placeholder')).toContain('ИНН');
});
it('фильтр по search оставляет только matching-tenants', async () => {
it('поиск передаётся на сервер параметром search', async () => {
const wrapper = await factory();
const input = wrapper.find('input[type="text"]');
await input.setValue('Натяжные');
await wrapper.vm.$nextTick();
const rows = wrapper.findAll('tbody tr');
expect(rows.length).toBe(1);
expect(rows[0].text()).toContain('Натяжные потолки СПб');
const api = await import('../../resources/js/api/admin');
const vm = wrapper.vm as unknown as { search: string; loadTenants: () => Promise<void> };
vm.search = 'Натяжные';
await vm.loadTenants();
// .some, а не .at(-1): usePolling может вставить фоновый вызов между действием и проверкой.
expect(vi.mocked(api.listAdminTenants).mock.calls.some((c) => c[0]?.search === 'Натяжные')).toBe(true);
});
it('содержит Экспорт-кнопку и фильтры Статус/Тариф', async () => {
@@ -147,24 +148,38 @@ describe('AdminTenantsView.vue', () => {
expect(wrapper.find('[data-testid="filter-tariffs"]').exists()).toBe(true);
});
it('фильтр по статусу «overdue» оставляет только просроченных', async () => {
it('фильтр по статусу «overdue» уходит на сервер параметром statuses + сброс на 1 страницу', async () => {
const wrapper = await factory();
const vm = wrapper.vm as unknown as { filterStatuses: string[] };
const api = await import('../../resources/js/api/admin');
const vm = wrapper.vm as unknown as { filterStatuses: string[]; page: number };
vm.page = 3;
vm.filterStatuses = ['overdue'];
await wrapper.vm.$nextTick();
const rows = wrapper.findAll('tbody tr');
expect(rows.length).toBe(1);
expect(rows[0].text()).toContain('Двери Премиум');
await flushPromises();
expect(
vi.mocked(api.listAdminTenants).mock.calls.some((c) => c[0]?.statuses === 'overdue' && c[0]?.offset === 0),
).toBe(true);
expect(vm.page).toBe(1);
});
it('фильтр по тарифу «Pro» оставляет 1 row', async () => {
it('фильтр по тарифу «Pro» уходит на сервер параметром tariffs', async () => {
const wrapper = await factory();
const api = await import('../../resources/js/api/admin');
const vm = wrapper.vm as unknown as { filterTariffs: string[] };
vm.filterTariffs = ['Pro'];
await wrapper.vm.$nextTick();
const rows = wrapper.findAll('tbody tr');
expect(rows.length).toBe(1);
expect(rows[0].text()).toContain('Кухни на заказ Екб');
await flushPromises();
expect(vi.mocked(api.listAdminTenants).mock.calls.some((c) => c[0]?.tariffs === 'Pro')).toBe(true);
});
it('пагинация: goPage шлёт offset и грузит страницу', async () => {
const wrapper = await factory();
const api = await import('../../resources/js/api/admin');
const vm = wrapper.vm as unknown as { goPage: (p: number) => void; perPage: number };
vm.goPage(3);
await flushPromises();
// offset = (3-1) * perPage
expect(vi.mocked(api.listAdminTenants).mock.calls.some((c) => c[0]?.offset === 2 * vm.perPage)).toBe(true);
});
it('clearFilters сбрасывает оба фильтра + кнопка «Сбросить» появляется только когда фильтры активны', async () => {
@@ -187,7 +202,6 @@ describe('AdminTenantsView.vue', () => {
it('каждая строка имеет impersonate-кнопку (mdi-account-switch) с уникальным data-testid', async () => {
const wrapper = await factory();
// Все 7 mock-tenants должны иметь кнопку
MOCK_TENANTS.forEach((t) => {
const btn = wrapper.find(`[data-testid="impersonate-btn-${t.id}"]`);
expect(btn.exists()).toBe(true);
@@ -198,23 +212,19 @@ describe('AdminTenantsView.vue', () => {
const wrapper = await factory();
const suspendedBtn = wrapper.find('[data-testid="impersonate-btn-105"]');
expect(suspendedBtn.exists()).toBe(true);
// v-btn disabled-state — атрибут disabled на DOM-элементе
expect(suspendedBtn.attributes('disabled')).toBeDefined();
});
it('click на impersonate-кнопке открывает ImpersonationDialog с правильным tenant', async () => {
const wrapper = await factory();
// До click — диалог закрыт (modelValue=false)
const dialogStub = wrapper.findComponent({ name: 'ImpersonationDialog' });
expect(dialogStub.exists()).toBe(true);
expect(dialogStub.props('modelValue')).toBe(false);
expect(dialogStub.props('tenant')).toBeNull();
// Click по кнопке для Окна Москва (id=42)
await wrapper.find('[data-testid="impersonate-btn-42"]').trigger('click');
await wrapper.vm.$nextTick();
// Диалог открывается с этим tenant
expect(dialogStub.props('modelValue')).toBe(true);
expect(dialogStub.props('tenant')).toMatchObject({ id: 42, name: 'Окна Москва ООО' });
expect(dialogStub.props('requestedBy')).toBe(1);
@@ -12,6 +12,7 @@ vi.mock('../../resources/js/api/admin', async (importOriginal) => {
return {
...orig,
listAdminTenants: vi.fn(),
listAdminTariffPlans: vi.fn().mockResolvedValue([]),
};
});
@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import InvoicesTable from '../../resources/js/components/billing/InvoicesTable.vue';
import * as billingApi from '../../resources/js/api/billing';
import type { BillingInvoice } from '../../resources/js/api/billing';
function inv(over: Partial<BillingInvoice> = {}): BillingInvoice {
return {
id: 1,
invoice_number: 'СЧ-2026-00001',
amount_total: '1500.00',
status: 'issued',
issued_at: '2026-06-29T09:00:00+00:00',
expires_at: '2026-07-05T09:00:00+00:00',
has_pdf: true,
has_act: false,
pdf_url: '/api/billing/invoices/1/pdf',
act_url: null,
...over,
};
}
describe('InvoicesTable — список счетов', () => {
beforeEach(() => vi.clearAllMocks());
it('рендерит счёт со статусом и кнопкой «Счёт»; кнопки «Акт» нет пока счёт не оплачен', async () => {
vi.spyOn(billingApi, 'getInvoices').mockResolvedValue({ data: [inv()] });
const w = mount(InvoicesTable, { global: { plugins: [createVuetify()] } });
await flushPromises();
expect(w.text()).toContain('СЧ-2026-00001');
expect(w.text()).toContain('Выставлен');
expect(w.find('[data-testid="inv-pdf-1"]').exists()).toBe(true);
expect(w.find('[data-testid="inv-act-1"]').exists()).toBe(false);
});
it('для оплаченного счёта показывает кнопку «Акт»', async () => {
vi.spyOn(billingApi, 'getInvoices').mockResolvedValue({
data: [inv({ id: 2, status: 'paid', has_act: true, act_url: '/api/billing/invoices/2/act' })],
});
const w = mount(InvoicesTable, { global: { plugins: [createVuetify()] } });
await flushPromises();
expect(w.text()).toContain('Оплачен');
expect(w.find('[data-testid="inv-act-2"]').exists()).toBe(true);
});
it('пустой список — empty-state', async () => {
vi.spyOn(billingApi, 'getInvoices').mockResolvedValue({ data: [] });
const w = mount(InvoicesTable, { global: { plugins: [createVuetify()] } });
await flushPromises();
expect(w.text()).toContain('появятся');
});
});
@@ -15,9 +15,10 @@ describe('ChangePasswordCard (Q.DEFER.003 sub-B)', () => {
});
it('shows last-change hint text', () => {
// Дата берётся из GET /api/account/security; без backend (в тесте) — честное
// «не менялся» (хардкод-демо «12.04.2026» убран намеренно, не показываем фейк).
const wrapper = factory();
expect(wrapper.text()).toContain('Последняя смена: 12.04.2026');
expect(wrapper.text()).toContain('26 дней назад');
expect(wrapper.text()).toContain('Последняя смена: не менялся');
});
it('renders «Сменить пароль» button with lock-reset icon', () => {
@@ -88,8 +88,8 @@ describe('DealDetailBody ↔ GET /api/deals/{id} integration', () => {
expect(dealsApi.getDeal).toHaveBeenCalledWith(MOCK_DEALS[0].id, 1);
const items = wrapper.findAll('.timeline-item');
expect(items).toHaveLength(2);
// status_changed event имеет detail "new → won".
expect(wrapper.text()).toContain('new → won');
// status_changed мапит слаги в русские ярлыки воронки: new→«Новая сделка», won→«Сделка».
expect(wrapper.text()).toContain('Новая сделка → Сделка');
});
it('getDeal reject → eventsFetchError=true, alert виден, events пуст (I3)', async () => {
+6 -2
View File
@@ -57,8 +57,12 @@ describe('DealsView.vue — реестр лидов', () => {
it('панель экспорта: поля дат + кнопки Excel/CSV', async () => {
const w = await mountDeals();
expect(w.find('[data-testid="export-from"]').exists()).toBe(true);
expect(w.find('[data-testid="export-to"]').exists()).toBe(true);
const panel = w.find('.export-panel');
expect(panel.exists()).toBe(true);
// 2 поля даты: RuDateField построен на v-menu, поэтому data-testid не доходит
// до DOM-элемента — проверяем по input'ам активаторов (v-text-field) внутри панели.
expect(panel.findAll('input').length).toBeGreaterThanOrEqual(2);
// Кнопки — v-btn, data-testid доходит до корня button.
expect(w.find('[data-testid="export-xlsx-btn"]').exists()).toBe(true);
expect(w.find('[data-testid="export-csv-btn"]').exists()).toBe(true);
});
+7 -9
View File
@@ -38,25 +38,23 @@ describe('ErrorView.vue', () => {
expect(text).toContain('Все рабочие экраны Лидерра доступны через дашборд');
});
it('errorCode=403 показывает «403 / У вас нет доступа» + RequestId', async () => {
it('errorCode=403 показывает «403 / У вас нет доступа» (фейк-RequestId убран)', async () => {
const wrapper = await mountErrorView('403');
const text = wrapper.text();
expect(wrapper.find('.err-code').text()).toBe('403');
expect(text).toContain('У вас нет доступа');
expect(text).toContain('REQ-3F8A2-0007');
expect(text).toContain('Запрос');
// Хардкод «REQ-3F8A2-0007» убран намеренно (не показываем фейк как настоящее).
expect(text).not.toContain('REQ-3F8A2-0007');
});
it('errorCode=500 показывает «500 / Что-то пошло не так» + IncidentId + status-list', async () => {
it('errorCode=500 показывает «500 / Что-то пошло не так» (фейк-Incident/status-list убраны)', async () => {
const wrapper = await mountErrorView('500');
const text = wrapper.text();
expect(wrapper.find('.err-code').text()).toBe('500');
expect(text).toContain('Что-то пошло не так');
expect(text).toContain('INC-2026-0507-0034');
expect(text).toContain('Инцидент');
// status-list только на 500.
expect(text).toContain('API · OK');
expect(text).toContain('Telegram · деградация');
// Хардкод «INC-2026-0507-0034» + фейк-список статусов убраны намеренно.
expect(text).not.toContain('INC-2026-0507-0034');
expect(text).not.toContain('Telegram · деградация');
});
it('404 содержит «На дашборд» primary + «Назад» secondary', async () => {
+34 -37
View File
@@ -9,24 +9,32 @@ vi.mock('../../resources/js/api/billing');
const vuetify = createVuetify();
function inv(over: Partial<BillingInvoice> = {}): BillingInvoice {
return {
id: 1,
invoice_number: 'СЧ-2026-00001',
amount_total: '990.00',
status: 'issued',
issued_at: '2026-05-07T00:00:00Z',
expires_at: '2026-05-14T00:00:00Z',
has_pdf: true,
has_act: false,
pdf_url: '/api/billing/invoices/1/pdf',
act_url: null,
...over,
};
}
describe('InvoicesTable.vue', () => {
it('показывает empty-state без счетов', async () => {
vi.mocked(billingApi.getInvoices).mockResolvedValue({ data: [] });
const wrapper = mount(InvoicesTable, { global: { plugins: [vuetify] } });
await flushPromises();
expect(wrapper.text()).toContain('Счета появятся');
expect(wrapper.text()).toContain('появятся');
});
it('рендерит строки счетов из API', async () => {
const inv: BillingInvoice = {
id: 1,
invoice_number: 'СЧ-2026-00001',
amount_total: '990.00',
status: 'issued',
issued_at: '2026-05-07T00:00:00Z',
has_pdf: true,
};
vi.mocked(billingApi.getInvoices).mockResolvedValue({ data: [inv] });
vi.mocked(billingApi.getInvoices).mockResolvedValue({ data: [inv()] });
const wrapper = mount(InvoicesTable, { global: { plugins: [vuetify] } });
await flushPromises();
const text = wrapper.text();
@@ -34,36 +42,31 @@ describe('InvoicesTable.vue', () => {
expect(text).toContain('Выставлен');
});
it('PDF-кнопка disabled при has_pdf=false и активна при has_pdf=true', async () => {
it('кнопка «Счёт» disabled при has_pdf=false и активна при has_pdf=true', async () => {
const invs: BillingInvoice[] = [
{
id: 1,
invoice_number: 'СЧ-2026-00010',
amount_total: '990.00',
status: 'issued',
issued_at: '2026-05-07T00:00:00Z',
has_pdf: false,
},
{
id: 2,
invoice_number: 'СЧ-2026-00011',
amount_total: '500.00',
status: 'paid',
issued_at: '2026-05-08T00:00:00Z',
has_pdf: true,
},
inv({ id: 1, invoice_number: 'СЧ-2026-00010', has_pdf: false, pdf_url: null }),
inv({ id: 2, invoice_number: 'СЧ-2026-00011', status: 'paid', has_pdf: true }),
];
vi.mocked(billingApi.getInvoices).mockResolvedValue({ data: invs });
const wrapper = mount(InvoicesTable, { global: { plugins: [vuetify] } });
await flushPromises();
const pdfButtons = wrapper.findAll('button').filter((b) => b.text().includes('PDF'));
const pdfButtons = wrapper.findAll('button, a').filter((b) => b.text().includes('Счёт'));
expect(pdfButtons).toHaveLength(2);
// Строка 1 (has_pdf=false) → disabled; строка 2 (has_pdf=true) → активна.
expect(pdfButtons[0].attributes('disabled')).toBeDefined();
expect(pdfButtons[1].attributes('disabled')).toBeUndefined();
});
it('показывает кнопку «Акт» только при has_act=true', async () => {
vi.mocked(billingApi.getInvoices).mockResolvedValue({
data: [inv({ id: 7, status: 'paid', has_act: true, act_url: '/api/billing/invoices/7/act' })],
});
const wrapper = mount(InvoicesTable, { global: { plugins: [vuetify] } });
await flushPromises();
expect(wrapper.find('[data-testid="inv-act-7"]').exists()).toBe(true);
});
it('показывает error-alert при сбое', async () => {
vi.mocked(billingApi.getInvoices).mockRejectedValue(new Error('fail'));
const wrapper = mount(InvoicesTable, { global: { plugins: [vuetify] } });
@@ -72,15 +75,9 @@ describe('InvoicesTable.vue', () => {
});
it('renders amount_total with ₽ suffix', async () => {
const inv: BillingInvoice = {
id: 1,
invoice_number: 'INV-1',
amount_total: '1234.00',
status: 'paid',
issued_at: '2026-05-23T00:00:00Z',
has_pdf: true,
};
vi.mocked(billingApi.getInvoices).mockResolvedValue({ data: [inv] });
vi.mocked(billingApi.getInvoices).mockResolvedValue({
data: [inv({ invoice_number: 'INV-1', amount_total: '1234.00', status: 'paid' })],
});
const wrapper = mount(InvoicesTable, { global: { plugins: [vuetify] } });
await flushPromises();
expect(wrapper.text()).toMatch(/1\s?234\s?₽/);
+8 -1
View File
@@ -12,7 +12,9 @@ describe('KanbanCard.vue', () => {
});
it('рендерит имя, телефон, проект и стоимость', () => {
const wrapper = factory(MOCK_DEALS[0]);
// Карточка показывает ФАКТ списания (costKopecks), а не legacy cost; при отсутствии
// списания — «—». Формат проверяем на сделке с costKopecks=185000 → «1 850 ₽».
const wrapper = factory({ ...MOCK_DEALS[0], costKopecks: 185000 });
const text = wrapper.text();
expect(text).toContain('Анна Соколова');
expect(text).toContain('+7 (916) 871-23-45');
@@ -20,6 +22,11 @@ describe('KanbanCard.vue', () => {
expect(text).toMatch(/1\s+850\s*₽/);
});
it('показывает «—» в стоимости когда списания ещё не было (costKopecks=null)', () => {
const wrapper = factory({ ...MOCK_DEALS[0], costKopecks: null });
expect(wrapper.find('.card-cost').text()).toBe('—');
});
it('показывает initials менеджера', () => {
const wrapper = factory(MOCK_DEALS[0]);
expect(wrapper.text()).toContain('ИП');
+14 -10
View File
@@ -24,9 +24,9 @@ const mountAt = async (path: string) => {
};
describe('LegalDocView.vue', () => {
it('рендерит «Договор-оферта» на /legal/offer', async () => {
it('рендерит «Публичная оферта» на /legal/offer', async () => {
const wrapper = await mountAt('/legal/offer');
expect(wrapper.text()).toContain('Договор-оферта');
expect(wrapper.text()).toContain('Публичная оферта');
});
it('рендерит «Политика конфиденциальности» на /legal/privacy', async () => {
@@ -34,17 +34,21 @@ describe('LegalDocView.vue', () => {
expect(wrapper.text()).toContain('Политика конфиденциальности');
});
it('показывает честную заглушку «документ готовится», а не фейк-текст', async () => {
it('показывает реальный текст оферты (рабочая редакция под ЮKassa), а не заглушку', async () => {
const wrapper = await mountAt('/legal/offer');
const notice = wrapper.find('[data-testid="legal-stub-notice"]');
expect(notice.exists()).toBe(true);
expect(notice.text()).toContain('готовится');
const text = wrapper.text();
// Реальные разделы + дата редакции из content/legalDocs.ts.
expect(text).toContain('Предмет');
expect(text).toContain('Реквизиты Исполнителя');
expect(text).toContain('Редакция от 2026-06-24');
expect(text).not.toContain('готовится');
});
it('содержит ссылку возврата ко входу', async () => {
it('политика конфиденциальности содержит реальные разделы (оператор/права субъекта)', async () => {
const wrapper = await mountAt('/legal/privacy');
const back = wrapper.find('a.legal-back');
expect(back.exists()).toBe(true);
expect(back.attributes('href')).toBe('/login');
const text = wrapper.text();
expect(text).toContain('Оператор');
expect(text).toContain('Права субъекта');
expect(text).toContain('Редакция от 2026-06-24');
});
});
+8 -5
View File
@@ -104,7 +104,7 @@ describe('ProjectsView', () => {
cards[0].vm.$emit('toggle-select', 1);
cards[1].vm.$emit('toggle-select', 2);
await wrapper.vm.$nextTick();
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(true);
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).isVisible()).toBe(true);
});
});
@@ -155,7 +155,8 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => {
expect(drawer.exists()).toBe(true);
expect(drawer.classes()).not.toContain('open');
// BulkActionsBar uses v-if; should not exist.
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(false);
// BulkActionsBar теперь v-show (всегда смонтирован, скрыт display:none) → проверяем видимость.
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).isVisible()).toBe(false);
// .has-drawer class should not be on the view root.
expect(wrapper.find('.projects-view').classes()).not.toContain('has-drawer');
});
@@ -173,7 +174,8 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => {
expect(drawer.exists()).toBe(true);
expect(drawer.classes()).toContain('open');
// BulkActionsBar should NOT exist (size < 2).
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(false);
// BulkActionsBar теперь v-show (всегда смонтирован, скрыт display:none) → проверяем видимость.
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).isVisible()).toBe(false);
// .has-drawer class should be present.
expect(wrapper.find('.projects-view').classes()).toContain('has-drawer');
});
@@ -192,7 +194,7 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => {
expect(drawer.exists()).toBe(true);
expect(drawer.classes()).not.toContain('open');
// BulkActionsBar should exist (size >= 2).
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(true);
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).isVisible()).toBe(true);
// .has-drawer class should NOT be present.
expect(wrapper.find('.projects-view').classes()).not.toContain('has-drawer');
});
@@ -215,7 +217,8 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => {
// Both should be hidden now.
const drawerAfter = wrapper.find('aside.project-details-drawer');
expect(drawerAfter.classes()).not.toContain('open');
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(false);
// BulkActionsBar теперь v-show (всегда смонтирован, скрыт display:none) → проверяем видимость.
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).isVisible()).toBe(false);
expect(wrapper.find('.projects-view').classes()).not.toContain('has-drawer');
});
+13 -5
View File
@@ -2,12 +2,20 @@ import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
import SettingsView from '../../resources/js/views/SettingsView.vue';
// SettingsView читает route.query.tab (deep-link вкладки) через useRoute(),
// поэтому компоненту нужен router-контекст в тестах.
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/settings', name: 'settings', component: SettingsView }],
});
describe('SettingsView.vue', () => {
const factory = () =>
mount(SettingsView, {
global: { plugins: [createPinia(), createVuetify()] },
global: { plugins: [createPinia(), createVuetify(), router] },
});
it('монтируется и содержит заголовок «Настройки»', () => {
@@ -15,10 +23,10 @@ describe('SettingsView.vue', () => {
expect(wrapper.find('h1').text()).toBe('Настройки');
});
it('содержит ровно 4 nav-tabs (placeholder-вкладки убраны, audit D6/D7)', () => {
it('содержит ровно 5 nav-tabs (Профиль/Реквизиты/Безопасность/API/Уведомления)', () => {
const wrapper = factory();
const items = wrapper.findAll('.tabs-rail .v-list-item');
expect(items.length).toBe(4);
expect(items.length).toBe(5);
});
it('содержит все 4 названия рабочих вкладок', () => {
@@ -52,8 +60,8 @@ describe('SettingsView.vue', () => {
await wrapper.vm.$nextTick();
const text = wrapper.text();
expect(text).toContain('События × каналы');
// 8 типов событий из schema users.notification_preferences.
['Новый лид', 'Напоминание', 'Низкий баланс', 'Нулевой баланс', 'Анонсы и промо'].forEach((e) =>
// Типы событий из schema users.notification_preferences (актуальный набор).
['Новый лид', 'Низкий баланс', 'Нулевой баланс', 'Пополнение успешно', 'Анонсы и промо'].forEach((e) =>
expect(text).toContain(e),
);
});
@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import TopupDialog from '../../resources/js/components/billing/TopupDialog.vue';
import * as billingApi from '../../resources/js/api/billing';
describe('TopupDialog — оплата по счёту', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('open', vi.fn());
});
it('при способе «По счёту» и сумме вызывает createInvoice и открывает PDF', async () => {
const spy = vi.spyOn(billingApi, 'createInvoice').mockResolvedValue({
id: 1,
invoice_number: 'СЧ-2026-00001',
amount_total: '1500.00',
pdf_url: '/api/billing/invoices/1/pdf',
});
const wrapper = mount(TopupDialog, {
props: { modelValue: true },
global: { plugins: [createVuetify()] },
});
const vm = wrapper.vm as unknown as { method: string; amount: number | null; submit: () => Promise<void> };
vm.method = 'invoice';
vm.amount = 1500;
await vm.submit();
await flushPromises();
expect(spy).toHaveBeenCalledWith(1500);
expect(window.open).toHaveBeenCalledWith('/api/billing/invoices/1/pdf', '_blank');
expect(wrapper.emitted('invoiced')?.[0]).toEqual(['СЧ-2026-00001']);
});
it('способ «Картой» вызывает topup, не createInvoice', async () => {
const topupSpy = vi.spyOn(billingApi, 'topup').mockResolvedValue({ balance_rub: '2000.00' });
const invoiceSpy = vi.spyOn(billingApi, 'createInvoice');
const wrapper = mount(TopupDialog, {
props: { modelValue: true },
global: { plugins: [createVuetify()] },
});
const vm = wrapper.vm as unknown as { method: string; amount: number | null; submit: () => Promise<void> };
vm.method = 'card';
vm.amount = 2000;
await vm.submit();
await flushPromises();
expect(topupSpy).toHaveBeenCalledWith(2000);
expect(invoiceSpy).not.toHaveBeenCalled();
});
});
+42
View File
@@ -2,6 +2,10 @@
use App\Models\Project;
use App\Models\SupplierProject;
use App\Services\External\BalanceProvider;
use App\Services\External\BalanceReading;
use App\Services\External\LivenessProbe;
use App\Services\External\LivenessReading;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Date;
@@ -61,6 +65,44 @@ function something()
// ..
}
/** Стаб балансового провайдера с заранее заданным результатом (внешние сервисы). */
function fakeProvider(string $key, BalanceReading $reading): BalanceProvider
{
return new class($key, $reading) implements BalanceProvider
{
public function __construct(private string $key, private BalanceReading $reading) {}
public function serviceKey(): string
{
return $this->key;
}
public function fetch(): BalanceReading
{
return $this->reading;
}
};
}
/** Стаб-проба живости с заранее заданным результатом (внешние сервисы). */
function fakeProbe(string $key, LivenessReading $reading): LivenessProbe
{
return new class($key, $reading) implements LivenessProbe
{
public function __construct(private string $key, private LivenessReading $reading) {}
public function serviceKey(): string
{
return $this->key;
}
public function check(): LivenessReading
{
return $this->reading;
}
};
}
/**
* Link a Лидерра-project to a supplier_project via the M:N pivot
* (Plan 1 model). Post-Plan-2 LeadRouter eligibility queries the pivot
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
use App\Services\External\CaptchaLivenessProbe;
use Tests\TestCase;
uses(TestCase::class); // нужен booted-app: config()
it('серый «выключена», когда driver=null', function () {
config()->set('services.captcha.driver', 'null');
$r = (new CaptchaLivenessProbe)->check();
expect($r->serviceKey)->toBe('captcha');
expect($r->light)->toBe('grey');
expect($r->detail)->toContain('выключена');
});
it('зелёный, когда капча включена', function () {
config()->set('services.captcha.driver', 'yandex');
expect((new CaptchaLivenessProbe)->check()->light)->toBe('green');
});
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use App\Services\External\JivoLivenessProbe;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class); // нужен booted-app: config()/Http::fake()
beforeEach(function () {
config()->set('services.jivosite.widget_id', 'ABC123');
config()->set('services.jivosite.widget_url_template', 'https://code.jivo.ru/widget/{id}');
});
it('зелёный при 200 на виджет-скрипт', function () {
Http::fake(['code.jivo.ru/widget/ABC123' => Http::response('/* jivo */', 200)]);
$r = (new JivoLivenessProbe)->check();
expect($r->serviceKey)->toBe('jivosite');
expect($r->light)->toBe('green');
});
it('красный при 404', function () {
Http::fake(['code.jivo.ru/widget/ABC123' => Http::response('', 404)]);
expect((new JivoLivenessProbe)->check()->light)->toBe('red');
});
it('серый, когда widget_id не задан', function () {
config()->set('services.jivosite.widget_id', '');
expect((new JivoLivenessProbe)->check()->light)->toBe('grey');
});
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use App\Services\External\Yandex360BalanceProvider;
use App\Services\External\Yandex360BalanceStore;
use Tests\TestCase;
uses(TestCase::class);
/** Стаб хранилища ручного баланса. */
function balanceStoreReturning(?float $balance): Yandex360BalanceStore
{
return new class($balance) extends Yandex360BalanceStore
{
public function __construct(private ?float $b) {}
public function get(): ?float
{
return $this->b;
}
};
}
it('ok при заданном балансе', function () {
$p = new Yandex360BalanceProvider(balanceStoreReturning(1464.31));
$r = $p->fetch();
expect($r->serviceKey)->toBe('email');
expect($r->ok)->toBeTrue();
expect($r->balance)->toBe(1464.31);
});
it('fail, когда баланс не задан', function () {
$p = new Yandex360BalanceProvider(balanceStoreReturning(null));
$r = $p->fetch();
expect($r->ok)->toBeFalse();
expect($r->error)->toContain('не задан');
});
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use App\Services\External\Yandex360BalanceStore;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
it('set сохраняет число, get возвращает float', function () {
$store = new Yandex360BalanceStore;
$store->set(1464.31);
expect($store->get())->toBe(1464.31);
});
it('get возвращает null, когда не задан', function () {
expect((new Yandex360BalanceStore)->get())->toBeNull();
});
it('status отдаёт balance + updated_at', function () {
$store = new Yandex360BalanceStore;
expect($store->status())->toMatchArray(['balance' => null, 'updated_at' => null]);
$store->set(500.0);
$st = $store->status();
expect($st['balance'])->toBe(500.0);
expect($st['updated_at'])->not->toBeNull();
});
it('null очищает баланс', function () {
$store = new Yandex360BalanceStore;
$store->set(500.0);
$store->set(null);
expect($store->get())->toBeNull();
expect($store->status()['balance'])->toBeNull();
});
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use App\Services\External\YooKassaLivenessProbe;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class); // нужен booted-app: config()/Http::fake()
beforeEach(function () {
config()->set('services.yookassa.shop_id', '1392092');
config()->set('services.yookassa.secret_key', 'test_secret');
config()->set('services.yookassa.api_url', 'https://api.yookassa.ru/v3');
});
it('зелёный при 200 от /me', function () {
Http::fake(['api.yookassa.ru/v3/me' => Http::response(['account_id' => '1392092'], 200)]);
$r = (new YooKassaLivenessProbe)->check();
expect($r->serviceKey)->toBe('yookassa');
expect($r->light)->toBe('green');
});
it('красный при 401', function () {
Http::fake(['api.yookassa.ru/v3/me' => Http::response(['type' => 'error'], 401)]);
expect((new YooKassaLivenessProbe)->check()->light)->toBe('red');
});
it('серый, когда ключи не заданы', function () {
config()->set('services.yookassa.shop_id', '');
config()->set('services.yookassa.secret_key', '');
expect((new YooKassaLivenessProbe)->check()->light)->toBe('grey');
});
it('красный при сетевой ошибке (проба не бросает)', function () {
Http::fake(fn () => throw new RuntimeException('network down'));
expect((new YooKassaLivenessProbe)->check()->light)->toBe('red');
});
+6 -14
View File
@@ -1,6 +1,6 @@
# Brain Status (auto-generated)
Last updated: 2026-06-28T07:15:07.331Z
Last updated: 2026-06-29T08:26:07.630Z
| Контролёр | Состояние | Детали |
|---|---|---|
@@ -33,21 +33,13 @@ Last updated: 2026-06-28T07:15:07.331Z
| enforce-coverage-verify.mjs | `enforce-coverage-verify.mjs` | 🔴 |
| enforce-todowrite-skill-verifier.mjs | `enforce-todowrite-skill-verifier.mjs` | 🔴 |
Недавние escape владельца: 0 · Недавние блоки: 3
**Недавние блоки (детали):**
| Время | Действие | Причина |
|---|---|---|
| 2026-06-27T11:50:42.480Z | bash:cd "c:/моя/проекты/claude-brain" && git add -- "docs/superpowers/specs/2026-06-27-secretary-closing-doors-design.md | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:cd "c:/моя/проекты/cla |
| 2026-06-27T10:01:08.010Z | bash:git restore --staged docs/observer/STATUS.md 2>/dev/null; git diff --staged --name-only | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:git restore --staged d |
| 2026-06-27T09:25:54.127Z | bash:node -e "for (const d of ['протокол-наставника','проблема-закрытия-вопросов-протокола','содержит']) { try { const p | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:node -e "for (const d |
Недавние escape владельца: 0 · Недавние блоки: 0
## Метрики (информационные, не алерты)
- Observer evidence: 2354 episodes this month, 0 observer_error markers, 8 PII matches before filter
- Legacy v1 episodes (not in factor analysis): 2354
- Last /brain-retro: 32 day(s) ago
- Last /brain-retro: 33 day(s) ago
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 0. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
## Метрики дисциплины
@@ -125,9 +117,9 @@ Episodes since last run: 542 / threshold: 10
| PID | Имя | CPU-время | Возраст |
|---|---|---|---|
| 3440 | MsMpEng | 17.47ч | 0.0ч |
| 21928 | Code | 7.73ч | 0.0ч |
| 1212 | svchost | 4.46ч | NaNч |
| 3440 | MsMpEng | 20.00ч | NaNч |
| 21928 | Code | 10.83ч | NaNч |
| 1212 | svchost | 5.68ч | NaNч |
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.

Some files were not shown because too many files have changed in this diff Show More