# Дизайн: правка рублёвого баланса тенанта из админки

**Дата:** 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.
