Тема
ТЗ — Платёжные шлюзы РФ (cms/pay-gateways-ru)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★★ · Донор: notal (
notal/src/app/Services/— YooKassa) Статус: ТЗ к разработке
Назначение и возможности
Коннекторы российских платёжных шлюзов (ЮKassa, Т-Банк, Робокасса, СБП) поверх cms/integrations-bus: реализуют контракт PaymentGateway из cms/commerce-payments, дают checkout единый выбор способа оплаты без привязки к конкретному провайдеру.
- Коннекторы ЮKassa, Т-Банк (Тинькофф-эквайринг), Робокасса, СБП (динамический QR)
provides: payment-gateway— реализация контрактаPaymentGatewayдля каждого шлюза- Создание платежа с редиректом/виджетом на стороне шлюза
- Приём колбэков через
cms/webhooks-in, обработка идемпотентна поevent_idшлюза - Возвраты (полные и частичные) через API конкретного шлюза
- Сохранённые методы оплаты (токены карт) — источник для
cms/commerce-subscriptions - Передача фискальных данных чека в запросе создания платежа (если ОФД на стороне шлюза)
- Тестовый/боевой режим на шлюз, переключаемый в настройках без правки кода
Зависимости и выключение
requires: cms/integrations-bus, cms/webhooks-in, cms/commerce-payments · suggests: cms/commerce-subscriptions, cms/ofd · provides: payment-gateway
Поведение при выключении: коды соответствующих шлюзов пропадают из списка активных на checkout в cms/commerce-payments, оформление заказа продолжает работать через оставшиеся активные способы оплаты (или офлайн-оплату) — заказы не теряются.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_pay_gateways_ru_connectors | id, code, driver, is_test_mode, is_active | реестр подключённых шлюзов, креды — ссылки на .env |
cms_pay_gateways_ru_saved_methods | id, user_id, gateway_code, token, masked_pan, expires_at | сохранённые методы оплаты для рекуррентов |
cms_pay_gateways_ru_fiscal_log | id, payment_id, gateway_code, receipt_payload (json), status | журнал передачи фискальных данных в запросе |
Индексы: FK user_id/payment_id — constrained() + index(); receipt_payload — json() + cast array; cms_pay_gateways_ru_fiscal_log — журнал, append-only, BRIN по created_at; status — PHP Enum.
ПДн-паспорт: cms_pay_gateways_ru_fiscal_log.receipt_payload может содержать ФИО, email/телефон покупателя и состав чека (обязательно при send_fiscal_data, 54-ФЗ). Срок хранения — по требованиям к фискальным документам (не короче срока хранения заказа в commerce-orders); ретеншн-джоба не удаляет запись целиком, а обезличивает нефискальные поля после истечения срока. «Выгрузить всё по субъекту» — агрегируется на стороне commerce-payments по payment_id → order_id; «забыть по запросу» (UserDeleted) обезличивает receipt_payload, кроме юридически обязательных фискальных реквизитов. cms_pay_gateways_ru_saved_methods.masked_pan — не ПДн в чистом виде (маскированный номер), но токен — чувствительный секрет: не логируется, не попадает в аналитику.
Входные и выходные данные
Входы
| Вход | Источник | Канал | Проверка |
|---|---|---|---|
| Создание платежа | POST /api/v1/checkout/payments/{gateway} (форма checkout) | API | FormRequest whitelist (order_id, amount, currency, return_url); Idempotency-Key обязателен |
| Возврат платежа | POST /api/v1/admin/pay-gateways-ru/{code}/refund | API | pay-gateways-ru.manage, Idempotency-Key, сумма ≤ остаток к возврату |
| Переключение коннектора | PUT /api/v1/admin/pay-gateways-ru/{code} | API | pay-gateways-ru.manage, схема настроек группы |
| Колбэк ЮKassa | Вебхук шлюза → cms/webhooks-in | Канал 5 (WebhookReceived) | Подпись по секрету ЮKassa (Basic/HMAC), event_id, IP-диапазон ЮKassa |
| Колбэк Т-Банк | Вебхук шлюза → cms/webhooks-in | Канал 5 (WebhookReceived) | Token-подпись: SHA-256 от отсортированных параметров + пароль терминала |
| Колбэк Робокасса | Вебхук шлюза → cms/webhooks-in | Канал 5 (WebhookReceived) | Подпись MD5/SHA (OutSum:InvId:Пароль2) |
| Колбэк СБП | Вебхук агрегатора → cms/webhooks-in | Канал 5 (WebhookReceived) | Подпись провайдера СБП, sbp_qr_ttl_seconds для актуальности QR |
Всё, что не входит в этот перечень (произвольные поля запроса, необъявленные типы колбэков, лишние HTTP-заголовки) — отклоняется на границе (422/403), whitelist- принцип §11 стандарта: перечислено — принимается, не перечислено — отвергается.
Выходы
| Выход | Формат | Получатель |
|---|---|---|
| Ответ создания платежа | {"data": {"payment_id", "redirect_url"/"qr_payload", "status"}} | Checkout (клиент) |
| Ответ шлюзу на колбэк | 200 OK (тело — по протоколу конкретного шлюза) | Шлюз |
PayGatewayConnected / PayGatewaySavedMethodAdded / PayGatewayFiscalDataRejected | канал 1 (событие) | cms/audit, подписчики ядра |
Реализация PaymentGateway (pay/capture/refund/status/callback) | канал 3 (provides: payment-gateway) | cms/commerce-payments |
Вызов PaymentsService::confirm()/refund() | канал 4 (requires) | cms/commerce-payments |
| Список подключённых шлюзов и режимов | GET /api/v1/admin/pay-gateways-ru | Админка |
Настройки (группа pay-gateways-ru)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
pay-gateways-ru.enabled_connectors | array | [] | нет | Активные коды шлюзов (yookassa, tbank, robokassa, sbp) |
pay-gateways-ru.test_mode | array | [] | нет | Коды шлюзов, работающих в тест-режиме |
pay-gateways-ru.sbp_qr_ttl_seconds | int | 900 | нет | Время жизни динамического СБП-QR |
pay-gateways-ru.send_fiscal_data | bool | true | нет | Передавать чек в запросе создания платежа |
pay-gateways-ru.saved_methods_enabled | bool | true | нет | Разрешить сохранение токенов карт |
pay-gateways-ru.max_payment_attempts | int | 5 | нет | Лимит попыток оплаты одного заказа (защита от перебора/абьюза) |
pay-gateways-ru.max_payment_amount | int (minor units) | 5000000 | нет | Максимальная сумма одного платежа на шлюз — страховка от ошибки ввода/фрода |
pay-gateways-ru.emergency_disabled | array | [] | нет | Kill-switch: коды шлюзов, аварийно отключённые без выключения модуля (быстрее, чем правка enabled_connectors) |
Секреты шлюзов (API-ключи, shop_id, секрет подписи колбэка) — только .env → config/pay-gateways-ru.php, в settings-store не хранятся.
Стоимость внешних API: комиссия шлюза — тариф провайдера по договору, в конфиге не фиксируется (плавающая). Фактический процент/сумма комиссии виден в личном кабинете шлюза; если провайдер возвращает её в колбэке — попадает в fiscal_log для сверки, но модуль не гарантирует и не пересчитывает комиссию самостоятельно.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| POST | /api/v1/checkout/payments/{gateway} | auth/guest (Idempotency-Key) | Создание платежа в конкретном шлюзе |
| POST | /api/v1/admin/pay-gateways-ru/{code}/refund | admin (pay-gateways-ru.manage, Idempotency-Key) | Полный/частичный возврат платежа |
| GET | /api/v1/payments/{id}/saved-methods | auth (владелец) | Список сохранённых методов оплаты |
| DELETE | /api/v1/payments/saved-methods/{id} | auth (владелец) | Удаление сохранённого метода |
| GET | /api/v1/admin/pay-gateways-ru | admin (pay-gateways-ru.view) | Список подключённых шлюзов и режимов |
| PUT | /api/v1/admin/pay-gateways-ru/{code} | admin (pay-gateways-ru.manage) | Включение/выключение, переключение тест/бой |
Список сохранённых методов — keyset-пагинация, не OFFSET.
Компоненты
Блоки: способ оплаты СБП-QR на checkout. Filament: ресурс коннекторов (статус, режим), журнал фискальных данных, журнал обмена по платежу. Команды: cms:pay-gateways-ru:doctor --json (проверка доступности каждого подключённого шлюза), cms:pay-gateways-ru:reconcile --json (плановая сверка реестров).
События и обмен
| Событие | Когда | Payload |
|---|---|---|
PayGatewayConnected | шлюз подключён и прошёл проверку кредов | gateway_code, is_test_mode |
PayGatewaySavedMethodAdded | пользователь сохранил метод оплаты | user_id, gateway_code, method_id |
PayGatewayFiscalDataRejected | шлюз отклонил переданные фискальные данные | payment_id, gateway_code, reason |
Слушает: WebhookReceived из cms/webhooks-in (колбэки шлюзов), маршрутизация по provider. Реализует provides: payment-gateway — потребитель cms/commerce-payments. Весь обмен со шлюзами — через cms/integrations-bus.
Взаимодействия
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/webhooks-in | 5 (событие) | webhooks-in → pay-gateways-ru | Приём и проверка подписи колбэка, издание WebhookReceived, модуль слушает и маршрутизирует по provider |
cms/commerce-payments | 3 (provides) | pay-gateways-ru → commerce-payments | Реализация PaymentGateway (pay/capture/refund/status/callback) |
cms/commerce-payments | 4 (requires) | pay-gateways-ru → commerce-payments | Вызов PaymentsService::confirm()/refund() при подтверждённом колбэке |
cms/commerce-subscriptions | 3 (suggests) | commerce-subscriptions → pay-gateways-ru | Чтение сохранённых токенов карт для рекуррентных списаний |
cms/ofd | 4 (suggests, при включении — фактический requires) | pay-gateways-ru → cms/ofd | Если ОФД вынесен отдельным модулем — чек уходит туда, а не в запрос шлюза |
cms/integrations-bus | внешний канал | pay-gateways-ru ↔ шлюз | Все HTTP-вызовы к API шлюзов — через единую шину интеграций (лог, HTTP-клиент, ретраи) |
cms/audit | 1 (событие) | pay-gateways-ru → audit | Подключение шлюза, сохранение метода оплаты, отклонение чека, аварийное отключение |
Любая связь вне этой таблицы — скрытая зависимость (анти-паттерн стандарта).
Фоновая работа
Создание платежа и обработка колбэка — синхронный HTTP-вызов шлюза (не через очередь, пользователь ждёт редирект), но с коротким таймаутом и понятной ошибкой при недоступности. Возвраты — из очереди pay-gateways-ru с ретраями (backoff) и circuit-breaker на шлюз; при открытом breaker шлюз временно исключается из активных на checkout. Плановая сверка реестров с каждым шлюзом — ежедневная джоба (cms:pay-gateways-ru:reconcile), результат — отчёт расхождений в админке, без автоисправления.
Мини-ранбук
| Симптом | Что проверить | Команда |
|---|---|---|
| Оплата не проходит у всех клиентов на одном шлюзе | Health-чек коннектора, статус circuit breaker, доступность API шлюза | cms:pay-gateways-ru:doctor --json |
| Расхождение суммы заказа и колбэка | Журнал обмена конкретного платежа, отчёт плановой сверки | cms:pay-gateways-ru:reconcile --date=<день> --json |
| Возврат «завис» (создан, не подтверждён шлюзом) | Очередь pay-gateways-ru, failed jobs, статус возврата у шлюза | php artisan queue:failed + cms:pay-gateways-ru:doctor --json |
Производительность и кеш
- Ожидаемые объёмы (оценочно): платежи — от десятков до нескольких тысяч в сутки на клиентский сайт студии; фискальный лог растёт 1:1 с платежами при
send_fiscal_data. - Горячие пути: создание платежа — синхронный вызов шлюза с таймаутом ≤2 с и graceful fallback (допустимое исключение §9 стандарта, не анти-паттерн — бюджет фиксирован явно); список активных коннекторов на checkout — из кеша настроек группы, 0 запросов к БД; обработка колбэка — быстрая запись статуса, тяжёлая реакция (фискализация, письма, бонусы) — уносится в очередь событиями
commerce-payments, не здесь. - Возвраты и плановая сверка — только из очереди
pay-gateways-ru, к API шлюза не обращаются синхронно из запроса админа (кроме исключения выше — создание платежа). - Индексы — см. «Модель данных»:
user_id/payment_id(FK + index),status(PHP Enum), BRIN поcms_pay_gateways_ru_fiscal_log.created_at. - Собственных тегов кеша нет — статусы платежей и возвратов читаются напрямую (низкий трафик операции, консистентность важнее скорости кеша); список активных коннекторов — в общем кеше настроек группы (0 запросов на горячем пути checkout).
Безопасность
- Подпись колбэков каждого шлюза верифицируется на входе через
cms/webhooks-in, запросы без валидной подписи отклоняются до бизнес-логики (алгоритмы — см. «Входные и выходные данные»: HMAC/Basic у ЮKassa, token-подпись у Т-Банка, MD5/SHA у Робокассы). - Обработка колбэка идемпотентна по
event_idшлюза — повторная доставка не создаёт дублирующий переход статуса платежа. - Защита от replay: там, где шлюз передаёт временную метку (ЮKassa, Т-Банк) — колбэк с меткой вне окна (например ±5 минут) отклоняется; СБП/Робокасса не дают timestamp — защита исключительно идемпотентностью по
event_id/InvIdи коротким окном обработки очереди возвратов. - Секреты (API-ключи, секрет подписи) — только
.env, whitelist источников колбэков по IP/домену шлюза, где это поддерживается провайдером. - Права:
pay-gateways-ru.view,pay-gateways-ru.manage.
Матрица ролей:
| Действие | Админ | Менеджер | Редактор | Studio |
|---|---|---|---|---|
Просмотр списка шлюзов и статусов (pay-gateways-ru.view) | ✅ | ✅ | — | ✅ |
Переключение шлюза, тест/бой режим (pay-gateways-ru.manage) | ✅ | — | — | ✅ |
| Ручной возврат платежа | ✅ | — | — | ✅ |
Аварийное отключение шлюза (emergency_disabled) | ✅ | — | — | ✅ |
Правка секретов шлюза (.env) | — | — | — | ✅ (только через деплой) |
UX-требования
Админ: список коннекторов показывает статус живости (последний успешный колбэк/ проверка), не только чекбокс вкл/выкл; журнал обмена доступен по конкретному платежу (запросы/колбэки, время, статус, без сырых секретов); ошибки — на человеческом языке («ЮKassa не отвечает, платежи временно недоступны», не «Error 500» или голый код шлюза); подтверждение необратимых операций — ручной возврат и переключение бой/тест режима (может сорвать реальные платежи) требуют модального подтверждения.
Покупатель: при ошибке оплаты (таймаут/отказ шлюза) корзина и данные заказа сохраняются, показывается понятное сообщение и предложение выбрать другой способ оплаты, а не пустая ошибка; редирект на шлюз — с индикатором ожидания (спиннер), не белый экран, в рамках бюджета ≤2 с; для СБП-QR — видимый остаток времени жизни QR и кнопка «обновить QR» после истечения.
Крайние случаи и типовые баги
Двойной колбэк (тот же
event_idдоставлен дважды) → идемпотентность поevent_id, второй колбэк не создаёт второй переход статуса платежа, шлюзу отвечаем200в обоих случаях.Колбэк на несуществующий/чужой
order_id(устаревший заказ, тестовые данные просочились в бой) → лог + алерт вcms/health, платёж не создаётся; шлюзу отдаём200(иначе он будет бесконечно ретраить), инцидент виден в журнале обмена админки.Расхождение суммы колбэка с суммой заказа → алерт администратору, не автоподтверждение платежа: это принципиально — сумма не единственный источник истины, расхождение может быть багом интеграции или попыткой мошенничества; платёж переходит в статус «требует проверки», а не «оплачен».
Плановая сверка реестров с шлюзом (ежедневно,
cms:pay-gateways-ru:reconcile) → расхождения видны в админке отдельным списком, без автоисправления.Возврат (полный/частичный) → идемпотентность по
Idempotency-Key: повторный запрос возврата с тем же ключом не списывает средства дважды, возвращает результат первого запроса.СБП QR протух (истёк
sbp_qr_ttl_seconds) → новый QR выдаётся по явному запросу клиента, старый недействителен и отклоняется шлюзом при попытке оплаты.Таймаут при создании платежа → ретрай с тем же
Idempotency-Keyне создаёт дублирующий платёж — сервис возвращает уже созданный платёж по ключу идемпотентности.Смена схемы ответа API шлюза (breaking change у провайдера) → парсинг ответа падает предсказуемо: платёж помечается «требует проверки», circuit breaker открывается на этот шлюз, алерт разработчику через
cms/health— не молчаливая порча данных.Rate-limit провайдера (чаще всего — на возвраты) → очередь
pay-gateways-ruне долбит API повторно, backoff между попытками, при исчерпании ретраев — failed job + алерт, не бесконечный цикл.Недоступность конкретного шлюза (или всех сразу) → checkout деградирует до оставшихся активных способов оплаты; если недоступный шлюз — единственный активный, показывается явная ошибка «оплата временно недоступна», а не пустой список способов.
⚠️ Противоречие (снято ниже): конвенция ядра «200 +
meta.degraded, не 5xx» для взаимозаменяемого провайдера (ревизия контрактов core.md, п.2) формально описана для чтения (поиск, курсы, подсказки), а платёж создаётся явным выбором конкретного шлюза пользователем — это мутация, а не auto-selected чтение. Разрешение: список доступных способов оплаты на checkout (чтение) подчиняется конвенции200 + meta.degraded— недоступный шлюз просто не попадает в список; попытка создать платёж на уже недоступном/отключённом шлюзе — предметная ошибка (409/503сcode: gateway_unavailable), а не тихий200с фиктивным успехом. Это одно и то же поведение с крипто-платежами (см.crypto-payments.md).
Донорский код
| Что взять | Путь |
|---|---|
| Интеграция ЮKassa (создание платежа, колбэки, возвраты) | notal/src/app/Services/ |
Legacy-импорт: собственные таблицы модуля (connectors, saved_methods, fiscal_log) — служебные, историю платежей не хранят; исторические платежи и их статусы принадлежат cms/commerce-payments/commerce-orders и импортируются их cms:commerce-payments:import-legacy. Токены сохранённых карт (saved_methods.token) — не переносятся импортом: токен привязан к merchant-аккаунту конкретного шлюза, при смене учётной записи провайдера токен теряет силу — клиентам предлагается пересохранить карту при первой новой оплате.
Тесты и приёмка
- [ ] Контрактный тест: каждый коннектор реализует
PaymentGatewayизcms/commerce-payments - [ ] Мок внешнего API шлюза — тест создания платежа, возврата и обработки колбэка
- [ ] Колбэк с повторным
event_idне создаёт дублирующий переход статуса (идемпотентность) - [ ] Ретраи создания платежа при таймауте шлюза не создают дублирующий платёж
- [ ] При недоступности шлюза (circuit breaker открыт) checkout деградирует до оставшихся активных способов оплаты
- [ ] СБП-QR становится недействительным после истечения
sbp_qr_ttl_seconds(тест) - [ ] Права
pay-gateways-ru.manageразграничивают просмотр и переключение тест/бой режима - [ ] Контрактный тест деградации: создание платежа на недоступном шлюзе возвращает предметную ошибку (
code: gateway_unavailable), список способов на checkout —200без выбывшего шлюза - [ ] Дубль вебхука (тот же
event_id, разное тело) обрабатывается один раз, второй ответ —200без побочных эффектов - [ ] Расхождение суммы колбэка с суммой заказа не подтверждает платёж автоматически (тест на алерт + статус «требует проверки»)
- [ ] Плановая сверка реестров находит внесённое расхождение на синтетических данных
- [ ] Контрактный набор
cms-testingзелёный, feature-тест на каждый роут; тестовая БД толькоpay_gateways_ru_test,migrate:freshзапрещён