# Дизайн: Онлайн-пополнение баланса через ЮKassa (Часть 1 + Часть 2)

**Дата:** 2026-06-22
**Статус:** утверждён заказчиком (брейншторм), готов к написанию плана
**Автор:** Claude (под стеной «роутер-наставник»)
**Кодовая фраза:** роутер-наставник

---

## 1. Цель и контекст

Сделать **реальный приём онлайн-оплаты** для пополнения рублёвого баланса тенанта
через платёжный шлюз **ЮKassa** (карта / СБП-QR / SberPay / YooMoney), с
автозачислением баланса по факту оплаты.

**Текущее состояние (что уже есть и работает на проде liderra.ru):**

- `POST /api/billing/topup` → `BillingController@topup` → `BillingTopupService` —
  **MVP-заглушка**: кредитует `tenants.balance_rub` мгновенно и пишет append-only
  строку `balance_transactions(type='topup')`. Реального платёжного шлюза нет.
- Деньги считаются корректно: `bcmath`, `lockForUpdate` на строке тенанта,
  append-only ledger с hash-chain триггером (`audit_chain_hash`),
  `audit_block_mutation` запрещает UPDATE/DELETE.
- Фронт: `BillingView.vue`, `TopupDialog`, `topup()` в `resources/js/api/billing.ts`.
- В `db/schema.sql` платёжный домен **спроектирован** (`payment_gateways`,
  `legal_entities`, `saas_invoices`, `saas_invoice_items`, `saas_transactions`,
  `saas_upd_documents`, `tariff_subscriptions`), но **реально не построен** —
  нет ни миграций, ни моделей (в `app/Models/` их нет).

**Блокер Б-1:** боевые ключи ЮKassa и фискализация (54-ФЗ чеки) требуют
зарегистрированного **ООО** (договор с кассой + ОФД). Поэтому «по-настоящему»
(боевые деньги) включить нельзя до закрытия Б-1. Эта работа доводит интеграцию
до состояния «полностью готова, отлажена на песочнице картами, включается ключами
и флагом».

---

## 2. Объём (scope)

Декомпозиция на 3 части (решение заказчика 2026-06-22):

| Часть | Что | В этой спеке |
|---|---|---|
| **1. Фундамент** | Таблицы/модели `legal_entities`, `payment_gateways`, `saas_transactions`; интерфейс драйвера + `YooKassaDriver`; админ-ввод ключей | **ДА** |
| **2. Онлайн-оплата** | Создание платежа → редирект → webhook → автозачисление; флаг вкл/выкл; чек в код | **ДА** |
| **3. Счёт на безнал** | `saas_invoices` + PDF-счёт + ручное/полуавтоматическое зачисление | **НЕТ** (следующий цикл, отдельная спека) |

### Out of scope (явно НЕ делаем сейчас)

- Безналичная оплата по счёту (Часть 3).
- Боевое подключение ключей ЮKassa и реальная фискализация (post-Б-1, шаг владельца).
- Автосписание/рекуррентные платежи (сохранённые карты).
- Возвраты (refunds) через UI — структуру под `chargeback_*` не трогаем.
- Тарифные подписки (`tariff_subscriptions`) — отдельная тема.

---

## 3. Решение заказчика по вопросам брейншторма

1. **Касса:** ЮKassa (YooKassa). СБП-QR поддерживается (способ `sbp`, редирект-сценарий).
2. **Чеки 54-ФЗ:** заложить в код (передаём состав чека в запрос ЮKassa),
   фактически включится после ООО+ОФД.
3. **Пути оплаты:** онлайн + счёт на безнал — но в этой спеке только онлайн (Часть 1+2).
4. **Рубильник:** флаг в `system_settings`, переключается в админке
   «SaaS-admin → Система → Настройки».

---

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

### 4.1. Поток онлайн-оплаты (флаг ВКЛ)

```
Клиент: «Пополнить» N₽
  → POST /api/billing/topup {amount_rub}
      → создаём saas_transactions(status=pending, gateway=yookassa)
      → YooKassaDriver->createPayment(amount, idempotenceKey, receipt?, returnUrl)
      → ЮKassa возвращает {id, confirmation_url}
      → сохраняем gateway_payment_id в saas_transactions
      → ответ фронту: { confirmation_url }
  → фронт редиректит клиента на confirmation_url
  → клиент платит (карта / СБП-QR) на стороне ЮKassa
  → клиент возвращается на return_url (BillingView с баннером «ожидаем подтверждение»)

ЮKassa (асинхронно):
  → POST /api/billing/payment-webhook  (event=payment.succeeded)
      → проверка подлинности (см. §4.5)
      → идемпотентность по webhook_dedup_keys / gateway_payment_id
      → если succeeded и ещё не зачислено:
          DB::transaction + lockForUpdate:
            BillingTopupService->topup(tenantId, amountRub, userId=null)
            saas_transactions → succeeded, привязка balance_transaction_id
      → 200 OK
```

### 4.2. Поток при флаге ВЫКЛ (текущая заглушка, прод сегодня)

`POST /api/billing/topup` ведёт себя как сейчас: мгновенно зачисляет баланс через
`BillingTopupService->topup()` и возвращает `{ transaction, balance_rub }`.
**Контракт ответа фронту:** дискриминируется по наличию поля —
`confirmation_url` (шлюз) ИЛИ `transaction`+`balance_rub` (заглушка). Фронт:
есть `confirmation_url` → редирект; иначе → как сейчас (мгновенный успех).

### 4.3. Компоненты (изоляция и ответственность)

| Компонент | Ответственность | Зависит от |
|---|---|---|
| `PaymentGatewayDriver` (interface) | контракт: `createPayment()`, `parseWebhook()`, `verifyWebhook()` | — |
| `YooKassaDriver` | реализация под API ЮKassa (HTTP, idempotence-key, разбор статуса) | HTTP-клиент, config из `payment_gateways` |
| `PaymentGatewayManager` | выбор активного драйвера по `payment_gateways.is_active` + флагу | модели |
| `BillingTopupService` (есть) | **не меняется** — зачисление баланса + ledger | Tenant, BalanceTransaction |
| `OnlineTopupService` (новый) | оркестрация: создать saas_transactions + позвать драйвер | Driver, модели |
| `PaymentWebhookController` (новый) | приём webhook, идемпотентность, вызов зачисления | Driver, OnlineTopupService, BillingTopupService |
| `BillingController@topup` (есть) | развилка флаг вкл/выкл | OnlineTopupService / BillingTopupService |

Граница: `OnlineTopupService` ничего не знает про HTTP ЮKassa (это в драйвере);
`BillingTopupService` ничего не знает про шлюз (только баланс). Webhook-контроллер
не считает деньги сам — делегирует в `BillingTopupService`.

### 4.4. Данные

Создаём миграции под **уже спроектированную** схему (`db/schema.sql`), без отхода
от неё (правка schema.sql + запись в `db/CHANGELOG_schema.md`, RLS-review). Минимум
для Части 1+2:

- **`legal_entities`** — юрлицо (название, ИНН/КПП, адрес). Для песочницы — одна
  запись-заглушка; боевые реквизиты впишутся после ООО.
- **`payment_gateways`** — `code='yookassa'`, `driver`, `legal_entity_id`,
  `config` (зашифрован `Crypt::encrypt`: `{shop_id, secret_key}`), `is_active`,
  `accepts_methods`, `min/max_amount_rub`.
- **`saas_transactions`** — платёж: `tenant_id`, `gateway_id`, `gateway_payment_id`,
  `amount_rub`, `status` (`pending|succeeded|canceled`), `balance_transaction_id`
  (FK на зачисленную строку ledger, NULL до зачисления), таймстемпы. RLS под тенанта.

> Точная форма колонок берётся из существующего `db/schema.sql`; план сверит и при
> необходимости допишет недостающие поля (например `idempotence_key`,
> `return_url`), фиксируя это в `db/CHANGELOG_schema.md`.

Модели Eloquent: `LegalEntity`, `PaymentGateway`, `SaasTransaction`.

### 4.5. Безопасность

- **Ключи** ЮKassa — только `Crypt::encrypt` в `payment_gateways.config`; никогда в
  git/логах/Sentry. Ввод через отдельную защищённую админ-форму (не общий
  экран `system_settings`).
- **Webhook подлинность:** ЮKassa не подписывает webhook HMAC-секретом, поэтому
  канонический способ — **не доверять телу webhook**, а по `object.id` из него
  сделать `GET /payments/{id}` к API ЮKassa и поверить статусу из ответа
  (server-to-server сверка).
  Дополнительно — ограничение по списку IP ЮKassa, если доступно на инфраструктуре.
- **Идемпотентность:** запись в `webhook_dedup_keys` по `gateway_payment_id`;
  зачисление только если `saas_transactions.status != succeeded`. Повторный
  webhook → 200 OK без повторного зачисления.
- **Деньги:** зачисление в одной DB-транзакции с `lockForUpdate` (как в
  `BillingTopupService`), append-only ledger неизменен.
- **RLS:** `saas_transactions` под тенант-контекстом; webhook исполняется вне
  пользовательской сессии → зачисление под системной ролью с явным tenant scope
  по `saas_transactions.tenant_id` (паттерн `crm_supplier_worker`/джоб).
- **Idempotence-key** при `createPayment` — чтобы повторный POST topup не создал
  два платежа в ЮKassa.

### 4.6. Чек 54-ФЗ (заложен, выключен)

В запросе `createPayment` формируем секцию `receipt` (email/телефон клиента, одна
позиция «Пополнение баланса», ставка НДС, признак предмета/способа расчёта).
Активируется только при подключённом ОФД (после ООО). Под флагом
`billing_receipt_enabled` (по умолчанию выкл), чтобы песочница без ОФД не падала.

### 4.7. Флаги (system_settings)

| Ключ | Тип | По умолчанию | Смысл |
|---|---|---|---|
| `billing_yookassa_enabled` | bool | `false` | рубильник: заглушка ↔ реальный шлюз |
| `billing_receipt_enabled` | bool | `false` | передавать ли чек 54-ФЗ в ЮKassa |

Оба правятся в «SaaS-admin → Система → Настройки» (`AdminSystemSettingsController`,
с аудит-логом). Сидируются в `db/schema.sql` seed с дефолтом `false`.

---

## 5. UI / фронт

- **`TopupDialog`**: добавить пресеты сумм (1000 / 3000 / 5000 ₽) + произвольная
  (мин 100, как сейчас). При ответе с `confirmation_url` — редирект; иначе —
  текущее поведение (мгновенный успех).
- **`BillingView`**: обработать возврат с `return_url` — баннер «Платёж
  обрабатывается, баланс обновится автоматически» + опрос/refresh кошелька.
- **Админ:** форма ввода ключей ЮKassa (shopId + secret, маскированная) +
  переключатель `billing_yookassa_enabled` (уже через существующий экран настроек).

---

## 6. Тестирование (TDD)

**Backend (Pest):**
- `OnlineTopupService`: создаёт `saas_transactions(pending)`, зовёт драйвер, хранит `gateway_payment_id`.
- `YooKassaDriver`: на замоканных HTTP-ответах — создание платежа, разбор `succeeded/canceled`.
- `PaymentWebhookController`: успех → зачисление; **повтор → без двойного зачисления**;
  поддельный/неизвестный платёж → отклонён; статус не `succeeded` → не зачисляет.
- Флаг вкл/выкл: `topup` ведёт себя соответственно (редирект vs мгновенно).
- Идемпотентность зачисления (двойной webhook, гонка).
- RLS: тенант видит только свои `saas_transactions`.

**Frontend (Vitest):** `TopupDialog` (пресеты, редирект), `BillingView` (возврат).

**Ручная проверка:** песочница ЮKassa тестовой картой (полный цикл
create→pay→webhook→зачисление). СБП-QR — только на боевом магазине (post-Б-1),
код пишется сейчас.

---

## 7. Риски и решения

| Риск | Решение |
|---|---|
| Сломать рабочий прод-topup | Флаг по умолчанию `false` → прод живёт на заглушке |
| Двойное зачисление при повторном webhook | `webhook_dedup_keys` + проверка `status` + транзакция |
| Webhook нельзя проверить подписью | Server-to-server сверка `GET /payments/{id}` |
| Песочница не тестирует СБП | Картой тестируем логику; СБП-код проверяется на боевом |
| Отход от schema.sql | Миграции строго по schema + запись в CHANGELOG_schema + RLS-review |
| Чек без ОФД роняет платёж | `billing_receipt_enabled=false` по умолчанию |

---

## 8. Критерии готовности (Часть 1+2)

- [ ] Миграции `legal_entities`, `payment_gateways`, `saas_transactions` + модели; schema.sql синхронен + CHANGELOG + RLS-review.
- [ ] `PaymentGatewayDriver` + `YooKassaDriver` + менеджер; unit-тесты зелёные.
- [ ] `OnlineTopupService` + развилка флага в `BillingController@topup`.
- [ ] `PaymentWebhookController` идемпотентен; зачисление через `BillingTopupService`.
- [ ] Флаги в `system_settings` + админ-форма ключей.
- [ ] Фронт: пресеты + редирект + обработка возврата.
- [ ] Pest + Vitest зелёные; ручной прогон на песочнице картой пройден.
- [ ] Прод не затронут (флаг `false`); регрессия GREEN.

---

## 9. Следующий шаг

Спека → план реализации (skill `superpowers:writing-plans`), затем TDD-реализация
под стеной «роутер-наставник». Часть 3 (счёт на безнал) — отдельным циклом.
