# Балансы внешних сервисов — дизайн

**Дата:** 2026-06-28
**Автор:** Claude (по запросу владельца)
**Статус:** одобрено владельцем (брейншторм 28.06)

## Цель

Раз в сутки контролировать баланс/бюджет внешних платных сервисов, чтобы они не
кончились внезапно (иначе встанут лиды / серверы / резолв региона). Состояние
показывать **плиткой в дашборде «Командный центр»** со светофором; писем-алертов
не делаем (решение владельца — только плитка).

## Состав (3 сервиса)

| Сервис | Зачем | Чтение баланса |
|---|---|---|
| **Поставщик лидов** (crm.bp-gr.ru) | кончится → лиды встанут | вход в кабинет (Playwright, уже работает) → баланс со страницы |
| **DaData** | резолв региона/ИНН лида | API `profile/balance` (ключ на проде есть) |
| **Yandex Cloud** | серверы + Managed PG (~18к₽/мес) | billing API (нужен токен сервисного аккаунта — владелец создаёт, см. §Предусловие) |

**Не входит:** Unisender Go — на проде НЕ используется (почта идёт через
`smtp.yandex.ru`, Yandex 360; отдельного оплачиваемого баланса почты нет). Email-алерты —
вне объёма (только плитка).

## Что видит владелец

5-я секция/плитка в дашборде. По каждому сервису строка:

> 📦 Поставщик · **12 400 ₽** · хватит на ~5 дней · 🟡
> 🧭 DaData · **3 100 ₽** · хватит на ~9 дней · 🟢
> ☁️ Yandex Cloud · **42 000 ₽** · хватит на ~70 дней · 🟢

Светофор по ДВУМ правилам (как просил владелец — и дни, и абсолютное число):
- 🔴 если `баланс < red_floor` **ИЛИ** `дней_осталось < 3`
- 🟡 если `баланс < amber_floor` **ИЛИ** `дней_осталось < 7`
- 🟢 иначе

Пороги (`red_floor`, `amber_floor`) — свои на каждый сервис, настраиваемые
(в `config/services.php` секциях, дефолты в спеке плана). Светофор плитки L1 =
худший из сервисов.

Если опрос сервиса упал (кабинет недоступен / API не ответил) — показываем
**последнее известное** значение + пометку «данные от ДАТА» и серый/янтарный
статус «не удалось обновить», но плитку не роняем.

## Архитектура

**Переходник на сервис (изоляция):** интерфейс `BalanceProvider` с методом
`fetch(): BalanceReading`, по одному классу на сервис:
- `DadataBalanceProvider` — HTTP GET к DaData.
- `SupplierBalanceProvider` — через `node playwright/supplier-balance.js` (новый скрипт,
  логинится сессией из Redis `supplier:session` и читает баланс; селектор баланса
  определяется при реализации осмотром страницы кабинета).
- `YandexCloudBalanceProvider` — SA-ключ → IAM-токен → `GET billing/v1/billingAccounts/{id}`.

`BalanceReading` (DTO): `service_key, balance_amount (decimal), currency, daily_spend_estimate (decimal|null), ok (bool), error (string|null), checked_at`.

**Расход/день (для «хватит на N дней»)** — оценка, считается отдельно:
- DaData: вызовы резолва за последние ~7 дней × стоимость вызова (`call_cost_kopecks`).
- Поставщик: лиды/день × средняя цена лида (из `supplier_leads` объёма и цен).
- YC: месячный расход billing API ÷ 30 (или текущий month-to-date ÷ дни месяца).

`days_left = ok && daily_spend>0 ? floor(balance / daily_spend) : null` (null → «—»,
светофор только по абсолютному порогу).

**Чистая логика порога:** `App\Services\Dashboard\BalanceHealth::evaluate(balance, daily_spend, red_floor, amber_floor): {days_left, light}` — без БД/сети, unit-тестируемо.

**Хранение:** таблица `external_service_balances` (одна строка на сервис, upsert):
`service_key (PK), balance_amount, currency, daily_spend_estimate, days_left, light, ok, error, checked_at`.
Хранит ТОЛЬКО последнее значение (история не нужна для v1).

**Ежедневная задача:** `RefreshExternalBalancesJob` в планировщике (раз в сутки, утро МСК).
Перебирает провайдеры; каждый в try/catch — упавший провайдер оставляет прошлую строку +
ставит `ok=false, error=...`; успешный — upsert свежего значения + пересчёт порога.

**Дашборд:** в `AdminDashboardController` — метод `balances()` (читает
`external_service_balances`, считает светофор плитки) + строка в `summary` (плитка L1).
Frontend: 5-я плитка «Балансы сервисов» + drill со строками по сервисам. Под группой
`['saas-admin','admin-db']` (cross-tenant не нужен, но единообразно).

## Предусловие: токен Yandex Cloud (владелец создаёт)

Для чтения баланса YC нужен read-only доступ к биллингу:
1. Сервисный аккаунт (напр. `billing-reader`).
2. Роль **`billing.accounts.viewer`** на биллинг-аккаунте.
3. Авторизованный ключ (JSON) для этого SA.
4. ID биллинг-аккаунта.

JSON-ключ кладём на прод (в `.env`-путь или YC Lockbox), `YC_BILLING_ACCOUNT_ID` — в env.
До получения токена YC-провайдер отдаёт `ok=false` («доступ не настроен»), плитка
показывает DaData+Поставщика. Пошаговая инструкция выдаётся владельцу отдельно.

## Надёжность / тесты

- Изоляция провайдеров: один упавший сервис не ломает плитку (try/catch на провайдер).
- Сеть только в провайдерах; ядро (`BalanceHealth`) чистое → unit-тесты на пороги/дни.
- Провайдеры — тесты с моками HTTP/процесса.
- Плитка — Vitest (мок API).
- Секреты (DaData token, YC key, supplier session) — не в логи, не в репозиторий.

## Порядок работ

1. Владелец создаёт токен YC (по инструкции).
2. Спец (этот файл) → план (writing-plans).
3. Реализация: таблица+миграция → `BalanceHealth` → 3 провайдера → джоба → эндпоинт → плитка.
4. Показ глазами (локально) → выкат по «выкатывай».

## Известные ограничения v1

- «Хватит на N дней» — оценка по среднему расходу, не точный прогноз.
- Селектор баланса в кабинете поставщика определяется при реализации (осмотр страницы);
  если разметка кабинета изменится — провайдер вернёт `ok=false`, плитка покажет «не удалось».
- История балансов не хранится (только последнее значение); тренды — отдельный follow-up.
