# Балансы внешних сервисов — 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:** Ежедневно читать баланс 3 внешних сервисов (Поставщик crm.bp-gr, DaData, Yandex Cloud) и показывать плиткой в дашборде «Командный центр»: баланс + «хватит на ~N дней» + светофор.

**Architecture:** Адаптер на сервис (`BalanceProvider`) → ежедневная джоба собирает в таблицу `external_service_balances` → дашборд-эндпоинт отдаёт плиткой. Логика порога/дней — чистый сервис `BalanceHealth`. Сеть только в провайдерах; каждый изолирован (падение одного не роняет плитку).

**Tech Stack:** Laravel 13, PostgreSQL 16, Pest 4 (backend), Vue 3 + Vuetify 3 + Vitest (frontend).

**Spec:** `docs/superpowers/specs/2026-06-28-external-service-balances-design.md`
**Базовая ветка:** `feat/admin-command-center` (там уже дашборд Этапы 1+2 + фиксы). Коммитить только файлы из задач по явным путям.

**Доступы (готовы на проде):** DaData `services.dadata.api_key`; Supplier `services.supplier.{login,password,portal_url}` + Playwright установлен + сессия в Redis; YC `.env`: `YC_OAUTH_TOKEN`, `YC_BILLING_ACCOUNT_ID=dn2w7fcvynjxe6elljct` (interim OAuth; SA `billing-reader` создан, ждёт роль).

---

## Amendment A (28.06): кнопки «Пополнить» в плитке

**Требование владельца:** у каждой строки сервиса в дашборде — кнопка-ссылка **«Пополнить»**, открывает страницу оплаты сервиса в новой вкладке (владелец работает с планшета: увидел минус → ткнул → попал на оплату).

URL'ы оплаты — **статика из конфига** (в БД не храним, это presentation):
- `yandex_cloud` → строится из `console_billing_url` + `billing_account_id` + `/payments`: `https://console.yandex.cloud/billing/accounts/dn2w7fcvynjxe6elljct/payments`
- `dadata` → `https://dadata.ru/profile/#billing` (страница баланса/пополнения)
- `supplier` → кабинет crm.bp-gr (`portal_url` или явный `topup_url`)

Реализация: метод `topupUrl(string $key): ?string` в контроллере добавляет `topup_url` в каждую строку ответа `balances()`; во фронте — `v-btn`/ссылка «Пополнить» (target=_blank, rel=noopener) в drill-таблице (и в плитке у красных). Затрагивает: config-задачи (Tasks 4/5/6 — добавить URL'ы), Task 8 (endpoint +`topup_url`), Task 10 (кнопка в drill). Тест Task 8: ответ содержит `topup_url` у yandex_cloud; Task 10: кнопка с правильным href.

---

## File Structure

- Create: `app/database/migrations/2026_06_28_120000_create_external_service_balances_table.php`
- Create: `app/app/Services/Dashboard/BalanceHealth.php` — чистая логика (дни + светофор).
- Create: `app/app/Services/External/BalanceReading.php` — DTO.
- Create: `app/app/Services/External/BalanceProvider.php` — интерфейс.
- Create: `app/app/Services/External/DadataBalanceProvider.php`
- Create: `app/app/Services/External/YandexCloudBalanceProvider.php`
- Create: `app/app/Services/External/SupplierBalanceProvider.php` + `app/playwright/supplier-balance.js`
- Create: `app/app/Jobs/External/RefreshExternalBalancesJob.php`
- Modify: `app/routes/console.php` (или bootstrap/app.php withSchedule) — ежедневный запуск.
- Modify: `app/config/services.php` — секции балансов + пороги.
- Modify: `app/app/Http/Controllers/Api/AdminDashboardController.php` — `balances()` + плитка в `summary`.
- Modify: `app/routes/web.php` — роут `/api/admin/dashboard/balances`.
- Modify: `app/resources/js/api/adminDashboard.ts` — типы + клиент.
- Modify: `app/resources/js/views/admin/AdminDashboardView.vue` — 5-я плитка + drill.
- Tests: `tests/Unit/Dashboard/BalanceHealthTest.php`, `tests/Feature/Admin/AdminDashboardBalancesTest.php`, `tests/Unit/External/{Dadata,YandexCloud}BalanceProviderTest.php`, `tests/Frontend/AdminDashboardView.spec.ts` (доп. проверки).

Все команды — из `app/`.

---

## Task 1: Таблица `external_service_balances`

**Files:** Create migration (путь выше).

- [ ] **Step 1: Миграция**

```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('external_service_balances', function (Blueprint $t) {
            $t->string('service_key', 32)->primary(); // dadata | supplier | yandex_cloud
            $t->decimal('balance_amount', 14, 2)->nullable();
            $t->string('currency', 8)->default('RUB');
            $t->decimal('daily_spend_estimate', 14, 2)->nullable();
            $t->integer('days_left')->nullable();
            $t->string('light', 8)->default('green'); // green|amber|red|grey
            $t->boolean('ok')->default(false);
            $t->text('error')->nullable();
            $t->timestampTz('checked_at')->nullable();
            $t->timestampsTz();
        });
    }
    public function down(): void { Schema::dropIfExists('external_service_balances'); }
};
```

- [ ] **Step 2:** `php artisan migrate` (на dev — liderra_testing через тесты; на проде позже в выкат). Ожидаемо: таблица создана.
- [ ] **Step 3: Commit** `git add app/database/migrations/2026_06_28_120000_create_external_service_balances_table.php && git commit -F <msg>` «feat(балансы): таблица external_service_balances».

NB по схеме (правило §4.2): дописать строку в `db/CHANGELOG_schema.md` если таблица идёт в `db/schema.sql`; миграция — источник, schema.sql обновить отдельным шагом при синке.

---

## Task 2: Чистая логика `BalanceHealth`

**Files:** Create `app/app/Services/Dashboard/BalanceHealth.php` + test.

- [ ] **Step 1: Failing test** — `tests/Unit/Dashboard/BalanceHealthTest.php`:

```php
<?php
declare(strict_types=1);
use App\Services\Dashboard\BalanceHealth;

it('красный при низком балансе ИЛИ малом числе дней', function () {
    // balance 1000, spend 500/д → days=2 → red (дней<3)
    expect(BalanceHealth::evaluate(1000, 500, 5000, 15000))->toMatchArray(['days_left' => 2, 'light' => 'red']);
    // balance 4000 (< red_floor 5000), spend 100 → days 40, но баланс<red → red
    expect(BalanceHealth::evaluate(4000, 100, 5000, 15000)['light'])->toBe('red');
    // balance 12000 (< amber 15000), days большой → amber
    expect(BalanceHealth::evaluate(12000, 100, 5000, 15000)['light'])->toBe('amber');
    // balance 100000, spend 100 → days 1000, выше порогов → green
    expect(BalanceHealth::evaluate(100000, 100, 5000, 15000)['light'])->toBe('green');
    // нет данных о расходе → days_left null, светофор только по порогам
    expect(BalanceHealth::evaluate(100000, null, 5000, 15000))->toMatchArray(['days_left' => null, 'light' => 'green']);
});
```

- [ ] **Step 2:** `php artisan test tests/Unit/Dashboard/BalanceHealthTest.php` → FAIL (нет класса).

- [ ] **Step 3: Реализация**

```php
<?php
declare(strict_types=1);
namespace App\Services\Dashboard;

class BalanceHealth
{
    /**
     * @return array{days_left:?int,light:string}
     */
    public static function evaluate(
        float $balance,
        ?float $dailySpend,
        float $redFloor,
        float $amberFloor,
    ): array {
        $days = ($dailySpend !== null && $dailySpend > 0)
            ? (int) floor($balance / $dailySpend)
            : null;

        $light = 'green';
        if ($balance < $amberFloor || ($days !== null && $days < 7)) {
            $light = 'amber';
        }
        if ($balance < $redFloor || ($days !== null && $days < 3)) {
            $light = 'red';
        }

        return ['days_left' => $days, 'light' => $light];
    }
}
```

- [ ] **Step 4:** тест → PASS.
- [ ] **Step 5: Commit** «feat(балансы): BalanceHealth — пороги + дни».

---

## Task 3: DTO + интерфейс провайдера

**Files:** Create `BalanceReading.php`, `BalanceProvider.php`.

- [ ] **Step 1: BalanceReading**

```php
<?php
declare(strict_types=1);
namespace App\Services\External;
use Illuminate\Support\Carbon;

final class BalanceReading
{
    public function __construct(
        public readonly string $serviceKey,
        public readonly ?float $balance,
        public readonly string $currency,
        public readonly ?float $dailySpend,
        public readonly bool $ok,
        public readonly ?string $error,
        public readonly Carbon $checkedAt,
    ) {}

    public static function ok(string $key, float $balance, string $currency, ?float $dailySpend): self
    {
        return new self($key, $balance, $currency, $dailySpend, true, null, now());
    }

    public static function fail(string $key, string $error): self
    {
        return new self($key, null, 'RUB', null, false, mb_substr($error, 0, 500), now());
    }
}
```

- [ ] **Step 2: BalanceProvider**

```php
<?php
declare(strict_types=1);
namespace App\Services\External;

interface BalanceProvider
{
    public function serviceKey(): string;   // dadata | supplier | yandex_cloud

    public function fetch(): BalanceReading; // НЕ бросает: ошибку заворачивает в BalanceReading::fail
}
```

- [ ] **Step 3: Commit** «feat(балансы): BalanceReading DTO + BalanceProvider интерфейс».

---

## Task 4: DaData провайдер

**Files:** Create `DadataBalanceProvider.php` + test. Modify `config/services.php`.

- [ ] **Step 1: config** — в `services.dadata` добавить пороги:

```php
        'red_floor_rub' => (int) env('DADATA_RED_FLOOR_RUB', 500),
        'amber_floor_rub' => (int) env('DADATA_AMBER_FLOOR_RUB', 2000),
        'balance_url' => env('DADATA_BALANCE_URL', 'https://dadata.ru/api/v2/profile/balance'),
```

- [ ] **Step 2: Failing test** — `tests/Unit/External/DadataBalanceProviderTest.php` (мок Http):

```php
<?php
declare(strict_types=1);
use App\Services\External\DadataBalanceProvider;
use Illuminate\Support\Facades\Http;

it('читает баланс DaData по API', function () {
    config()->set('services.dadata.api_key', 'test-token');
    Http::fake(['dadata.ru/*' => Http::response(['balance' => 4500.0], 200)]);
    $r = app(DadataBalanceProvider::class)->fetch();
    expect($r->ok)->toBeTrue();
    expect($r->balance)->toBe(4500.0);
    expect($r->serviceKey)->toBe('dadata');
});

it('ошибка API → fail, не бросает', function () {
    config()->set('services.dadata.api_key', 'test-token');
    Http::fake(['dadata.ru/*' => Http::response('forbidden', 403)]);
    $r = app(DadataBalanceProvider::class)->fetch();
    expect($r->ok)->toBeFalse();
    expect($r->error)->not->toBeNull();
});
```

- [ ] **Step 3: Реализация**

```php
<?php
declare(strict_types=1);
namespace App\Services\External;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;

class DadataBalanceProvider implements BalanceProvider
{
    public function serviceKey(): string { return 'dadata'; }

    public function fetch(): BalanceReading
    {
        try {
            $key = (string) config('services.dadata.api_key');
            if ($key === '') {
                return BalanceReading::fail('dadata', 'DaData api_key не задан');
            }
            $resp = Http::timeout(10)
                ->withHeaders(['Authorization' => 'Token '.$key, 'Accept' => 'application/json'])
                ->get((string) config('services.dadata.balance_url'));
            if (! $resp->ok()) {
                return BalanceReading::fail('dadata', 'HTTP '.$resp->status());
            }
            $balance = (float) ($resp->json('balance') ?? 0);
            return BalanceReading::ok('dadata', $balance, 'RUB', $this->dailySpend());
        } catch (\Throwable $e) {
            return BalanceReading::fail('dadata', $e->getMessage());
        }
    }

    /** Оценка расхода/день: вызовы резолва за 7д × стоимость вызова. */
    private function dailySpend(): ?float
    {
        $costRub = ((int) config('services.dadata.call_cost_kopecks', 60)) / 100;
        $calls7d = DB::table('supplier_leads')
            ->where('region_source', 'dadata')
            ->where('received_at', '>=', now()->subDays(7))
            ->count();
        if ($calls7d === 0) {
            return null;
        }
        return round(($calls7d / 7) * $costRub, 2);
    }
}
```

- [ ] **Step 4:** тест → PASS.
- [ ] **Step 5: Commit** «feat(балансы): DaData провайдер».

---

## Task 5: Yandex Cloud провайдер

**Files:** Create `YandexCloudBalanceProvider.php` + test. Modify `config/services.php`.

- [ ] **Step 1: config** — новая секция:

```php
    'yandex_cloud' => [
        'oauth_token' => env('YC_OAUTH_TOKEN'),
        'billing_account_id' => env('YC_BILLING_ACCOUNT_ID'),
        'iam_url' => env('YC_IAM_URL', 'https://iam.api.cloud.yandex.net/iam/v1/tokens'),
        'billing_url' => env('YC_BILLING_URL', 'https://billing.api.cloud.yandex.net/billing/v1/billingAccounts'),
        'daily_spend_rub' => (int) env('YC_DAILY_SPEND_RUB', 600), // оценка ~18к/мес; уточнить
        'red_floor_rub' => (int) env('YC_RED_FLOOR_RUB', 1000),
        'amber_floor_rub' => (int) env('YC_AMBER_FLOOR_RUB', 5000),
    ],
```

- [ ] **Step 2: Failing test** — `tests/Unit/External/YandexCloudBalanceProviderTest.php`:

```php
<?php
declare(strict_types=1);
use App\Services\External\YandexCloudBalanceProvider;
use Illuminate\Support\Facades\Http;

it('читает баланс YC: OAuth→IAM→billing', function () {
    config()->set('services.yandex_cloud.oauth_token', 'oauth-x');
    config()->set('services.yandex_cloud.billing_account_id', 'dn-test');
    Http::fake([
        'iam.api.cloud.yandex.net/*' => Http::response(['iamToken' => 'iam-x'], 200),
        'billing.api.cloud.yandex.net/*' => Http::response(['balance' => '-540.48', 'currency' => 'RUB'], 200),
    ]);
    $r = app(YandexCloudBalanceProvider::class)->fetch();
    expect($r->ok)->toBeTrue();
    expect($r->balance)->toBe(-540.48);
    expect($r->currency)->toBe('RUB');
});

it('нет токена → fail', function () {
    config()->set('services.yandex_cloud.oauth_token', null);
    $r = app(YandexCloudBalanceProvider::class)->fetch();
    expect($r->ok)->toBeFalse();
});
```

- [ ] **Step 3: Реализация**

```php
<?php
declare(strict_types=1);
namespace App\Services\External;
use Illuminate\Support\Facades\Http;

class YandexCloudBalanceProvider implements BalanceProvider
{
    public function serviceKey(): string { return 'yandex_cloud'; }

    public function fetch(): BalanceReading
    {
        try {
            $oauth = (string) config('services.yandex_cloud.oauth_token');
            $acc = (string) config('services.yandex_cloud.billing_account_id');
            if ($oauth === '' || $acc === '') {
                return BalanceReading::fail('yandex_cloud', 'YC доступ не настроен');
            }
            $iam = Http::timeout(10)->post((string) config('services.yandex_cloud.iam_url'), [
                'yandexPassportOauthToken' => $oauth,
            ]);
            if (! $iam->ok() || ! $iam->json('iamToken')) {
                return BalanceReading::fail('yandex_cloud', 'IAM exchange: HTTP '.$iam->status());
            }
            $resp = Http::timeout(10)
                ->withToken((string) $iam->json('iamToken'))
                ->get((string) config('services.yandex_cloud.billing_url').'/'.$acc);
            if (! $resp->ok()) {
                return BalanceReading::fail('yandex_cloud', 'Billing: HTTP '.$resp->status());
            }
            $balance = (float) ($resp->json('balance') ?? 0);
            $currency = (string) ($resp->json('currency') ?? 'RUB');
            $spend = (float) config('services.yandex_cloud.daily_spend_rub') ?: null;
            return BalanceReading::ok('yandex_cloud', $balance, $currency, $spend);
        } catch (\Throwable $e) {
            return BalanceReading::fail('yandex_cloud', $e->getMessage());
        }
    }
}
```

- [ ] **Step 4:** тест → PASS.
- [ ] **Step 5: Commit** «feat(балансы): Yandex Cloud провайдер (OAuth→IAM→billing)».

---

## Task 6: Supplier провайдер (Playwright)

**Files:** Create `app/playwright/supplier-balance.js`, `SupplierBalanceProvider.php`. Modify `config/services.php` (пороги).

- [ ] **Step 1: Разведка баланса в кабинете** (один раз, вручную при реализации):
  На проде под www-data запустить осмотр страницы кабинета (логин из config, сессия Playwright). Цель — найти, где на странице показан баланс (текст вида «Баланс: N ₽» / селектор). Команда-проба (read-only):
  ```bash
  ssh liderra "cd /var/www/liderra/app && sudo -u www-data HOME=/var/www node -e \"const {chromium}=require('playwright');(async()=>{const b=await chromium.launch();const p=await b.newPage();await p.goto(process.env.SUP_URL);/* login flow как в refresh-session.js */ console.log(await p.content());await b.close()})().catch(e=>{console.error(e);process.exit(1)})\""
  ```
  Зафиксировать селектор/regex баланса. (Если кабинет не показывает баланс на доступной странице — отметить в отчёте, провайдер вернёт `fail` «баланс не найден», и эскалировать владельцу.)

- [ ] **Step 2: `app/playwright/supplier-balance.js`** — по образцу `refresh-session.js`: на stdin `{login,password,url}`, логинится, открывает страницу с балансом, извлекает число по найденному в Step 1 селектору, печатает JSON `{"balance": N, "currency":"RUB"}`; exit 0 успех / 1 auth / 2 не найден баланс / 3 timeout. (Полный код пишется после Step 1 по реальному селектору — здесь не плейсхолдер, а зависимость от разведки.)

- [ ] **Step 3: `SupplierBalanceProvider.php`** — запускает node-скрипт через тот же `PlaywrightBridge`-паттерн (ProcessFactory, base_path, timeout), парсит JSON. Расход/день: `supplier_leads` за 7д ÷ 7 × средняя цена лида (из `lead_charges` или `services.supplier`-конфига); если цены нет → `dailySpend=null`. Ошибку заворачивает в `BalanceReading::fail`.

```php
<?php
declare(strict_types=1);
namespace App\Services\External;
use App\Services\Supplier\PlaywrightBridge;
use Illuminate\Support\Facades\DB;

class SupplierBalanceProvider implements BalanceProvider
{
    public function __construct(private readonly PlaywrightBridge $bridge) {}

    public function serviceKey(): string { return 'supplier'; }

    public function fetch(): BalanceReading
    {
        try {
            $out = $this->bridge->runScript('supplier-balance.js', [
                'login' => (string) config('services.supplier.login'),
                'password' => (string) config('services.supplier.password'),
                'url' => (string) config('services.supplier.portal_url'),
            ]);
            $balance = (float) ($out['balance'] ?? 0);
            return BalanceReading::ok('supplier', $balance, (string) ($out['currency'] ?? 'RUB'), $this->dailySpend());
        } catch (\Throwable $e) {
            return BalanceReading::fail('supplier', $e->getMessage());
        }
    }

    private function dailySpend(): ?float
    {
        $leads7d = DB::table('supplier_leads')->where('received_at', '>=', now()->subDays(7))->count();
        $price = (float) config('services.supplier.avg_lead_price_rub', 0);
        if ($leads7d === 0 || $price <= 0) {
            return null;
        }
        return round(($leads7d / 7) * $price, 2);
    }
}
```

> NB: `PlaywrightBridge::runScript('supplier-balance.js', $args)` — generic runner уже есть (использует FormProjectChannel). Проверить сигнатуру и переиспользовать.

- [ ] **Step 4: config** — `services.supplier` добавить `'avg_lead_price_rub' => (float) env('SUPPLIER_AVG_LEAD_PRICE_RUB', 0)`, пороги `supplier_red_floor_rub`/`supplier_amber_floor_rub`.
- [ ] **Step 5: Commit** «feat(балансы): Supplier провайдер (Playwright чтение баланса кабинета)».

---

## Task 7: Ежедневная джоба

**Files:** Create `RefreshExternalBalancesJob.php`. Modify планировщик.

- [ ] **Step 1: Джоба** — перебирает 3 провайдера, каждый в try/catch, upsert в таблицу с пересчётом порога:

```php
<?php
declare(strict_types=1);
namespace App\Jobs\External;
use App\Services\Dashboard\BalanceHealth;
use App\Services\External\{BalanceProvider, DadataBalanceProvider, SupplierBalanceProvider, YandexCloudBalanceProvider};
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Support\Facades\DB;

class RefreshExternalBalancesJob implements ShouldQueue
{
    use Dispatchable, Queueable;

    /** @return array<int,class-string<BalanceProvider>> */
    private function providers(): array
    {
        return [DadataBalanceProvider::class, SupplierBalanceProvider::class, YandexCloudBalanceProvider::class];
    }

    public function handle(): void
    {
        foreach ($this->providers() as $cls) {
            /** @var BalanceProvider $p */
            $p = app($cls);
            $key = $p->serviceKey();
            $reading = $p->fetch(); // не бросает
            if (! $reading->ok) {
                // оставляем прошлый баланс, помечаем ok=false + ошибку
                DB::table('external_service_balances')->updateOrInsert(
                    ['service_key' => $key],
                    ['ok' => false, 'error' => $reading->error, 'checked_at' => $reading->checkedAt, 'updated_at' => now()],
                );
                continue;
            }
            $floors = $this->floors($key);
            $h = BalanceHealth::evaluate($reading->balance, $reading->dailySpend, $floors[0], $floors[1]);
            DB::table('external_service_balances')->updateOrInsert(
                ['service_key' => $key],
                [
                    'balance_amount' => $reading->balance,
                    'currency' => $reading->currency,
                    'daily_spend_estimate' => $reading->dailySpend,
                    'days_left' => $h['days_left'],
                    'light' => $h['light'],
                    'ok' => true,
                    'error' => null,
                    'checked_at' => $reading->checkedAt,
                    'updated_at' => now(),
                ],
            );
        }
    }

    /** @return array{0:float,1:float} red_floor, amber_floor */
    private function floors(string $key): array
    {
        return match ($key) {
            'dadata' => [(float) config('services.dadata.red_floor_rub'), (float) config('services.dadata.amber_floor_rub')],
            'yandex_cloud' => [(float) config('services.yandex_cloud.red_floor_rub'), (float) config('services.yandex_cloud.amber_floor_rub')],
            'supplier' => [(float) config('services.supplier.supplier_red_floor_rub', 5000), (float) config('services.supplier.supplier_amber_floor_rub', 15000)],
            default => [0, 0],
        };
    }
}
```

- [ ] **Step 2: Расписание** — зарегистрировать ежедневный запуск (утро МСК). Найти где определён schedule (`routes/console.php` Schedule:: или `bootstrap/app.php` ->withSchedule) и добавить:

```php
Schedule::job(new \App\Jobs\External\RefreshExternalBalancesJob)->dailyAt('06:30')->timezone('Europe/Moscow');
```

- [ ] **Step 3: Commit** «feat(балансы): ежедневная джоба сбора балансов».

---

## Task 8: Backend дашборда — `balances()` + плитка в summary

**Files:** Modify `AdminDashboardController.php`, `routes/web.php`. Test `tests/Feature/Admin/AdminDashboardBalancesTest.php`.

- [ ] **Step 1: Failing test**:

```php
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);

it('GET /api/admin/dashboard/balances возвращает строки сервисов', function () {
    DB::table('external_service_balances')->updateOrInsert(['service_key' => 'dadata'],
        ['balance_amount' => 4500, 'currency' => 'RUB', 'days_left' => 9, 'light' => 'green', 'ok' => true, 'checked_at' => now()]);
    $res = $this->getJson('/api/admin/dashboard/balances');
    $res->assertOk();
    $res->assertJsonStructure(['light', 'services' => [['service_key', 'balance_amount', 'currency', 'days_left', 'light', 'ok', 'checked_at']]]);
});
```

- [ ] **Step 2:** FAIL (нет роута).
- [ ] **Step 3: Метод** в `AdminDashboardController`:

```php
    /** GET /api/admin/dashboard/balances — балансы внешних сервисов (L2). */
    public function balances(): JsonResponse
    {
        $rows = DB::table('external_service_balances')->get()->map(fn ($r) => [
            'service_key' => $r->service_key,
            'balance_amount' => $r->balance_amount,
            'currency' => $r->currency,
            'daily_spend_estimate' => $r->daily_spend_estimate,
            'days_left' => $r->days_left,
            'light' => $r->ok ? $r->light : 'grey',
            'ok' => (bool) $r->ok,
            'error' => $r->error,
            'checked_at' => $r->checked_at,
        ])->values();

        $order = ['green' => 0, 'grey' => 1, 'amber' => 2, 'red' => 3];
        $light = $rows->isEmpty() ? 'grey'
            : $rows->sortByDesc(fn ($s) => $order[$s['light']] ?? 0)->first()['light'];

        return response()->json(['light' => $light, 'services' => $rows]);
    }

    /** @return array<string,mixed> */
    private function balancesTile(): array
    {
        $rows = DB::table('external_service_balances')->get();
        $order = ['green' => 0, 'grey' => 1, 'amber' => 2, 'red' => 3];
        $light = $rows->isEmpty() ? 'grey'
            : $rows->map(fn ($r) => $r->ok ? $r->light : 'grey')->sortByDesc(fn ($l) => $order[$l] ?? 0)->first();
        return [
            'light' => $light,
            'count' => $rows->count(),
            'red' => $rows->where('light', 'red')->where('ok', true)->count(),
        ];
    }
```

И добавить `'balances' => $this->balancesTile(),` в массив `summary()`.

- [ ] **Step 4: Роут** в `routes/web.php` (группа `['saas-admin','admin-db']`):

```php
    Route::get('/api/admin/dashboard/balances', 'App\Http\Controllers\Api\AdminDashboardController@balances');
```

- [ ] **Step 5:** тесты Admin-среза → PASS. Обновить `AdminDashboardSummaryTest` structure: добавить `'balances' => ['light']`.
- [ ] **Step 6: Commit** «feat(балансы): эндпоинт дашборда + плитка в summary».

---

## Task 9: Frontend — клиент

**Files:** Modify `app/resources/js/api/adminDashboard.ts`.

- [ ] **Step 1:** В `DashboardSummary` добавить `balances: { light: Light; count: number; red: number }`. Добавить тип `BalancesDetail` и клиент:

```ts
export interface BalancesDetail {
    light: Light;
    services: Array<{
        service_key: string; balance_amount: string | null; currency: string;
        daily_spend_estimate: string | null; days_left: number | null;
        light: Light | 'grey'; ok: boolean; error: string | null; checked_at: string | null;
    }>;
}
export async function getDashboardBalances(): Promise<BalancesDetail> {
    const { data } = await apiClient.get<BalancesDetail>('/api/admin/dashboard/balances');
    return data;
}
```

- [ ] **Step 2: Commit** «feat(балансы-fe): клиент балансов».

---

## Task 10: Frontend — 5-я плитка + drill

**Files:** Modify `AdminDashboardView.vue`. Test `AdminDashboardView.spec.ts`.

- [ ] **Step 1: View** — добавить `balances` ref + загрузку в `load()` (Promise.all +1), `type Area` добавить `'balances'`. Новая плитка «💳 Балансы сервисов» (светофор `summary.balances.light`, подпись «N сервисов, K на исходе»). Drill — таблица: Сервис | Баланс | Хватит на | Статус (светофор-chip); строка с `ok=false` → «не удалось обновить, данные от ДАТА». Названия сервисов: `dadata→DaData`, `supplier→Поставщик`, `yandex_cloud→Yandex Cloud`. Формат денег — функция `rub()` уже есть.

- [ ] **Step 2: Тест** — в мок summary добавить `balances`, мок `getDashboardBalances` (3 строки); проверить рендер плитки + таблицы по клику.
- [ ] **Step 3:** `npm run test:vue -- AdminDashboardView` → PASS.
- [ ] **Step 4: Commit** «feat(балансы-fe): плитка Балансы сервисов + drill».

---

## Task 11: Self-review + регрессия

- [ ] `php artisan test tests/Feature/Admin tests/Unit/Dashboard tests/Unit/External` → PASS.
- [ ] `composer stan` → 0 (перегенерировать baseline при getJson-ложняках, квирк 25).
- [ ] `composer pint -- <изменённые php>`.
- [ ] `npm run test:vue -- AdminDashboardView`, `npm run type-check`, `npx eslint <fe-файлы>` (следить за U+202F), `npm run build`.

---

## Task 12: Показ глазами + выкат

- [ ] Локально засеять `external_service_balances` демо-строками → поднять портал → открыть плитку «Балансы сервисов» → скриншот.
- [ ] Прогнать `RefreshExternalBalancesJob` локально/на dev, проверить реальные значения провайдеров (DaData/YC по сети; Supplier через Playwright).
- [ ] Выкат по «выкатывай» + prod-deploy-validator: merge feat→gitea main → `bin/deploy-source-edit.sh` (с фронт-сборкой). На проде после выката: `php artisan migrate --force` создаст таблицу; джоба в расписании; **config:cache подхватит YC_OAUTH_TOKEN/YC_BILLING_ACCOUNT_ID** (уже в .env). Прогнать джобу один раз вручную (dispatch_sync) чтобы плитка сразу наполнилась.
- [ ] Live-приёмка: плитка показывает реальные балансы (YC ≈ −540₽, DaData, Поставщик). Обновить память.

---

## Notes / Self-review

- **Spec coverage:** 3 сервиса (Tasks 4,5,6) ✓; баланс+дни+светофор по двум правилам (Task 2) ✓; ежедневная задача (Task 7) ✓; плитка+drill (Tasks 8,10) ✓; изоляция провайдеров (Task 7 try/catch + fail-DTO) ✓; «данные от ДАТА» при сбое (Task 8/10) ✓; Unisender исключён ✓; email-алертов нет ✓.
- **Зависимость-разведка (не плейсхолдер):** Task 6 Step 1 — селектор баланса в кабинете поставщика определяется осмотром (внешний UI, мы им не владеем); полный код supplier-balance.js пишется после разведки по реальному селектору. Если баланс на доступных страницах не показывается — провайдер `fail`, эскалация владельцу.
- **YC interim:** OAuth-токен владельца (в .env). Позже миграция на SA `billing-reader` (создан) одним кликом роли в консоли — отдельный follow-up; код провайдера не изменится (только источник токена).
- **Пороги** — дефолты в config (env-override). Калибровать после первых реальных значений.
- **Не трогать** чужой незакоммиченный WIP; коммитить только перечисленные пути. Память: feat-ветка содержит дашборд Этапы 1+2+фиксы.
- **Безопасность:** токены/ключи только в .env/Lockbox, не в репозиторий, не в логи (gitleaks в pre-commit).
```
