feat(биллинг): оплата по счёту (Этап 1) — счёт, акт, отметка оплаты

Клиент сам выставляет PDF-счёт (TopupDialog вкладка «По счёту»), счета и
акты — в отдельной вкладке «Счета». Админ (/admin/invoices) отмечает оплату
одной кнопкой → атомарно зачисляет баланс (BillingTopupService), формирует
Акт (без НДС, saas_upd_documents ДОП) и шлёт клиенту письмо «Счёт оплачен»
с вложением PDF-акта. PDF открываются inline в браузере (ASCII-имя).

- Сервисы InvoiceNumberGenerator/InvoiceService/ActService/InvoicePaymentService/PdfRenderer
- Контроллеры InvoiceController (клиент) + AdminInvoiceController (список+mark-paid)
- Модели SaasInvoice/SaasInvoiceItem/SaasUpdDocument; шаблоны pdf/invoice|act
- Нумерация СЧ-ГГГГ-NNNNN (advisory-lock); просрочка invoices:expire (cron)
- Наименование услуги: «Оплата генерации рекламных лидов»
- Зависимость barryvdh/laravel-dompdf (default_font dejavu sans); схема БД не менялась
- Этап 2 (автомат через ВТБ API) — отдельно, спека/план в docs/superpowers

Тесты: счета 13, Billing 138, фронт зелёные; larastan baseline +6 (Pest false-pos).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-29 11:25:16 +03:00
parent 67095f564d
commit a7b123a7c1
42 changed files with 4146 additions and 205 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,164 @@
# Оплата по счёту (банковский перевод) — дизайн
**Дата:** 2026-06-29
**Статус:** утверждён владельцем (устно: «ок делай»), Этап 1
**Автор сессии:** Claude (brainstorming)
---
## 0. Контекст и решение по каналу
Владелец хочет добавить **оплату по счёту** для клиентов-юрлиц/ИП в дополнение к онлайн-оплате картой (ЮKassa, уже работает).
**Исследование зафиксировало (важно, не переоткрывать):**
- **ЮKassa для этого сценария не подходит.** Её «оплата по счёту» (B2B) работает только через **Сбербанк Бизнес Онлайн** — обе стороны должны быть в Сбербанке. Расчётный счёт ИП открыт в **ВТБ** → путь закрыт. Обычная платёжка ВТБ↔другой банк идёт мимо ЮKassa.
- **Идея «деньги паркуются на счёте ЮKassa, потом раздаём» — нереализуема** для банковского перевода от юрлица. Деньги всегда падают на наш расчётный счёт (ВТБ).
- **Автомат возможен только со стороны ВТБ** через «Интеграционный Банк-Клиент» (ВТБ API, REST h2h). Но: **только poll, без вебхуков**; частота обновления выписки публично не задокументирована (риск «раз в сутки»); технически сложно (КриптоПро, сертификат УНЭП). → **Это Этап 2**, после подтверждения у банка.
**Решение владельца:** идём **поэтапно**.
- **Этап 1 (эта спека):** выставление счёта (самообслуживание клиентом) + закрывающий документ + ручная отметка оплаты администратором → автозачисление баланса.
- **Этап 2 (отдельная спека позже):** заменить ручную отметку на автоматический опрос ВТБ API. Перед началом — список вопросов менеджеру ВТБ (частота выписки, лимит опроса, условия подключения ИБК, СБП для бизнеса).
---
## 1. Цель Этапа 1
Клиент-юрлицо самостоятельно формирует счёт на пополнение баланса, оплачивает его банковским переводом по нашим реквизитам ВТБ. Администратор, увидев поступление, одной кнопкой подтверждает оплату — система автоматически зачисляет баланс, формирует закрывающий документ (Акт, без НДС) и уведомляет клиента.
**Критерии успеха:**
1. Клиент может ввести/подтянуть реквизиты компании, сумму и скачать PDF-счёт.
2. Счёт виден в «Моих счетах» со статусом.
3. Администратор видит выставленные счета и отмечает оплату одной кнопкой (с переспросом).
4. Отметка оплаты атомарно: зачисляет баланс (тот же ledger, что онлайн), формирует Акт PDF, шлёт письмо, ставит статус «оплачен». Повтор — no-op.
5. Клиент скачивает счёт и акт из кабинета.
6. Все суммы и документы — «Без НДС» (УСН).
7. **Схема БД не меняется** — переиспользуем существующие таблицы.
**Вне scope Этапа 1 (YAGNI):** автоматический опрос ВТБ; загрузка банковской выписки и авто-матч; УПД со счётом-фактурой (НДС); частичные оплаты; возвраты по счетам.
---
## 2. Что уже есть в коде (переиспользуем, НЕ строим заново)
| Готово | Где | Как используем |
|---|---|---|
| Таблица счетов `saas_invoices` (номер, плательщик ЮЛ/физлицо, ИНН/КПП/адрес, суммы, НДС, статусы `draft/issued/paid/overdue/cancelled`, `expires_at`, `pdf_path`, `transaction_id`) | `db/schema.sql:2371` | хранение счёта |
| Позиции счёта `saas_invoice_items` | `db/schema.sql:2410` | 1 позиция «Пополнение баланса» |
| Таблица закрывающих документов `saas_upd_documents` (покупатель, суммы, `pdf_path`, `status`, `invoice_id`, `transaction_id`, `upd_function` СЧФ/ДОП) | `db/schema.sql:2428` | хранение Акта (function=ДОП, без счёта-фактуры) |
| Транзакции `saas_transactions` (`type=topup`, `invoice_id`, `upd_id`, `payment_method='bank_transfer'`, `legal_entity_id`, статусы) | `db/schema.sql:2492` | строка пополнения |
| RLS-политики на все 4 таблицы (tenant isolation) | `db/schema.sql:3137+` | изоляция тенантов |
| Зачисление баланса `BillingTopupService::topup()` (lockForUpdate + append-only ledger) | `app/Services/Billing/BillingTopupService.php` | автозачисление при отметке оплаты |
| Идемпотентный атомарный claim pending→success + RLS-контекст (`SET LOCAL app.current_tenant_id`) | `app/Http/Controllers/Api/PaymentWebhookController.php` | образец для отметки оплаты |
| Письмо «Счёт оплачен» `InvoicePaidNotification` (шаблон `emails.invoice_paid`) | `app/Mail/InvoicePaidNotification.php` | уведомление клиента |
| Список счетов клиента `GET /api/billing/invoices` | `app/Http/Controllers/Api/BillingController.php:301` | «Мои счета» (расширить) |
| Реквизиты клиента `tenant_requisites` (1:1, ИНН/КПП/ОГРН/адрес/банк) + `RequisitesService::upsert()` | `app/Services/Requisites/RequisitesService.php` | плательщик в счёте |
| Подтяжка по ИНН (DaData) `PartyLookup` / `DaDataPartyClient` | `app/Services/DaData/` | автозаполнение реквизитов |
| Наши юрлица оператора `legal_entities` (ИНН, КПП NULL для ИП, `bank_account`, `bank_bik`, `is_default`) | `db/schema.sql:280` | получатель (наш ИП, ВТБ) |
| Способ онлайн-пополнения (диалог) | `app/resources/js/components/billing/TopupDialog.vue` | добавить вкладку «По счёту» |
---
## 3. Что строим нового
### 3.1. Зависимость: генерация PDF
В проекте **нет PDF-библиотеки**. Добавляем **`barryvdh/laravel-dompdf`** (HTML/Blade → PDF, без бинарников, кириллица через шрифт DejaVu Sans). Альтернативы (mPDF, wkhtmltopdf/snappy) тяжелее или требуют системный бинарь — отвергнуты для простоты на Windows-dev + Linux-prod.
### 3.2. Сервисы (backend)
- **`InvoiceService`** — создание счёта:
- нумерация `СЧ-2026-NNNNN` — последовательная по `legal_entity_id` + год, без дыр, через `UNIQUE (legal_entity_id, invoice_number)` + атомарный инкремент (advisory-lock или `SELECT ... FOR UPDATE` по счётчику);
- заполняет `saas_invoices` (payer из `tenant_requisites`, `legal_entity_id` = `is_default` ИП, `amount_net=amount_total`, `vat_rate=0`, `vat_amount=0`, `payment_purpose` с номером счёта, `expires_at` = +5 рабочих дней) + 1 строку `saas_invoice_items`;
- генерирует PDF счёта → `pdf_path`.
- **`ActService`** (закрывающий документ) — при отметке оплаты:
- создаёт `saas_upd_documents` (`upd_function='ДОП'`, без НДС, buyer из счёта, `invoice_id`, `transaction_id`), генерирует PDF Акта → `pdf_path`.
- **`InvoicePaymentService`** — отметка оплаты (по образцу `PaymentWebhookController`):
- в транзакции с `SET LOCAL app.current_tenant_id`: атомарный claim `saas_invoices.status issued→paid`; если 0 строк — no-op (идемпотентность);
- создаёт `saas_transactions(type=topup, status=success, invoice_id, payment_method='bank_transfer', legal_entity_id)`;
- зачисляет через `BillingTopupService::topup()`; пишет `balance_transaction_id`, `upd_id`;
- вызывает `ActService`; шлёт `InvoicePaidNotification`.
### 3.3. Контроллеры / API
**Клиент (auth + tenant):**
- `POST /api/billing/invoices` — создать счёт `{ amount_rub }` (реквизиты берутся из `tenant_requisites`; если не заполнены — 422 с подсказкой заполнить).
- `GET /api/billing/invoices` — список (расширить существующий: статус, ссылки на PDF счёта и акта).
- `GET /api/billing/invoices/{id}/pdf` — скачать счёт.
- `GET /api/billing/invoices/{id}/act` — скачать акт (если оплачен).
- Реквизиты компании — переиспользовать существующий endpoint G1/SP2 (`RequisitesService`); ИНН-автоподтяжка — существующий DaData endpoint.
**Админ (admin-зона):**
- `GET /api/admin/invoices` — список выставленных/всех счетов (фильтр по статусу, поиск по номеру/клиенту), серверная пагинация (как недавний экран «Тенанты»).
- `POST /api/admin/invoices/{id}/mark-paid` — отметить оплаченным (идемпотентно, через `InvoicePaymentService`).
### 3.4. Экраны (Vue 3 + Vuetify 3, палитра Forest)
- **Клиент:** в `TopupDialog.vue` — вкладка/способ **«По счёту (для юрлиц)»**: форма реквизитов (ИНН→автоподтяжка) при первом разе, сумма, кнопка «Сформировать счёт» → скачивание PDF + тост.
- **Клиент:** раздел **«Мои счета»** (расширить существующий список в BillingView): номер, сумма, статус (Выставлен / Оплачен / Просрочен), кнопки «Скачать счёт» / «Скачать акт».
- **Админ:** экран **«Счета»**: таблица выставленных счетов, серверная пагинация/поиск, кнопка **«Отметить оплаченным»** с диалогом подтверждения (`v-dialog`, как в manual-queue), показ суммы/клиента/номера в подтверждении.
### 3.5. PDF-шаблоны (Blade)
- `resources/views/pdf/invoice.blade.php` — счёт: шапка (наш ИП + ВТБ-реквизиты как получатель), плательщик (реквизиты клиента), таблица позиций, итог, «Без НДС», назначение платежа с номером счёта, срок оплаты.
- `resources/views/pdf/act.blade.php` — Акт об оказании услуг: исполнитель (наш ИП), заказчик (клиент), услуга, сумма, «Без НДС», ссылка на номер счёта.
---
## 4. Поток данных (happy path)
```
Клиент: TopupDialog «По счёту» → (реквизиты, если нужно) → сумма → POST /api/billing/invoices
→ InvoiceService: saas_invoices(issued) + items + PDF → возврат ссылки на PDF
Клиент скачивает счёт, платит платёжкой (в назначении — номер счёта)
... деньги идут на наш счёт ВТБ (~2 часа) ...
Админ: экран «Счета» → «Отметить оплаченным» → подтверждение → POST /api/admin/invoices/{id}/mark-paid
→ InvoicePaymentService (транзакция + SET LOCAL tenant):
claim issued→paid (атомарно; 0 строк = no-op)
saas_transactions(topup, success, bank_transfer)
BillingTopupService::topup() → баланс += сумма (ledger)
ActService → saas_upd_documents(ДОП) + PDF
InvoicePaidNotification (email)
Клиент: видит «Оплачен», скачивает счёт и акт; баланс пополнен
```
## 5. Обработка ошибок и граничные случаи
- **Реквизиты не заполнены** при создании счёта → 422 с понятным сообщением «Заполните реквизиты компании».
- **Сумма** — min/max как у онлайн-пополнения (валидация на бэке).
- **Нумерация** — атомарная, без дыр и гонок (advisory lock per legal_entity); `UNIQUE` ловит дубль.
- **Просроченный счёт** — `expires_at` прошёл и не оплачен → статус `overdue` (cron-задача раз в день или ленивый пересчёт при чтении). Просроченный нельзя «оплатить» без админ-переопределения (на Этапе 1 — просто предупреждение в подтверждении).
- **Идемпотентность отметки** — повторное `mark-paid` → claim 0 строк → no-op, без двойного зачисления/двойного акта.
- **RLS** — admin-операция отмечает счёт чужого тенанта: чтение через admin-соединение; зачисление строго под `SET LOCAL app.current_tenant_id` нужного тенанта (как webhook).
- **Деньги** — `BillingTopupService::topup()` уже атомарен (lockForUpdate); не дублируем.
## 6. Тестирование (TDD)
- **Unit:** `InvoiceService` (нумерация без дыр, поля счёта, vat=0); `ActService` (ДОП без НДС); генерация PDF не падает (smoke).
- **Feature (backend):** создание счёта клиентом (с/без реквизитов → 422); список; скачивание PDF; `mark-paid` happy-path (баланс += сумма, статус paid, акт создан, письмо отправлено — `Mail::fake`); идемпотентность повторного `mark-paid`; RLS-изоляция (чужой тенант не видит счёт).
- **Frontend (vitest):** вкладка «По счёту» в TopupDialog; «Мои счета» рендерит статусы и кнопки; админ-экран «Счета» зовёт `mark-paid` после подтверждения в диалоге.
- Прод-условие RLS воспроизводить осторожно (тест-БД под суперюзером скрывает RLS-баги — см. [[feedback-prod-full-test-isolated-db]]).
## 7. Предусловия к запуску (данные, не код)
- Заполнить в `legal_entities` строку нашего ИП: ИНН, банк (ВТБ), `bank_account`, `bank_bik`, адрес, `is_default=true`. Без этого шапка счёта пустая. Разовая настройка — собрать у владельца.
## 8. Демо перед выкатом (требование владельца)
Перед любым выкатом на боевой — **локальное демо**: владелец сам формирует счёт, скачивает PDF, отмечает оплату, видит пополнение баланса и акт. Только после «ок» на демо — деплой. (Боевая БД/деплой — отдельно, по эскейпу.)
## 9. Этап 2 (отдельно, не сейчас)
Автоматический опрос ВТБ API («Интеграционный Банк-Клиент»), матч поступления по сумме + назначению (номер счёта) → тот же `InvoicePaymentService`, только триггер не кнопка, а планировщик. Предшествует — вопросы менеджеру ВТБ:
1. Как часто формируется/обновляется выписка, доступная по API? Есть ли SLA?
2. Допустимая частота опроса API (раз в минуту — ок)?
3. Есть ли push/вебхук о входящем платеже (или только опрос)?
4. Условия и стоимость подключения «Интеграционный Банк-Клиент» для ИП; что с сертификатом (КриптоПро/УНЭП)?
5. СБП для бизнеса (B2B): мгновенное зачисление + есть ли API-уведомление?
6. Точные REST-методы получения выписки (из `Specifications_VTB_API`).
Полезные ссылки ВТБ:
- `https://db.vtb.ru/faq/files/vtb-api-kak-rabotaet-servis/Specifications_VTB_API_18.11.24.pdf`
- `https://db.vtb.ru/faq/files/vtb-api-kak-rabotaet-servis/Instruction_VTB_API_21.04.24.pdf`