From aa1aba3d7c45dfd1a16b8058e4babe1ee39663a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 22 Jun 2026 19:26:42 +0300 Subject: [PATCH] =?UTF-8?q?docs(billing):=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BE=D0=BD=D0=BB=D0=B0=D0=B9=D0=BD-=D0=BF=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B1=D0=B0=D0=BB?= =?UTF-8?q?=D0=B0=D0=BD=D1=81=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=AEKassa=20=D0=A7=D0=B0=D1=81=D1=82=D1=8C=201+2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фундамент legal_entities/payment_gateways/saas_transactions + драйвер ЮKassa, поток create->redirect->webhook->автозачисление, рубильник-флаг billing_yookassa_enabled в админке, чек 54-ФЗ заложен и выключен. Часть 3 счёт на безнал - следующим циклом. Прод на заглушке до ООО Б-1. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/observer/STATUS.md | 14 +- ...22-billing-online-topup-yookassa-design.md | 241 ++++++++++++++++++ 2 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-22-billing-online-topup-yookassa-design.md diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 9416f983..48279fb9 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-06-22T16:20:13.048Z +Last updated: 2026-06-22T16:26:01.609Z | Контролёр | Состояние | Детали | |---|---|---| @@ -47,6 +47,9 @@ Last updated: 2026-06-22T16:20:13.048Z | Время | Действие | Причина | |---|---|---| +| 2026-06-22T16:24:44.007Z | bash:ls $TEMP/claude-economy-ae7348fc-4410-4d81-8546-4b57c2df3ad0.json 2>/dev/null && cat $TEMP/claude-economy-ae7348fc- | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:ls $TEMP/claude-econom | +| 2026-06-22T16:22:00.158Z | bash:ls $TEMP/claude-economy-ae7348fc-4410-4d81-8546-4b57c2df3ad0.json 2>/dev/null && cat $TEMP/claude-economy-ae7348fc- | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:ls $TEMP/claude-econom | +| 2026-06-22T16:20:44.486Z | bash:ls $TEMP/claude-economy-ae7348fc-4410-4d81-8546-4b57c2df3ad0.json 2>/dev/null && cat $TEMP/claude-economy-ae7348fc- | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:ls $TEMP/claude-econom | | 2026-06-22T16:19:05.841Z | bash:ls $TEMP/claude-economy-ae7348fc-4410-4d81-8546-4b57c2df3ad0.json 2>/dev/null && cat $TEMP/claude-economy-ae7348fc- | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:ls $TEMP/claude-econom | | 2026-06-22T16:17:41.809Z | bash:ls $TEMP/claude-economy-ae7348fc-4410-4d81-8546-4b57c2df3ad0.json 2>/dev/null && cat $TEMP/claude-economy-ae7348fc- | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:ls $TEMP/claude-econom | | 2026-06-22T16:17:18.508Z | bash:cat "$TEMP/claude-economy-c45f5b05-836d-48ba-bf47-ab3477fd20e5.json" 2>/dev/null \|\| echo "FILE_NOT_FOUND" | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:cat "$TEMP/claude-econ | @@ -54,9 +57,6 @@ Last updated: 2026-06-22T16:20:13.048Z | 2026-06-22T16:12:12.039Z | bash:ls $TEMP/claude-economy-ae7348fc-4410-4d81-8546-4b57c2df3ad0.json 2>/dev/null && cat $TEMP/claude-economy-ae7348fc- | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:ls $TEMP/claude-econom | | 2026-06-22T16:10:29.400Z | bash:cat "$TEMP/claude-economy-c45f5b05-836d-48ba-bf47-ab3477fd20e5.json" 2>/dev/null \|\| echo "FILE_NOT_FOUND" | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:cat "$TEMP/claude-econ | | 2026-06-22T16:07:20.953Z | bash:cat "$TEMP/claude-economy-c45f5b05-836d-48ba-bf47-ab3477fd20e5.json" 2>/dev/null \|\| echo "FILE_NOT_FOUND" | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:cat "$TEMP/claude-econ | -| 2026-06-22T15:58:15.793Z | bash:ls $TEMP/claude-economy-c45f5b05-836d-48ba-bf47-ab3477fd20e5.json 2>/dev/null && cat $TEMP/claude-economy-c45f5b05- | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:ls $TEMP/claude-econom | -| 2026-06-22T15:56:58.348Z | bash:tail -c 8000 "C:\Users\Administrator\.claude\projects\c--------------claude-brain\ae7348fc-4410-4d81-8546-4b57c2df3 | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:tail -c 8000 "C:\Users | -| 2026-06-22T15:56:46.709Z | bash:ls $TEMP/claude-economy-ae7348fc-4410-4d81-8546-4b57c2df3ad0.json 2>/dev/null && cat $TEMP/claude-economy-ae7348fc- | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:ls $TEMP/claude-econom | ## Метрики (информационные, не алерты) @@ -140,9 +140,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 6976 | Code | 2.59ч | 0.0ч | -| 3440 | MsMpEng | 2.57ч | NaNч | -| 14232 | msedge | 2.39ч | 0.0ч | +| 6976 | Code | 2.60ч | NaNч | +| 3440 | MsMpEng | 2.59ч | 0.0ч | +| 14232 | msedge | 2.44ч | 0.0ч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий. diff --git a/docs/superpowers/specs/2026-06-22-billing-online-topup-yookassa-design.md b/docs/superpowers/specs/2026-06-22-billing-online-topup-yookassa-design.md new file mode 100644 index 00000000..58800105 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-billing-online-topup-yookassa-design.md @@ -0,0 +1,241 @@ +# Дизайн: Онлайн-пополнение баланса через Ю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 (счёт на безнал) — отдельным циклом.