Тема
ТЗ — Крипто-платежи (cms/crypto-payments)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: er (
er/project/src/app/Domain/Billing/) Статус: ТЗ к разработке
Назначение и возможности
Приём криптовалютных платежей как альтернативного способа оплаты заказа: генерация адреса/инвойса, мониторинг подтверждений в блокчейне, фиксация курса на момент выставления инвойса. Реализует контракт PaymentGateway из cms/commerce-payments.
- Генерация уникального адреса/инвойса на заказ через
cms/integrations-bus provides: payment-gateway— реализация контрактаPaymentGateway- Мониторинг подтверждений в очереди (порог
confirmationsнастраивается на валюту) - Фиксация курса на момент создания инвойса с TTL (после истечения — курс пересчитывается)
- Сценарий недоплаты: заказ помечается частично оплаченным, ожидает доплаты или ручного решения
- Сценарий переплаты: излишек фиксируется как баланс/возврат по решению администратора
- Журнал транзакций блокчейна, привязанных к инвойсу
Зависимости и выключение
requires: cms/integrations-bus, cms/commerce-payments · provides: payment-gateway
Поведение при выключении: криптовалюта пропадает из списка активных способов оплаты на checkout, ранее созданные неоплаченные инвойсы помечаются истёкшими — оформление заказа продолжает работать через оставшиеся способы оплаты.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_crypto_payments_invoices | id, order_id, currency_code, address, amount_crypto, rate_fixed, rate_ttl_at, status | инвойс на заказ, курс фиксирован на TTL |
cms_crypto_payments_transactions | id, invoice_id, tx_hash, confirmations, amount_received | транзакции блокчейна по инвойсу |
Индексы: FK order_id/invoice_id — constrained() + index(); status — PHP Enum; amount_crypto/amount_received — integer minor units (сатоши/wei-подобные) с currency_code, float запрещён; cms_crypto_payments_transactions — append-only, BRIN по created_at; tx_hash — уникальный индекс (идемпотентность по транзакции).
Входные и выходные данные
Входы
| Вход | Источник | Канал | Проверка |
|---|---|---|---|
| Создание инвойса | POST /api/v1/checkout/payments/crypto (форма checkout) | API | FormRequest whitelist (order_id, currency_code); Idempotency-Key обязателен |
| Ручное решение по инвойсу | POST /api/v1/admin/crypto-payments/{id}/resolve | API | crypto-payments.manage, whitelist действий (accept_underpayment, refund_overpayment, reject) |
| Транзакция блокчейна | Вебхук провайдера/ноды → cms/webhooks-in, либо плановый опрос из очереди | Канал 5 | Подпись провайдера (где поддерживается) или подтверждение из независимого опроса ноды; идемпотентность по tx_hash |
| Курс валюты | Запрос к оракулу/бирже через cms/integrations-bus | Внешний канал (синхронный, при создании инвойса) | Таймаут ≤2 с, fallback-источник курса при недоступности основного |
Всё, что не входит в этот перечень (произвольные адреса, необъявленные валюты, вебхуки не по схеме провайдера) — отклоняется на границе, а не тихо игнорируется (whitelist- принцип §11 стандарта).
Выходы
| Выход | Формат | Получатель |
|---|---|---|
| Ответ создания инвойса | {"data": {"invoice_id", "address", "amount_crypto", "rate_fixed", "rate_ttl_at"}} | Checkout (клиент) |
| Статус инвойса | {"data": {"status", "confirmations", "confirmations_required"}} | Клиент/личный кабинет (поллинг) |
CryptoInvoiceCreated / CryptoPaymentConfirmed / CryptoPaymentUnderpaid | канал 1 (событие) | cms/audit, подписчики ядра |
Реализация PaymentGateway (pay/capture/refund/status/callback) | канал 3 (provides: payment-gateway) | cms/commerce-payments |
Вызов PaymentsService::confirm() | канал 4 (requires) | cms/commerce-payments |
Настройки (группа crypto-payments)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
crypto-payments.enabled_currencies | array | [] | нет | Активные криптовалюты (btc, usdt-trc20 и т.п.) |
crypto-payments.confirmations_required | array | {} | нет | Порог подтверждений на валюту |
crypto-payments.rate_ttl_minutes | int | 15 | нет | Время жизни зафиксированного курса инвойса |
crypto-payments.underpayment_tolerance_percent | int | 1 | нет | Допустимое отклонение недоплаты без ручной проверки |
crypto-payments.max_invoice_amount | int (minor units) | 3000000 | нет | Максимальная сумма одного инвойса в валюте расчёта |
crypto-payments.max_active_invoices_per_order | int | 3 | нет | Лимит одновременных неоплаченных инвойсов на заказ (защита от спама адресов) |
crypto-payments.aml_kill_switch | array | [] | нет | Kill-switch: валюты, приём которых аварийно приостановлен (AML-инцидент/санкционный риск) без выключения модуля |
Секреты доступа к блокчейн-провайдеру/нодам — только .env.
Стоимость внешних API: комиссия сети (gas/miner fee) в конфиге не фиксируется — динамическая, приходит в ответе провайдера/оракула на момент создания инвойса и видна администратору в журнале транзакций конкретного инвойса; модуль её не гарантирует и не компенсирует клиенту при колебаниях.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| POST | /api/v1/checkout/payments/crypto | auth/guest (Idempotency-Key) | Создание крипто-инвойса на заказ |
| GET | /api/v1/payments/crypto/{id} | auth (владелец) | Статус инвойса и число подтверждений |
| GET | /api/v1/admin/crypto-payments | admin (crypto-payments.view) | Список инвойсов с фильтрами по статусу |
| POST | /api/v1/admin/crypto-payments/{id}/resolve | admin (crypto-payments.manage) | Ручное решение по недоплате/переплате |
Список инвойсов в админке — keyset-пагинация, не OFFSET.
Компоненты
Filament: ресурс инвойсов (статус, подтверждения, курс), форма ручного решения по недоплате/переплате, журнал транзакций по инвойсу. Команды: cms:crypto-payments:poll-confirmations --json (опрос блокчейна из очереди по расписанию), cms:crypto-payments:doctor --json (доступность провайдера/ноды на валюту).
События и обмен
| Событие | Когда | Payload |
|---|---|---|
CryptoInvoiceCreated | инвойс создан, курс зафиксирован | invoice_id, order_id, currency_code, rate_fixed |
CryptoPaymentConfirmed | достигнут порог подтверждений | invoice_id, order_id, tx_hash |
CryptoPaymentUnderpaid | получена сумма меньше требуемой | invoice_id, amount_received, amount_expected |
Реализует provides: payment-gateway — потребитель cms/commerce-payments. Обмен с блокчейн-провайдером — только через cms/integrations-bus.
Взаимодействия
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/webhooks-in | 5 (событие) | webhooks-in → crypto-payments | Приём вебхука о поступлении транзакции (если провайдер поддерживает), издание WebhookReceived |
cms/commerce-payments | 3 (provides) | crypto-payments → commerce-payments | Реализация PaymentGateway |
cms/commerce-payments | 4 (requires) | crypto-payments → commerce-payments | Вызов PaymentsService::confirm() по достижении порога подтверждений |
cms/integrations-bus | внешний канал | crypto-payments ↔ блокчейн-провайдер/оракул | Опрос ноды/API курса, все внешние вызовы — через шину |
cms/audit | 1 (событие) | crypto-payments → audit | Ручное решение по недоплате/переплате, срабатывание AML kill-switch |
cms/health | 1/метрики | crypto-payments → health | Недоступность ноды/оракула, отставание очереди опроса подтверждений |
Любая связь вне этой таблицы — скрытая зависимость (анти-паттерн стандарта).
Фоновая работа
Опрос подтверждений блокчейна — именованная очередь crypto-payments, по расписанию (не по запросу пользователя); ретраи с backoff при недоступности провайдера, circuit breaker на источник данных блокчейна. Создание инвойса — синхронный вызов с таймаутом и graceful fallback (ошибка «попробуйте позже», не 500).
Мини-ранбук
| Симптом | Что проверить | Команда |
|---|---|---|
| Подтверждения не растут ни у одного инвойса | Доступность ноды/провайдера, circuit breaker, отставание очереди crypto-payments | cms:crypto-payments:doctor --json |
| Инвойс завис в «ожидании» дольше обычного | rate_ttl_at/confirmations инвойса, журнал его транзакций | cms:crypto-payments:poll-confirmations --json для конкретного invoice_id |
| Расхождение суммы (недоплата/переплата) не разрешено вовремя | Список инвойсов со статусом «требует решения» | GET /api/v1/admin/crypto-payments?filter[status]=needs_review |
Производительность и кеш
- Ожидаемые объёмы (оценочно): крипта — нишевый способ оплаты, единицы-десятки инвойсов в сутки на сайт; журнал транзакций растёт по мере доначислений (несколько записей на инвойс при недоплате/доплате).
- Горячие пути: опрос блокчейна — плановая джоба по расписанию, пачками (не по запросу пользователя), бюджет — таймаут ≤2 с на запрос к ноде/провайдеру с backoff; статус инвойса на фронте — поллинг клиента по индексированному
invoice_id, без тяжёлых джойнов. - Создание инвойса (курс + адрес) — синхронный вызов с таймаутом ≤2 с и graceful fallback («попробуйте позже») — допустимое исключение §9 стандарта, бюджет фиксирован явно, а не «как получится».
- Индексы — см. «Модель данных»:
order_id/invoice_id(FK + index),tx_hash— уникальный индекс (идемпотентность),rate_ttl_at— для выборки инвойсов с истёкшим курсом,status— для admin-списка. - Собственных тегов кеша нет — статус инвойса и число подтверждений читаются напрямую (критично для точности оплаты, не кешируются).
Безопасность
- Опрос подтверждений идемпотентен по
tx_hash— повторный опрос той же транзакции не дублирует событие и не создаёт повторный переход статуса. Дедупликация на двух уровнях:cms/webhooks-in— поevent_idвебхука (если провайдер его даёт), и сам модуль — по уникальному индексуtx_hashвcms_crypto_payments_transactions. - Переплата не завершает платёж автоматически — обязательное ручное решение администратора (
POST /resolve): излишек не зачисляется и не возвращается сам по себе, это защита от автоматического злоупотребления и ошибок курса. - Защита от replay: опрос ноды — не вебхук, replay в классическом смысле неприменим (источник истины — сама транзакция в блокчейне); для провайдеров с вебхуком о поступлении — подпись провайдера + окно по timestamp там, где он его даёт.
- Секреты доступа к блокчейн-API/нодам — только
.env. - Права:
crypto-payments.view,crypto-payments.manage.
ПДн-паспорт: модуль не хранит ФИО/email/телефон — только order_id (связь с заказом в commerce-orders) и адрес блокчейна кошелька/инвойса (публичный идентификатор сети, не персональные данные по 152-ФЗ). Ручное решение администратора по AML-риску при необходимости сверяется с данными заказа в commerce-orders — не дублируется в таблицах этого модуля.
Матрица ролей:
| Действие | Админ | Менеджер | Редактор | Studio |
|---|---|---|---|---|
Просмотр списка инвойсов (crypto-payments.view) | ✅ | ✅ | — | ✅ |
Ручное решение по недоплате/переплате (crypto-payments.manage) | ✅ | — | — | ✅ |
| AML kill-switch (аварийная остановка валюты) | ✅ | — | — | ✅ |
Правка секретов провайдера/ноды (.env) | — | — | — | ✅ (только через деплой) |
UX-требования
Админ: список инвойсов показывает статус синхронизации с блокчейном (число подтверждений из требуемых, свежесть последнего опроса), а не только «оплачен/не оплачен»; журнал транзакций инвойса — все tx_hash и их подтверждения; ошибки на человеческом языке («блокчейн-провайдер не отвечает, подтверждения временно не обновляются», не «Error 500»); подтверждение необратимых операций — ручное решение по недоплате/переплате и AML kill-switch требуют явного подтверждения с указанием причины (попадает в аудит).
Покупатель: при истечении курса до оплаты — понятное сообщение («курс устарел, создайте новый инвойс») с кнопкой пересоздания, а не зависший счёт; статус оплаты обновляется по мере подтверждений без перезагрузки страницы (поллинг); при недоступности крипто-оплаты — предложение альтернативного способа, не обрыв оформления заказа.
Крайние случаи и типовые баги
Порог подтверждений сети (
confirmations_requiredна валюту) → до достижения порога инвойс в статусе «ожидает подтверждения», после — переходит в «подтверждён» и вызываетPaymentsService::confirm(); частичные подтверждения не считаются оплатой.Недоплата в пределах допуска (
underpayment_tolerance_percent) → засчитывается как полная оплата автоматически, без участия администратора.Недоплата вне допуска → инвойс переходит в статус «требует решения», доплата разрешена (тот же адрес донасчитывает сумму) или ручное решение администратора — автозачёт запрещён.
Переплата → излишек не засчитывается автоматически ни в каком случае: обязательно ручное решение администратора (
accept/refund) — самый частый риск ошибки в донорском коде er, поэтому вынесен отдельным правилом, а не подразумевается.Курс инвойса истёк (
rate_ttl_minutes) до поступления оплаты → инвойс помечается «курс устарел», оплата по старому адресу всё ещё может быть учтена вручную (не теряется физически), но клиенту предлагается создать новый инвойс с актуальным курсом; старый — недействителен для новых платежей на checkout.AML-риск (подозрительный источник средств, санкционный адрес) → kill-switch (
aml_kill_switch) аварийно останавливает приём валюты без выключения модуля; уже поступившие подозрительные транзакции не зачисляются автоматически — только ручная проверка администратора, событие уходит вcms/audit.Недоступность блокчейн-провайдера/ноды → опрос откладывается (circuit breaker), деньги не теряются (транзакция в блокчейне не зависит от нашей доступности), статус инвойса не считается ошибкой для покупателя — только даунтайм наблюдаемости для администратора/разработчика (три аудитории ошибок, §12 стандарта): алерт в
cms/healthдля разработчика, «ожидает подтверждения» — для покупателя.Дубль вебхука о поступлении транзакции → идемпотентность по
tx_hash, второй вебхук не создаёт вторую запись и не дублирует событиеCryptoPaymentConfirmed.Смена курса провайдера (API оракула недоступно) →
200 + meta.degradedпри запросе текущего курса (курс — взаимозаменяемые данные, не привязаны к конкретному оракулу) либо fallback-источник курса (вторичный провайдер); если оба недоступны — создание нового инвойса отклоняется с понятной ошибкой, ранее созданные инвойсы с уже зафиксированным курсом не затрагиваются.⚠️ Противоречие (снято ниже): как и в
pay-gateways-ru.md, конвенция ядра «200 +meta.degraded» формально относится к чтению взаимозаменяемого провайдера, а создание инвойса — мутация с денежным эффектом. Разрешение: запрос курса (чтение) подчиняется200 + meta.degraded/fallback-источнику; попытка создать инвойс, когда ни один источник курса не отвечает — предметная ошибка (503сcode: rate_provider_unavailable), а не тихий успех с фиктивным курсом — иначе клиент оплачивает по курсу, который никто не подтверждал.
Донорский код
| Что взять | Путь |
|---|---|
| Биллинг-домен, работа со статус-машиной платежа | er/project/src/app/Domain/Billing/ |
Legacy-импорт: донор er хранит биллинг-домен с историей инвойсов/транзакций — применимо. Команда cms:crypto-payments:import-legacy --source=er --dry-run маппит старые записи инвойсов/транзакций на cms_crypto_payments_invoices/_transactions, идемпотентна по tx_hash (естественный внешний ключ транзакции повторный прогон не дублирует), отчёт расхождений — обязателен перед боевым прогоном.
Тесты и приёмка
- [ ] Мок блокчейн-API: тест создания инвойса, накопления подтверждений, финализации оплаты
- [ ] Курс инвойса не пересчитывается до истечения
rate_ttl_minutes(тест фиксации) - [ ] Недоплата в пределах
underpayment_tolerance_percentзасчитывается как полная оплата - [ ] Переплата не завершает платёж автоматически — требует решения администратора
- [ ] Опрос подтверждений идемпотентен — повторный опрос той же транзакции не дублирует событие
- [ ] При выключении модуля неоплаченные инвойсы истекают без падения checkout
- [ ] Права
crypto-payments.manageразграничивают просмотр и ручное решение по инвойсу - [ ] Контрактный тест деградации: недоступность оракула курса →
meta.degraded/fallback-источник при чтении курса, предметная ошибка (rate_provider_unavailable) при попытке создать инвойс без курса - [ ] Дубль вебхука о поступлении транзакции (тот же
tx_hash) не создаёт вторую запись и не дублирует событие - [ ] AML kill-switch аварийно останавливает приём валюты без выключения модуля (тест на конкретную валюту, остальные не затронуты)
- [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут; тестовая БД только
crypto_payments_test,migrate:freshзапрещён