From 17ea005bce2fc4f5d403324cbba157e5d9eea9c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 23 May 2026 19:13:47 +0300 Subject: [PATCH] docs(spec): admin tenant balance edit design --- ...-05-23-admin-tenant-balance-edit-design.md | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-23-admin-tenant-balance-edit-design.md diff --git a/docs/superpowers/specs/2026-05-23-admin-tenant-balance-edit-design.md b/docs/superpowers/specs/2026-05-23-admin-tenant-balance-edit-design.md new file mode 100644 index 00000000..d943b2b4 --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-admin-tenant-balance-edit-design.md @@ -0,0 +1,93 @@ +# Дизайн: правка рублёвого баланса тенанта из админки + +**Дата:** 2026-05-23 +**Статус:** утверждён заказчиком (через brainstorming Q&A) +**Контекст:** после Биллинга v2 Spec A (единый ₽-баланс) заказчику нужен постоянный инструмент корректировки `tenants.balance_rub` из админки — вместо ручных правок через psql. Триггер — необходимость выставить адекватные балансы тестовым/демо-тенантам на проде после выкатки Spec A (где `balance_leads` стал vestigial, конвертация даёт артефакты вроде ½ млрд ₽). + +## Решения (зафиксированы в brainstorming) + +1. **Семантика — «установить точную сумму».** Админ вводит целевой `balance_rub`; сервер считает знаковую разницу `target − current` и записывает её в ledger. (Не дельта-ввод, не двойной режим — YAGNI.) +2. **Поле — только `balance_rub`.** `balance_leads` после Spec A не используется и удаляется в Phase B → редактировать его смысла нет. +3. **Доступ — из двух мест:** карточка тенанта (`AdminTenantDetailView`) И инлайн в таблице списка (`AdminTenantsView` → `TenantsTable`). Общий диалог-компонент. + +## Архитектура + +### Backend + +Новый метод `AdminTenantsController::updateBalance(Request $request, int $id): JsonResponse`. + +**Маршрут:** `PATCH /api/admin/tenants/{id}/balance` под `middleware('saas-admin')` (тот же гейт, что у hole #4 `pd-subject-requests` и `AdminPricingTiers`). Сейчас `AdminTenantsController` MVP-без-auth; новый мутирующий эндпоинт ставится под saas-admin гейт сразу (мутация денег — не lookup). + +**Валидация:** +- `balance_rub` — `required`, `string`, `regex:/^-?\d+(\.\d{1,2})?$/`. Отрицательное допустимо (баланс легитимно уходит в минус при задолженности; `chargeback_unrecovered_rub` / overdue-логика это поддерживает). +- `reason` — `nullable`, `string`, `max:500`. + +**Логика (внутри `DB::transaction`):** +1. Через SaaS-connection `DB::connection('pgsql_supplier')` (BYPASSRLS-роль `crm_supplier_worker`) — `AdminTenantsController` не tenant-aware, RLS-контекст не ставится (паттерн hole #7 + `AdminBillingController::refund`). +2. `lockForUpdate` на строке `tenants` (защита от lost-update при конкурентных topup/charge/adjust). +3. 404 если тенант не найден / `deleted_at` не null. +4. `delta = bcsub(target, current, 2)`. Если `bccomp(delta, '0', 2) === 0` → HTTP 422 «Баланс не изменился». +5. `UPDATE tenants SET balance_rub = target WHERE id = ?` (raw через ту же connection; модель Eloquent decimal:2 тоже допустима, паттерн `BillingTopupService`). +6. `INSERT balance_transactions`: + - `type = 'manual_adjustment'` (валидное значение CHECK). + - `amount_rub = delta` (знаковая строка — отрицательная при уменьшении). + - `amount_leads = null`, `balance_leads_after = null` (Spec A — лиды не трогаем). + - `balance_rub_after = target`. + - `description = reason ?? 'Ручная корректировка баланса (админ)'`. + - `admin_user_id = ` (nullable — saas-admin SSO ⏸ Б-1; на MVP `null`). + - `created_at = now()`. + - Hash-chain BEFORE INSERT триггер `audit_chain_hash` подпишет `log_hash` автоматически; `audit_block_mutation` гарантирует append-only. +7. `INSERT saas_admin_audit_log`: `action = 'tenant.balance_adjusted'`, `payload_before = {balance_rub: current}`, `payload_after = {balance_rub: target, delta, transaction_id}`. Паттерн `AdminBillingController::refund` (строки 130-131). + +**Ответ:** `{ balance_rub: target, delta, transaction_id }` (200). + +**Деньги:** только bcmath (`bcsub`/`bccomp`), без PHP float. `lockForUpdate` + append-only ledger. + +### Frontend + +**Общий компонент** `app/resources/js/components/admin/TenantBalanceDialog.vue`: +- Props: `tenantId: number`, `tenantName: string`, `currentBalanceRub: string`, `modelValue: boolean` (v-model open). +- Поля: «Новый баланс ₽» (числовой ввод, маска decimal 2), «Причина» (textarea, опц.). +- Живой предпросмотр: «было `{current}` ₽ → станет `{new}` ₽ (`{±delta}` ₽)». Считается на клиенте через простую арифметику строк (для отображения; источник истины — сервер). +- Кнопки: «Сохранить» (disabled если поле пустое / не изменилось / невалидно), «Отмена». +- Submit → `PATCH /api/admin/tenants/{id}/balance` → `emit('saved', { balance_rub, transaction_id })` → закрытие. +- Ошибка валидации/сервера → показ в диалоге (не закрывать). + +**Точка 1 — карточка тенанта** `AdminTenantDetailView.vue`: кнопка «Изменить баланс» рядом с отображением `balance_rub`. По `saved` → перезагрузить detail (`balance_rub` + `balance_history`, новая строка manual_adjustment видна). + +**Точка 2 — список** `AdminTenantsView.vue` / `TenantsTable.vue`: действие в строке (иконка-кнопка «карандаш» или пункт меню «Изменить баланс»). Открывает тот же диалог. По `saved` → обновить `balance_rub` строки в таблице (точечный патч локального состояния или перезапрос списка). + +**API-клиент** `app/resources/js/api/admin.ts` (или где живут admin-вызовы): функция `updateTenantBalance(id, { balance_rub, reason })`. + +### Тесты + +**Pest feature** `tests/Feature/Admin/AdminTenantBalanceUpdateTest.php`: +- Установка нового баланса → `tenants.balance_rub` обновлён, `balance_transactions(type='manual_adjustment')` с правильной знаковой разницей + `balance_rub_after`, `saas_admin_audit_log` строка. +- Уменьшение баланса (отрицательная дельта) → корректная знаковая amount_rub. +- Установка того же значения (delta=0) → 422. +- Невалидный формат (`10.123` / буквы) → 422. +- Отрицательный целевой баланс → принимается. +- 404 на несуществующий/удалённый тенант. + +**Vitest** `tests/Frontend/TenantBalanceDialog.spec.ts`: +- Предпросмотр считает дельту корректно. +- «Сохранить» disabled при пустом/неизменённом вводе. +- Submit вызывает API с правильными аргументами. + +## Изоляция и границы + +- Эндпоинт — в `AdminTenantsController` (домен «Тенанты»), не в `AdminBillingController` (там tenant-аккаунт-операции refund/changeTariff). Граница: balance-adjust — административная корректировка, логически принадлежит карточке тенанта. +- Диалог — отдельный переиспользуемый компонент, не дублируется между detail и list. +- Никаких изменений в `LedgerService` / `BalanceToLeadsConverter` / биллинг-flow — это независимая admin-операция. + +## Вне scope (YAGNI) + +- Редактирование `balance_leads` (vestigial, удаляется Phase B). +- Дельта-режим ввода / двойной режим. +- Массовая правка балансов нескольких тенантов разом. +- Лимиты-пороги на сумму (кроме формата decimal) — админ доверенный. +- Реальный actor_admin_user_id — saas-admin SSO ⏸ Б-1 (поле nullable, заполнится позже). + +## Развёртывание + +Feature-ветка `feat/admin-tenant-balance-edit` (worktree). После реализации + регрессии + ревью — выкатка на боевой `liderra.ru` тем же копир-паттерном, что Биллинг v2 Phase A (scp файлов + frontend build + кэши). DDL не требуется (новых таблиц/колонок нет). После выкатки заказчик выставляет реальные балансы тестовым тенантам через UI.