# Admin Tenant Balance Edit Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Дать SaaS-админу установить точный рублёвый баланс тенанта из админки (карточка тенанта + инлайн в таблице списка), с записью в ledger + audit-log.

**Architecture:** Новый эндпоинт `PATCH /api/admin/tenants/{id}/balance` (метод `AdminTenantsController::updateBalance`) под `saas-admin` middleware. Семантика «установить точную сумму»: сервер считает знаковую дельту `target − current`, применяет под `lockForUpdate` через bcmath, пишет `balance_transactions(type='manual_adjustment')` + `saas_admin_audit_log`. Frontend — общий `TenantBalanceDialog.vue`, открывается из `TenantDetailHeader` (карточка «Баланс») и из строки `TenantsTable`.

**Tech Stack:** PHP 8.3 / Laravel 13 / Pest 4 / Vue 3.5 / Vuetify 3.12 / Vitest 4 / bcmath / PostgreSQL 16.

**Spec:** [../specs/2026-05-23-admin-tenant-balance-edit-design.md](../specs/2026-05-23-admin-tenant-balance-edit-design.md)

---

## File Structure

- **Modify** `app/app/Http/Controllers/Api/AdminTenantsController.php` — добавить `use` трейта/моделей + метод `updateBalance`.
- **Modify** `app/routes/web.php` — 1 строка маршрута в группе `saas-admin` (рядом с tenants lookup).
- **Create** `app/tests/Feature/Admin/AdminTenantBalanceUpdateTest.php` — Pest feature-тест.
- **Modify** `app/resources/js/api/admin.ts` — функция `updateTenantBalance`.
- **Create** `app/resources/js/components/admin/TenantBalanceDialog.vue` — общий диалог.
- **Create** `app/tests/Frontend/TenantBalanceDialog.spec.ts` — Vitest.
- **Modify** `app/resources/js/components/admin/tenant-detail/TenantDetailHeader.vue` — кнопка в карточке «Баланс» + emit.
- **Modify** `app/resources/js/views/admin/AdminTenantDetailView.vue` — монтаж диалога + reload по `saved`.
- **Modify** `app/resources/js/components/admin/tenants/TenantsTable.vue` — действие в строке + emit.
- **Modify** `app/resources/js/views/admin/AdminTenantsView.vue` — монтаж диалога + обновление строки по `saved`.

---

## Task 1: Backend `updateBalance` endpoint

**Files:**

- Modify: `app/app/Http/Controllers/Api/AdminTenantsController.php`
- Modify: `app/routes/web.php` (~line 100, после tenants `show`)
- Test: `app/tests/Feature/Admin/AdminTenantBalanceUpdateTest.php`

- [ ] **Step 1: Write the failing test**

Create `app/tests/Feature/Admin/AdminTenantBalanceUpdateTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\BalanceTransaction;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;

uses(DatabaseTransactions::class);

function makeBalanceTenant(string $balanceRub): Tenant
{
    return Tenant::factory()->create(['balance_rub' => $balanceRub]);
}

it('sets exact balance and records signed manual_adjustment delta', function () {
    $tenant = makeBalanceTenant('1000.00');

    $resp = $this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
        'balance_rub' => '2500.00',
        'reason' => 'Коррекция тестового баланса',
    ]);

    $resp->assertOk()
        ->assertJsonPath('balance_rub', '2500.00')
        ->assertJsonPath('delta', '1500.00');

    $tenant->refresh();
    expect((string) $tenant->balance_rub)->toBe('2500.00');

    $tx = BalanceTransaction::where('tenant_id', $tenant->id)
        ->where('type', BalanceTransaction::TYPE_MANUAL_ADJUSTMENT)
        ->latest('id')->first();
    expect($tx)->not->toBeNull();
    expect((string) $tx->amount_rub)->toBe('1500.00');
    expect((string) $tx->balance_rub_after)->toBe('2500.00');
    expect($tx->amount_leads)->toBeNull();
    expect($tx->description)->toBe('Коррекция тестового баланса');
});

it('records negative delta when lowering balance', function () {
    $tenant = makeBalanceTenant('1000.00');

    $resp = $this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
        'balance_rub' => '300.00',
    ]);

    $resp->assertOk()->assertJsonPath('delta', '-700.00');

    $tx = BalanceTransaction::where('tenant_id', $tenant->id)
        ->where('type', BalanceTransaction::TYPE_MANUAL_ADJUSTMENT)
        ->latest('id')->first();
    expect((string) $tx->amount_rub)->toBe('-700.00');
    // Default description когда reason не передан.
    expect($tx->description)->toBe('Ручная корректировка баланса (админ)');
});

it('accepts negative target balance (debt correction)', function () {
    $tenant = makeBalanceTenant('0.00');

    $this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
        'balance_rub' => '-500.00',
    ])->assertOk()->assertJsonPath('balance_rub', '-500.00');

    expect((string) $tenant->fresh()->balance_rub)->toBe('-500.00');
});

it('rejects no-op (target equals current) with 422', function () {
    $tenant = makeBalanceTenant('1000.00');

    $this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
        'balance_rub' => '1000.00',
    ])->assertStatus(422);
});

it('rejects malformed balance_rub with 422', function () {
    $tenant = makeBalanceTenant('1000.00');

    $this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
        'balance_rub' => '10.123',
    ])->assertStatus(422);

    $this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
        'balance_rub' => 'abc',
    ])->assertStatus(422);
});

it('returns 404 for missing or soft-deleted tenant', function () {
    $this->patchJson('/api/admin/tenants/99999999/balance', [
        'balance_rub' => '100.00',
    ])->assertStatus(404);

    $tenant = makeBalanceTenant('100.00');
    $tenant->delete();
    $this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
        'balance_rub' => '200.00',
    ])->assertStatus(404);
});
```

- [ ] **Step 2: Run test to verify it fails**

Run from `app/`: `./vendor/bin/pest tests/Feature/Admin/AdminTenantBalanceUpdateTest.php`
Expected: FAIL — route `PATCH /api/admin/tenants/{id}/balance` does not exist (404 on all, or method-not-allowed).

If the testing DB lacks the `manual_adjustment` value in `balance_transactions_type_check` or the May-2026 partition — note from prior Billing v2 work: run `php artisan migrate --env=testing` and `php artisan partitions:create-months` if a CHECK/partition error appears. `manual_adjustment` is an existing CHECK value (predates this work), so it should already be valid.

- [ ] **Step 3: Add imports + trait to the controller**

In `app/app/Http/Controllers/Api/AdminTenantsController.php`, the current `use` block is:

```php
use App\Http\Controllers\Controller;
use App\Models\Tenant;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
```

Add these imports (alphabetical placement):

```php
use App\Http\Controllers\Concerns\ResolvesAdminUserId;
use App\Models\BalanceTransaction;
use App\Models\SaasAdminAuditLog;
```

Add the trait inside the class (right after the class opening brace):

```php
class AdminTenantsController extends Controller
{
    use ResolvesAdminUserId;
```

- [ ] **Step 4: Add the `updateBalance` method**

Append this method to `AdminTenantsController` (after `show()`, before the private helpers):

```php
    /**
     * PATCH /api/admin/tenants/{id}/balance — установить точный ₽-баланс тенанта.
     *
     * Семантика «set absolute»: админ передаёт целевой balance_rub, сервер
     * считает знаковую дельту (target − current) и пишет её append-only строкой
     * balance_transactions(type='manual_adjustment') + saas_admin_audit_log.
     *
     * SaaS-уровневый: НЕ tenant-aware. Money — bcmath, lockForUpdate (конвенция
     * LedgerService / AdminBillingController::refund). balance_leads не трогаем
     * (Billing v2 Spec A — лиды vestigial, удаляются в Phase B).
     */
    public function updateBalance(Request $request, int $id): JsonResponse
    {
        $validated = $request->validate([
            'balance_rub' => ['required', 'string', 'regex:/^-?\d+(\.\d{1,2})?$/'],
            'reason' => ['nullable', 'string', 'max:500'],
        ]);

        $target = bcadd((string) $validated['balance_rub'], '0', 2); // нормализуем scale 2
        $reason = isset($validated['reason']) && trim((string) $validated['reason']) !== ''
            ? trim((string) $validated['reason'])
            : 'Ручная корректировка баланса (админ)';
        $adminUserId = $this->resolveAdminUserId($request, 'system-balance@liderra.local', 'System Balance Bot');

        /** @var array{balance_rub:string, delta:string, transaction_id:int} $result */
        $result = DB::transaction(function () use ($id, $target, $reason, $adminUserId, $request): array {
            DB::statement('SET LOCAL app.current_tenant_id = '.$id);

            $tenant = DB::table('tenants')->where('id', $id)->whereNull('deleted_at')
                ->lockForUpdate()->first();
            if ($tenant === null) {
                abort(404, 'tenant not found');
            }

            $current = (string) $tenant->balance_rub;
            $delta = bcsub($target, $current, 2);
            if (bccomp($delta, '0', 2) === 0) {
                abort(422, 'balance unchanged');
            }

            DB::table('tenants')->where('id', $id)->update([
                'balance_rub' => $target,
                'updated_at' => now(),
            ]);

            $tx = BalanceTransaction::create([
                'tenant_id' => $id,
                'type' => BalanceTransaction::TYPE_MANUAL_ADJUSTMENT,
                'amount_rub' => $delta,
                'amount_leads' => null,
                'balance_rub_after' => $target,
                'balance_leads_after' => null,
                'description' => $reason,
                'admin_user_id' => $adminUserId,
                'created_at' => now(),
            ]);

            SaasAdminAuditLog::create([
                'admin_user_id' => $adminUserId,
                'action' => 'tenant.balance_adjusted',
                'target_type' => 'tenant',
                'target_id' => $id,
                'target_tenant_id' => $id,
                'payload_before' => ['balance_rub' => $current],
                'payload_after' => ['balance_rub' => $target, 'delta' => $delta, 'transaction_id' => $tx->id],
                'reason' => $reason,
                'ip_address' => $request->ip() ?? '127.0.0.1',
                'user_agent' => $request->userAgent(),
            ]);

            return ['balance_rub' => $target, 'delta' => $delta, 'transaction_id' => (int) $tx->id];
        });

        return response()->json([
            'id' => $id,
            'balance_rub' => $result['balance_rub'],
            'delta' => $result['delta'],
            'transaction_id' => $result['transaction_id'],
        ]);
    }
```

Verify `BalanceTransaction::TYPE_MANUAL_ADJUSTMENT` constant exists (it does — `= 'manual_adjustment'`, seen in the model). If the constant name differs, grep `app/app/Models/BalanceTransaction.php` for `MANUAL_ADJUSTMENT` and use the actual name.

- [ ] **Step 5: Add the route**

In `app/routes/web.php`, inside the `Route::middleware('saas-admin')->group(...)` block, right after the tenants `show` route (line ~100):

```php
    Route::patch('/api/admin/tenants/{id}/balance', 'App\Http\Controllers\Api\AdminTenantsController@updateBalance')
        ->where('id', '[0-9]+');
```

- [ ] **Step 6: Run test to verify it passes**

Run: `./vendor/bin/pest tests/Feature/Admin/AdminTenantBalanceUpdateTest.php`
Expected: PASS (6 tests).

If `saas_admin_audit_log` insert fails on a missing partition for the current month — create it (`php artisan partitions:create-months`) and re-run; this is the known testing-DB partition quirk, not a code bug.

- [ ] **Step 7: Run adjacent admin suite for regressions**

Run: `./vendor/bin/pest tests/Feature/Admin`
Expected: no NEW failures (pre-existing partition-gap failures, if any, are unrelated).

- [ ] **Step 8: Commit**

```bash
cd "/c/моя/проекты/портал crm/Документация/.claude/worktrees/admin-tenant-balance-edit"
git add app/app/Http/Controllers/Api/AdminTenantsController.php \
        app/routes/web.php \
        app/tests/Feature/Admin/AdminTenantBalanceUpdateTest.php
git commit -m "feat(admin): PATCH tenants/{id}/balance — set exact rub balance + ledger + audit"
```

Use `LEFTHOOK=0 git commit ...` if pre-commit fails on missing worktree binaries (gitleaks.exe/squawk.exe) — known worktree quirk.

---

## Task 2: Frontend API client `updateTenantBalance`

**Files:**

- Modify: `app/resources/js/api/admin.ts`

- [ ] **Step 1: Add the function**

In `app/resources/js/api/admin.ts`, after the `refundTenant` function (~line 372), add:

```typescript
export async function updateTenantBalance(
    id: number,
    payload: { balance_rub: string; reason?: string },
): Promise<{ id: number; balance_rub: string; delta: string; transaction_id: number }> {
    await ensureCsrfCookie();
    const { data } = await apiClient.patch<{
        id: number;
        balance_rub: string;
        delta: string;
        transaction_id: number;
    }>(`/api/admin/tenants/${id}/balance`, payload);
    return data;
}
```

- [ ] **Step 2: Type-check**

Run from `app/`: `npm run type-check 2>&1 | grep -E "admin.ts" | head`
Expected: no errors on `admin.ts`.

- [ ] **Step 3: No commit yet** — rides with Task 3 (its first consumer, the dialog).

---

## Task 3: `TenantBalanceDialog.vue` + Vitest

**Files:**

- Create: `app/resources/js/components/admin/TenantBalanceDialog.vue`
- Create: `app/tests/Frontend/TenantBalanceDialog.spec.ts`

- [ ] **Step 1: Write the Vitest spec (TDD)**

Create `app/tests/Frontend/TenantBalanceDialog.spec.ts`:

```typescript
import { mount } from '@vue/test-utils';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createVuetify } from 'vuetify';

import TenantBalanceDialog from '../../resources/js/components/admin/TenantBalanceDialog.vue';
import * as adminApi from '../../resources/js/api/admin';

const vuetify = createVuetify();

function mountDialog(props: Record<string, unknown> = {}) {
    return mount(TenantBalanceDialog, {
        props: {
            modelValue: true,
            tenantId: 42,
            tenantName: 'Окна Москва ООО',
            currentBalanceRub: 1000,
            ...props,
        },
        global: { plugins: [vuetify] },
        attachTo: document.body,
    });
}

describe('TenantBalanceDialog', () => {
    beforeEach(() => {
        vi.restoreAllMocks();
    });

    it('previews signed delta when new balance entered', async () => {
        const w = mountDialog();
        const vm = w.vm as unknown as { newBalance: string; delta: string };
        vm.newBalance = '2500';
        await w.vm.$nextTick();
        // delta = 2500 − 1000 = +1500
        expect((w.vm as unknown as { delta: string }).delta).toBe('1500.00');
    });

    it('disables save when balance empty or unchanged', async () => {
        const w = mountDialog();
        const vm = w.vm as unknown as { newBalance: string; canSave: boolean };
        vm.newBalance = '';
        await w.vm.$nextTick();
        expect((w.vm as unknown as { canSave: boolean }).canSave).toBe(false);
        vm.newBalance = '1000'; // равно текущему
        await w.vm.$nextTick();
        expect((w.vm as unknown as { canSave: boolean }).canSave).toBe(false);
        vm.newBalance = '1500';
        await w.vm.$nextTick();
        expect((w.vm as unknown as { canSave: boolean }).canSave).toBe(true);
    });

    it('calls updateTenantBalance with normalized payload and emits saved', async () => {
        const spy = vi.spyOn(adminApi, 'updateTenantBalance').mockResolvedValue({
            id: 42,
            balance_rub: '2500.00',
            delta: '1500.00',
            transaction_id: 7,
        });
        const w = mountDialog();
        const vm = w.vm as unknown as { newBalance: string; reason: string; submit: () => Promise<void> };
        vm.newBalance = '2500';
        vm.reason = 'тест';
        await vm.submit();

        expect(spy).toHaveBeenCalledWith(42, { balance_rub: '2500.00', reason: 'тест' });
        expect(w.emitted('saved')).toBeTruthy();
        expect(w.emitted('saved')![0][0]).toMatchObject({ balance_rub: '2500.00' });
    });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run from `app/`: `npm run test:vue -- TenantBalanceDialog.spec.ts 2>&1 | tail -20`
Expected: FAIL — component does not exist.

- [ ] **Step 3: Create the component**

Create `app/resources/js/components/admin/TenantBalanceDialog.vue`:

```vue
<script setup lang="ts">
/**
 * Диалог установки точного ₽-баланса тенанта (SaaS-admin).
 * Используется из карточки тенанта (TenantDetailHeader) и из строки таблицы
 * списка (TenantsTable). Семантика «установить точную сумму» — сервер сам
 * считает знаковую дельту и пишет manual_adjustment + audit.
 */
import { computed, ref, watch } from 'vue';
import { updateTenantBalance } from '../../api/admin';
import { extractErrorMessage } from '../../api/client';

const props = defineProps<{
    modelValue: boolean;
    tenantId: number;
    tenantName: string;
    currentBalanceRub: number;
}>();

const emit = defineEmits<{
    'update:modelValue': [value: boolean];
    saved: [payload: { balance_rub: string; delta: string; transaction_id: number }];
}>();

const newBalance = ref('');
const reason = ref('');
const submitting = ref(false);
const errorMsg = ref<string | null>(null);

// Нормализованная целевая сумма (scale 2) — '' если ввод невалиден.
const targetNormalized = computed(() => {
    const raw = newBalance.value.trim().replace(',', '.');
    if (!/^-?\d+(\.\d{1,2})?$/.test(raw)) return '';
    return Number(raw).toFixed(2);
});

const delta = computed(() => {
    if (targetNormalized.value === '') return '';
    return (Number(targetNormalized.value) - props.currentBalanceRub).toFixed(2);
});

const canSave = computed(
    () => !submitting.value && targetNormalized.value !== '' && delta.value !== '' && Number(delta.value) !== 0,
);

watch(
    () => props.modelValue,
    (open) => {
        if (open) {
            newBalance.value = '';
            reason.value = '';
            errorMsg.value = null;
        }
    },
);

async function submit(): Promise<void> {
    if (!canSave.value) return;
    submitting.value = true;
    errorMsg.value = null;
    try {
        const payload: { balance_rub: string; reason?: string } = { balance_rub: targetNormalized.value };
        if (reason.value.trim() !== '') payload.reason = reason.value.trim();
        const result = await updateTenantBalance(props.tenantId, payload);
        emit('saved', { balance_rub: result.balance_rub, delta: result.delta, transaction_id: result.transaction_id });
        emit('update:modelValue', false);
    } catch (e) {
        errorMsg.value = extractErrorMessage(e, 'Не удалось изменить баланс.');
    } finally {
        submitting.value = false;
    }
}

function close(): void {
    emit('update:modelValue', false);
}
</script>

<template>
    <v-dialog
        :model-value="modelValue"
        max-width="460"
        @update:model-value="emit('update:modelValue', $event)"
    >
        <v-card>
            <v-card-title class="text-h6">Изменить баланс</v-card-title>
            <v-card-subtitle>{{ tenantName }}</v-card-subtitle>
            <v-card-text>
                <div class="text-body-2 text-medium-emphasis mb-3">
                    Текущий баланс: <strong class="num">{{ currentBalanceRub.toFixed(2) }} ₽</strong>
                </div>

                <v-text-field
                    v-model="newBalance"
                    label="Новый баланс, ₽"
                    type="text"
                    inputmode="decimal"
                    density="comfortable"
                    data-testid="balance-input"
                    :hint="targetNormalized === '' && newBalance !== '' ? 'Формат: 1234.56' : ''"
                    persistent-hint
                />

                <v-text-field
                    v-model="reason"
                    label="Причина (необязательно)"
                    type="text"
                    density="comfortable"
                    maxlength="500"
                    class="mt-2"
                    data-testid="reason-input"
                />

                <div v-if="delta !== ''" class="preview mt-3 text-body-2">
                    было <span class="num">{{ currentBalanceRub.toFixed(2) }} ₽</span>
                    → станет <span class="num">{{ targetNormalized }} ₽</span>
                    (<span class="num" :class="Number(delta) < 0 ? 'text-error' : 'text-success'">
                        {{ Number(delta) > 0 ? '+' : '' }}{{ delta }} ₽
                    </span>)
                </div>

                <v-alert v-if="errorMsg" type="error" variant="tonal" density="compact" class="mt-3">
                    {{ errorMsg }}
                </v-alert>
            </v-card-text>
            <v-card-actions>
                <v-spacer />
                <v-btn variant="text" :disabled="submitting" @click="close">Отмена</v-btn>
                <v-btn
                    color="primary"
                    variant="flat"
                    :loading="submitting"
                    :disabled="!canSave"
                    data-testid="balance-save"
                    @click="submit"
                >
                    Сохранить
                </v-btn>
            </v-card-actions>
        </v-card>
    </v-dialog>
</template>

<style scoped>
.num {
    font-family: 'JetBrains Mono', ui-monospace, monospace;
    font-feature-settings: 'tnum';
}
</style>
```

- [ ] **Step 4: Run Vitest**

Run from `app/`: `npm run test:vue -- TenantBalanceDialog.spec.ts 2>&1 | tail -20`
Expected: PASS (3 tests). If `extractErrorMessage` signature differs (e.g. single-arg), check `app/resources/js/api/client.ts` and adapt the call.

- [ ] **Step 5: vue-tsc**

Run: `npm run type-check 2>&1 | grep -E "TenantBalanceDialog|admin.ts" | head`
Expected: 0 errors.

- [ ] **Step 6: Commit (Task 2 api + Task 3 dialog together)**

```bash
git add app/resources/js/api/admin.ts \
        app/resources/js/components/admin/TenantBalanceDialog.vue \
        app/tests/Frontend/TenantBalanceDialog.spec.ts
git commit -m "feat(admin): TenantBalanceDialog + updateTenantBalance api client"
```

---

## Task 4: Wire dialog into tenant detail card

**Files:**

- Modify: `app/resources/js/components/admin/tenant-detail/TenantDetailHeader.vue`
- Modify: `app/resources/js/views/admin/AdminTenantDetailView.vue`

- [ ] **Step 1: Add «Изменить баланс» button to the balance KPI card**

In `TenantDetailHeader.vue`, the `defineEmits` is currently:

```typescript
const emit = defineEmits<{
    back: [];
    impersonate: [];
}>();
```

Change to add `editBalance`:

```typescript
const emit = defineEmits<{
    back: [];
    impersonate: [];
    editBalance: [];
}>();
```

In the template, the balance KPI card is:

```vue
<v-card variant="outlined" class="kpi-card pa-4" data-testid="kpi-balance">
    <div class="kpi-label text-caption text-medium-emphasis">Баланс</div>
    <div class="kpi-value num" :class="{ 'text-error': tenant.balanceRub < 0 }">
        {{ formatRub(tenant.balanceRub) }}
    </div>
    <div class="kpi-sub text-caption text-medium-emphasis">runway ~{{ tenant.runwayDays }} дн</div>
</v-card>
```

Add an «Изменить» button after the `kpi-sub` div, inside the card:

```vue
<v-card variant="outlined" class="kpi-card pa-4" data-testid="kpi-balance">
    <div class="kpi-label text-caption text-medium-emphasis">Баланс</div>
    <div class="kpi-value num" :class="{ 'text-error': tenant.balanceRub < 0 }">
        {{ formatRub(tenant.balanceRub) }}
    </div>
    <div class="kpi-sub text-caption text-medium-emphasis">runway ~{{ tenant.runwayDays }} дн</div>
    <v-btn
        variant="text"
        size="small"
        density="comfortable"
        prepend-icon="mdi-pencil"
        class="mt-1 px-0"
        data-testid="edit-balance-btn"
        @click="emit('editBalance')"
    >
        Изменить
    </v-btn>
</v-card>
```

- [ ] **Step 2: Mount the dialog in the detail view**

In `AdminTenantDetailView.vue`:

Add import after the existing component imports (~line 24):

```typescript
import TenantBalanceDialog from '../../components/admin/TenantBalanceDialog.vue';
```

Add state near `impersonationOpen` (~line 66):

```typescript
const balanceDialogOpen = ref(false);
```

In the template, inside the `v-container v-if="tenant"` block, after `<ImpersonationDialog ... />`, add:

```vue
<TenantBalanceDialog
    v-model="balanceDialogOpen"
    :tenant-id="tenant.id"
    :tenant-name="tenant.name"
    :current-balance-rub="tenant.balanceRub"
    @saved="onBalanceSaved"
/>
```

Wire the header emit on the `<TenantDetailHeader>` element:

```vue
<TenantDetailHeader
    :tenant="tenant"
    @back="goBack"
    @impersonate="impersonationOpen = true"
    @edit-balance="balanceDialogOpen = true"
/>
```

Add the `onBalanceSaved` handler (after `goBack`):

```typescript
async function onBalanceSaved(): Promise<void> {
    await loadTenant();
}
```

Add `balanceDialogOpen` to `defineExpose` so Vitest can drive it:

```typescript
defineExpose({ tenant, activeTab, impersonationOpen, balanceDialogOpen, loadTenant });
```

Confirm `AdminTenantDetail` mock type has a numeric `id` field (it does — `mockTenantDetail.ts:11 id: number`) and `balanceRub: number` (used by header). If `tenant.id` is absent on the mapped type, check `adminTenantDetailMapper.ts` maps `tenant.id` from the API response and add it.

- [ ] **Step 3: Run frontend checks**

Run from `app/`:

```bash
npm run test:vue -- AdminTenantDetailView 2>&1 | tail -20
npm run type-check 2>&1 | grep -E "AdminTenantDetailView|TenantDetailHeader" | head
```

Expected: existing detail-view tests still pass; vue-tsc clean. If an existing test mounts `TenantDetailHeader` and asserts emitted events, it remains valid (we only added an emit).

- [ ] **Step 4: Commit**

```bash
git add app/resources/js/components/admin/tenant-detail/TenantDetailHeader.vue \
        app/resources/js/views/admin/AdminTenantDetailView.vue
git commit -m "feat(admin): wire balance dialog into tenant detail card"
```

---

## Task 5: Wire dialog into tenant list table

**Files:**

- Modify: `app/resources/js/components/admin/tenants/TenantsTable.vue`
- Modify: `app/resources/js/views/admin/AdminTenantsView.vue`

- [ ] **Step 1: Add row action + emit in TenantsTable**

In `TenantsTable.vue`, `defineEmits` is:

```typescript
const emit = defineEmits<{
    rowClick: [tenant: AdminTenant];
    impersonate: [tenant: AdminTenant];
}>();
```

Add `editBalance`:

```typescript
const emit = defineEmits<{
    rowClick: [tenant: AdminTenant];
    impersonate: [tenant: AdminTenant];
    editBalance: [tenant: AdminTenant];
}>();
```

In the `#[`item.actions`]` slot, add a balance-edit icon button before the impersonate tooltip (inside the same slot):

```vue
<template #[`item.actions`]="{ item }: { item: AdminTenant }">
    <v-tooltip text="Изменить баланс" location="top" aria-label="Изменить баланс">
        <template #activator="{ props: tipProps }">
            <v-btn
                v-bind="tipProps"
                icon="mdi-cash-edit"
                variant="text"
                size="small"
                density="comfortable"
                :aria-label="`Изменить баланс для ${item.name}`"
                :data-testid="`edit-balance-btn-${item.id}`"
                @click.stop="emit('editBalance', item)"
            />
        </template>
    </v-tooltip>
    <v-tooltip
        text="Войти как клиент (impersonation)"
        location="top"
        aria-label="Войти как клиент (impersonation)"
    >
        <template #activator="{ props: tipProps }">
            <v-btn
                v-bind="tipProps"
                icon="mdi-account-switch"
                variant="text"
                size="small"
                density="comfortable"
                :aria-label="`Войти как клиент (impersonation) для ${item.name}`"
                :disabled="item.status === 'suspended'"
                :data-testid="`impersonate-btn-${item.id}`"
                @click.stop="emit('impersonate', item)"
            />
        </template>
    </v-tooltip>
</template>
```

Widen the actions column so two icons fit — change the `actions` header `width: 56` to `width: 96` in the `:headers` array.

- [ ] **Step 2: Mount the dialog in the list view**

Read `app/resources/js/views/admin/AdminTenantsView.vue` first to see how it consumes `TenantsTable` and where it keeps state / how it reloads the list (look for the `listAdminTenants` call and the mapped tenants ref).

Then:

- Import `TenantBalanceDialog` and (if not already) ensure tenants list is in a reactive ref with a reload function.
- Add state: `const balanceDialogOpen = ref(false);` and `const balanceTarget = ref<AdminTenant | null>(null);`.
- Wire `<TenantsTable ... @edit-balance="openBalanceDialog" />`.
- Add handler:

  ```typescript
  function openBalanceDialog(t: AdminTenant): void {
      balanceTarget.value = t;
      balanceDialogOpen.value = true;
  }
  async function onBalanceSaved(): Promise<void> {
      // перезагрузить список, чтобы строка показала новый баланс
      await loadTenants(); // имя реальной функции загрузки — взять из файла
  }
  ```

  (Use the actual list-loader function name found in the file.)
- Mount the dialog (guarded by `balanceTarget`):

  ```vue
  <TenantBalanceDialog
      v-if="balanceTarget"
      v-model="balanceDialogOpen"
      :tenant-id="balanceTarget.id"
      :tenant-name="balanceTarget.name"
      :current-balance-rub="balanceTarget.balanceRub"
      @saved="onBalanceSaved"
  />
  ```

- [ ] **Step 3: Run frontend checks**

Run from `app/`:

```bash
npm run test:vue -- AdminTenantsView 2>&1 | tail -20
npm run type-check 2>&1 | grep -E "AdminTenantsView|TenantsTable" | head
```

Expected: existing list-view tests pass; vue-tsc clean.

- [ ] **Step 4: Commit**

```bash
git add app/resources/js/components/admin/tenants/TenantsTable.vue \
        app/resources/js/views/admin/AdminTenantsView.vue
git commit -m "feat(admin): wire balance dialog into tenant list table"
```

---

## Task 6: Full frontend + backend regression

**Files:** none directly; fix wherever it breaks.

- [ ] **Step 1: Run targeted suites**

```bash
cd app
./vendor/bin/pest tests/Feature/Admin/AdminTenantBalanceUpdateTest.php
npm run test:vue -- TenantBalanceDialog 2>&1 | tail -10
npm run type-check 2>&1 | tail -20
npm run lint:vue 2>&1 | tail -20
npm run build 2>&1 | tail -5
```

Expected: all green (pre-existing unrelated failures excluded).

- [ ] **Step 2: Fix any breaks, commit incrementally**

Each fix = own commit: `fix(admin): <what>`.

---

## Spec Coverage Check (self-review)

| Spec requirement | Task | Status |
|---|---|---|
| Set-absolute semantics, server computes delta | Task 1 | ✓ |
| `manual_adjustment` ledger row, signed amount | Task 1 | ✓ |
| `saas_admin_audit_log` `tenant.balance_adjusted` | Task 1 | ✓ |
| bcmath + lockForUpdate, SaaS connection / SET LOCAL | Task 1 | ✓ |
| Validation: decimal regex, negative allowed, reason optional, no-op 422, 404 | Task 1 | ✓ |
| Route under `saas-admin`, id-constrained | Task 1 | ✓ |
| API client `updateTenantBalance` | Task 2 | ✓ |
| Shared `TenantBalanceDialog` with live delta preview | Task 3 | ✓ |
| Edit from tenant detail card | Task 4 | ✓ |
| Edit inline from list table | Task 5 | ✓ |
| balance_leads NOT edited | Task 1 (amount_leads null, no leads field) | ✓ |
| Tests: Pest + Vitest | Tasks 1, 3 | ✓ |

---

## Plan complete

**Deployment after merge:** копир-паттерном на боевой `liderra.ru` (3 PHP-файла + frontend `public/build`); DDL не требуется. После выкатки заказчик выставляет реальные балансы тестовым тенантам через UI.
