docs(spec): admin tenant balance edit design

This commit is contained in:
Дмитрий
2026-05-23 19:13:47 +03:00
parent e24b8c168f
commit 17ea005bce
@@ -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 = <actor>` (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.