docs(spec): admin tenant balance edit design
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user