Тема
ТЗ — Платежи (cms/commerce-payments)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★★ · Донор: notal (
notal/src/app/Services/— YooKassa+эскроу), er (er/project/src/app/Domain/Billing/— Cashier), freelance (freelance/project/src/app/Domains/{Payments,Escrow,Arbitration}/) Статус: ТЗ к разработке
Назначение и возможности
Абстракция платежей поверх заказа: единая модель Payment со статус-машиной, контракт PaymentGateway, выбор конкретного шлюза на checkout. Конкретные коннекторы (ЮKassa, Тинькофф, СБП, крипта) — отдельные модули-реализации, см. /cms-v2/integrations.
- Модель
Payment: статус-машинаcreated → pending → succeeded/failed → refunded - Контракт
PaymentGateway(pay/capture/refund/status/callback) — единая точка входа для коннекторов - Выбор шлюза на checkout из списка активных коннекторов (по настройке/доступности для суммы/валюты)
- Приём вебхуков от шлюзов через
cms/webhooks-in, обработка идемпотентна поevent_id - Возвраты: полный и частичный, с журналом операций
- Сверка (reconciliation): отчёт «наши платежи vs выписка шлюза» с расхождениями
Idempotency-Keyна создание платежа — повторный запрос не создаёт дубль- Событие
PaymentSucceeded— единая точка запуска фулфилмента (цифровые товары, чеки, начисления)
Зависимости и выключение
requires: ядро, cms/commerce-model (заказы) · suggests: cms/webhooks-in, cms/commerce-receipts, cms/commerce-digital · provides: payment-gateway (контракт реализуют коннекторы cms/pay-gateways-ru, cms/crypto-payments)
Поведение при выключении: оформление заказа деградирует до «заказ без онлайн-оплаты» (оплата по счёту/наличными при получении, если включён cms/commerce-b2b или доставка с наложенным платежом) — модули, зависящие от PaymentSucceeded (чеки, цифровые товары, эскроу), перестают получать события и требуют ручного подтверждения оплаты.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
commerce_payments | id, order_id, gateway_code, status, amount, currency_code, idempotency_key, external_id | статус-машина, amount — integer minor units + currency_code, не float |
commerce_payment_transitions | payment_id, from_status, to_status, reason, created_at | append-only журнал переходов, BRIN-индекс по created_at |
commerce_payment_refunds | id, payment_id, amount, type (full|partial), status, reason | возвраты, частичные и полные; type/status — PHP Enum |
commerce_payment_reconciliation | id, gateway_code, period_from, period_to, report (json), discrepancies_count | отчёты сверки, report — JSONB + GIN |
order_id — FK constrained()->index(); status/gateway_code — PHP Enum + индекс под фильтры списка. Исправление ошибочного платежа — новая запись перехода/возврата, не UPDATE/DELETE истории.
Входные и выходные данные
Входы (whitelist — всё не перечисленное отвергается FormRequest/валидатором):
| Источник | Канал | Поля | Валидация |
|---|---|---|---|
| Форма checkout | API POST /api/v1/checkout/payments | order_id, gateway_code | order_id — существующий неоплаченный заказ владельца сессии/пользователя; gateway_code — whitelist enabled_gateways; amount не принимается от клиента — берётся сервером из order.total_amount/currency_code |
| Возврат покупателя с шлюза | client-side redirect (не источник статуса) | payment_id (из query шлюза) | используется только как триггер GET /api/v1/payments/{id} — статус не выставляется по данным редиректа |
| Колбэк шлюза | вебхук через cms/webhooks-in | external_id/transaction_id, status, signature, timestamp/nonce | подпись обязательна (см. Безопасность); event_id дедуп на уровне webhooks-in, external_id дедуп на уровне статус-машины платежа |
| Возврат (админ) | Filament / API POST /admin/payments/{id}/refund | amount, type (full|partial), reason | amount ≤ (сумма платежа − сумма уже сделанных возвратов); reason обязателен, попадает в commerce_payment_refunds |
| Импорт истории | команда cms:commerce-payments:import-legacy | файл/профиль источника → external_id, status, amount, gateway_code, created_at | идемпотентно по external_id, поддерживает --dry-run (см. Донорский код) |
Выходы:
| Получатель | Канал | Формат |
|---|---|---|
| Checkout (фронт) | ответ API POST /checkout/payments | {data: {payment_id, status, redirect_url}} |
| Владелец заказа | ответ API GET /payments/{id} | {data: {id, status, gateway_code, amount, currency_code, created_at}} |
| Подписчики (фулфилмент) | события PaymentCreated/Succeeded/Failed/Refunded | payload — см. «События и обмен» |
| Админ | Filament-список, GET /admin/payments, GET /admin/payments/reconciliation | конверт {data, meta}, keyset-пагинация |
| Оператор сверки/бухгалтер | экспорт отчёта сверки | JSON (commerce_payment_reconciliation.report), скачиваемый отчёт расхождений |
Настройки (группа commerce-payments)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
commerce-payments.enabled_gateways | array | [] | нет | Список активных кодов шлюзов на checkout |
commerce-payments.default_gateway | string | null | нет | Шлюз по умолчанию, если один активен |
commerce-payments.refund_requires_approval | bool | true | нет | Возврат требует подтверждения администратором |
commerce-payments.idempotency_ttl_hours | int | 24 | нет | Срок жизни Idempotency-Key для повторных запросов |
commerce-payments.reconciliation_auto_days | int | 1 | нет | Периодичность автосверки в днях |
commerce-payments.max_payment_amount | int (minor units) | 10000000 | нет | Максимальная сумма разового платежа (лимит) — заградительный порог от ошибок ввода/фрода |
commerce-payments.max_attempts_per_order | int | 5 | нет | Лимит попыток оплаты на один заказ — защита от перебора карт |
commerce-payments.pending_ttl_minutes | int | 30 | нет | TTL платежа в pending до принудительной проверки статуса poll-job'ом |
commerce-payments.overpayment_policy | enum (refund|balance) | refund | нет | Что делать с излишком при переплате: вернуть или зачислить на баланс |
commerce-payments.accept_payments_enabled | bool | true | нет | Kill-switch: аварийная остановка приёма новых платежей (подозрение на фрод/сбой шлюза) без выключения модуля целиком — история, возвраты, сверка продолжают работать |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| POST | /api/v1/checkout/payments | auth/guest (Idempotency-Key обязателен) | Создание платежа по заказу, выбор шлюза |
| GET | /api/v1/payments/{id} | auth (владелец заказа) | Статус платежа |
| POST | /api/v1/admin/payments/{id}/refund | admin (commerce-payments.manage) | Возврат (полный/частичный) |
| GET | /api/v1/admin/payments | admin (commerce-payments.view) | Список платежей с фильтрами по статусу/шлюзу (keyset-пагинация) |
| GET | /api/v1/admin/payments/reconciliation | admin (commerce-payments.view) | Отчёт сверки за период |
Компоненты
Блоки (BlockRegistry): выбор способа оплаты на checkout. Filament: список платежей с переходами статуса, форма возврата, отчёт сверки. Команды: cms:commerce-payments:reconcile --json (сверка с шлюзом за период), cms:commerce-payments:retry-webhooks --json, cms:commerce-payments:import-legacy --source=<профиль> --json (см. Донорский код). Демо-сидер: набор платежей во всех статусах (created/pending/succeeded/failed/refunded) и пример частичного возврата — для галереи блока выбора оплаты и playground-профиля без ручного оформления заказов.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
PaymentCreated | платёж создан, ожидает оплаты | payment_id, order_id, gateway_code |
PaymentSucceeded | платёж подтверждён шлюзом | payment_id, order_id, amount |
PaymentFailed | платёж отклонён/просрочен | payment_id, order_id, reason |
PaymentRefunded | возврат выполнен (полный/частичный) | payment_id, refund_id, amount, type |
Слушает: WebhookReceived из cms/webhooks-in для колбэков шлюзов. Реализует provides-контракт-потребление: конкретный шлюз резолвится DI по коду из enabled_gateways, интерфейс PaymentGateway — в cms/core-contracts.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/webhooks-in | 1 (событие WebhookReceived) | входящее | триггер обработки колбэка шлюза (валидация подписи → переход статуса) |
Коннектор шлюза (cms/pay-gateways-ru и т.п.) | 3 (payment-gateway-контракт) | исходящее | pay/capture/refund/status/callback — реализация резолвится DI по gateway_code |
cms/commerce-model (заказы) | 4 (requires) | входящее | сумма/валюта заказа читаются сервис-вызовом при создании платежа (клиент сумму не передаёт) |
Подписчики PaymentSucceeded (orders, receipts, digital, loyalty, notifications, CRM) | 1 (событие) | исходящее | фулфилмент — см. сквозной пример «заказ оплачен» |
cms/audit | 1 (событие) | исходящее | запись финансовых операций (создание/возврат/сверка) в аудит |
cms/attack-monitor | 1 (событие, при включении) | исходящее | аномалии (серия отказов, подозрительные возвраты) |
Фоновая работа
Очередь commerce-payments: обработка колбэков шлюзов (валидация подписи → переход статуса), команда сверки reconcile по расписанию (reconciliation_auto_days), ретраи недоставленных вебхуков, poll-job зависших pending-платежей (опрос статуса у шлюза по истечении pending_ttl_minutes, перевод в failed/отмена с освобождением резерва заказа при отсутствии ответа). Все внешние вызовы к шлюзу — только из очереди, с backoff и ограничением числа попыток; синхронный путь допускает лишь создание платежа (redirect-URL, таймаут ≤2 с).
Эксплуатация (ранбук):
- метрики: платежей/час по статусам, доля
failed, число платежей зависших вpendingдольше TTL, время обработки колбэка, число расхождений по последней сверке; - алерты: доля
failedвыше порога, очередьcommerce-paymentsотстаёт,reconcileтретий раз подряд с расхождениями, poll-job не получает ответа от шлюза (health-чек коннектора); - типовые инциденты:
- Шлюз не шлёт колбэки → проверить доставку в
cms/webhooks-in(лог входящих) →cms:commerce-payments:retry-webhooks --json; - Платежи массово зависают в
pending→ проверить health коннектора шлюза → ручной запуск poll/reconcileза период; при системном сбое шлюза — kill-switchaccept_payments_enabled=false; - Сверка показывает массовые расхождения → не автоисправлять — выгрузить
GET /admin/payments/reconciliation, свести вручную с бухгалтерией/шлюзом; - Двойной фулфилмент на один платёж → проверить
commerce_payment_transitionsна дубль перехода — баг идемпотентности, требует хотфикса, не рутинная операция; - Возврат завис без подтверждения → статус у шлюза через
status-метод контракта, ручная фиксация вcommerce_payment_refundsтолько при подтверждённом факте.
- Шлюз не шлёт колбэки → проверить доставку в
- бэкап/рестор: таблицы
commerce_payments*— часть регулярного бэкапа БД (append-only журналы восстанавливаются штатно); денормализованных агрегатов модуль не хранит — доступный остаток возврата всегдаΣпо журналу, пересчёт после рестора не требуется.
Производительность и кеш
Ожидаемый объём — тысячи платежей в день на крупном инсталле (пиковые часы распродаж — десятки в минуту). Горячие пути: создание платежа на checkout (синхронная часть — только редирект-URL, ≤2 с), обработка колбэка в очереди, список платежей в админке (keyset).
Индексы: commerce_payments — order_id (constrained()->index()), status (под фильтры списка), external_id (уникальный — дедуп колбэков и поиск при сверке), составной gateway_code+status под витрину активных. commerce_payment_transitions — BRIN по created_at. Бюджет: обработка колбэка ≤5 запросов; список платежей в админке — keyset, ≤15 запросов, без COUNT(*) по всей таблице.
Кеш: теги commerce-payments:payment:<id>, commerce-payments:gateways (список активных шлюзов для checkout). Инвалидация — событиями PaymentCreated/Succeeded/Failed/Refunded и SettingChanged для enabled_gateways. Платёж — персональные/финансовые данные: статус, сумма, реквизиты не попадают в общий page-cache и не кешируются на CDN; GET /payments/{id} — приватный ответ по токену владельца, без публичного кеша. Список активных шлюзов (commerce-payments:gateways) — единственный кешируемый публично артефакт модуля (не содержит ПДн); при недоступности шлюза чтение списка деградирует (200 + meta.degraded, шлюз скрыт из выбора), а не отдаёт 5xx.
Безопасность
Приём вебхуков — только через cms/webhooks-in с проверкой подписи шлюза; обработка колбэка идемпотентна по event_id и по external_id платежа (двойная защита — дубль вебхука с новым event_id, но старым external_id, тоже не меняет статус дважды, см. Крайние случаи). Защита от replay: колбэк с timestamp/nonce вне допустимого окна рассинхрона отклоняется до обработки статус-машины. Idempotency-Key обязателен на создании платежа и возврате. Секреты шлюзов (ключи API, секрет подписи) — только .env → config('commerce-payments'), никогда в БД/настройках. Возврат — rate-limit + подтверждение администратором (refund_requires_approval).
IDOR: GET /payments/{id} и операции с платежом — доступ только владельцу заказа (order.user_id/order.session_id == текущий контекст) либо admin-праву; публичный id платежа не даёт доступа к чужому платежу перебором (публичные идентификаторы — не автоинкремент, §4 стандарта).
Подмена суммы: сумма платежа никогда не принимается от клиента — вычисляется сервером из order.total_amount/currency_code при создании (см. «Входные и выходные данные»); поле amount вне whitelist FormRequest создания платежа — 422 при попытке его передать.
ПДн-паспорт: модуль хранит платёжные реквизиты плательщика в объёме, который возвращает шлюз в колбэке (обычно маскированный номер карты **** 1234, email/телефон плательщика при СБП) — полные реквизиты карты не хранятся (это зона PCI DSS шлюза, не CMS). Срок хранения — как у заказа (бухгалтерский период, по умолчанию 5 лет, конфиг студии); участие в «выгрузить всё по субъекту» — да; в «забыть по запросу» — обезличивание реквизитов плательщика при сохранении факта платежа (полное удаление финансовых записей запрещено требованиями к хранению бухгалтерских документов).
Матрица ролей:
| Роль | .view (список/статус) | .manage (список admin) | .refund (возврат) |
|---|---|---|---|
| Покупатель (владелец заказа) | свой платёж (GET /payments/{id}) | — | — |
| Менеджер | ✅ | ✅ (без возврата) | — |
| Администратор | ✅ | ✅ | ✅ (с подтверждением) |
| Studio | ✅ | ✅ | ✅ |
Разграничение прав: commerce-payments.view, commerce-payments.manage, commerce-payments.refund.
UX-требования
Покупатель:
- checkout без регистрации — оплата гостем (
order_idпривязан к сессии/email, не требует аккаунта); - отказ платежа не обнуляет форму — данные доставки/контактов заказа сохранены, повторная попытка оплаты не заставляет вводить их заново: заказ остаётся «ожидает оплаты», меняется только платёж;
- понятные причины отказа оплаты — человекочитаемый текст по коду ошибки шлюза: «недостаточно средств на карте», «карта заблокирована банком», «оплата отклонена банком-эмитентом», «технический сбой платёжного шлюза, попробуйте другой способ» — никогда «Error 500» или необработанный код ошибки шлюза;
- пока платёж обрабатывается (после редиректа, до колбэка) — страница ожидания с опросом статуса (poll), не бесконечный спиннер без обратной связи;
- переплата/недоплата (см. Крайние случаи) — покупателю виден статус «ожидает доплаты» либо «излишек будет возвращён», не молчаливое зависание заказа.
Админ:
- список платежей — фильтры по статусу/шлюзу/периоду; массовое действие ограничено экспортом/пометкой к проверке — возврат только по одному платежу, с подтверждением;
- возврат — модальное подтверждение с суммой и причиной; для суммы больше доступной к возврату — кнопка недоступна (не серверная ошибка после клика);
- пустое состояние списка — подсказка «платежей ещё нет» / «нет активных шлюзов — включите в настройках»;
- отчёт сверки — расхождения подсвечены, ссылка на карточку платежа для ручной проверки.
Крайние случаи и типовые баги
- Двойной колбэк шлюза с одним
external_id→ обработка идемпотентна поexternal_idплатежа (не только поevent_idвебхука — второй вебхук с новымevent_id, но тем жеexternal_id, тоже не меняет статус дважды); повторная доставка — no-op,PaymentSucceededне публикуется повторно, фулфилмент не запускается дважды. - Колбэк раньше редиректа покупателя — шлюз может прислать вебхук до того, как браузер вернётся на сайт. Оба пути (webhook-обработчик и client-side redirect-check через
GET /payments/{id}) идемпотентно сходятся к одному статусу; редирект не выставляет статус сам — только читает текущий, гонки с вебхуком не возникает. - Частичная оплата (пришла сумма меньше суммы заказа) → не считается успешным закрытием заказа: статус «ожидает доплаты» либо ручная обработка администратором (конфиг),
OrderPaidне издаётся до полного закрытия. - Переплата (пришла сумма больше суммы заказа) → излишек фиксируется отдельной записью в
commerce_payment_transitions, не теряется в округлении; дальше — возврат излишка или зачисление на баланс (overpayment_policy). - Платёж завис в
pendingдольше TTL (шлюз не ответил/недоступен) → периодический poll-job опрашивает статус у шлюза; по истеченииpending_ttl_minutesбез ответа — перевод вfailed/отмена с освобождением резерва заказа, покупателю — понятная ошибка, не бесконечное ожидание. - Возврат больше уплаченного (с учётом уже сделанных частичных возвратов) → отклоняется валидацией на создании (сумма ≤ сумма платежа − Σ предыдущих возвратов), не проверкой постфактум у шлюза.
- Расхождение при сверке (
reconcile) → формирует отчёт для ручной проверки (commerce_payment_reconciliation.discrepancies_count); автоисправление баланса запрещено — расхождение может значить фрод или сбой шлюза, решение — за человеком. - Двойной сабмит формы оплаты (два клика/два таба) →
Idempotency-Keyна создание: повторный запрос с тем же ключом возвращает существующий платёж, не создаёт второй. - Заказ отменён, пока платёж в
pending→ платёж не отменяется автоматически; колбэк об успехе после отмены заказа — платёж помечается на ручную обработку (несогласованное состояние), не молчаливыйsucceededна отменённый заказ. - Валюта заказа не поддерживается выбранным шлюзом → шлюз не попадает в список активных для этого заказа на этапе выбора, а не 422 после попытки создать платёж.
- Выключен
cms/commerce-receipts/cms/commerce-digital(suggests) →PaymentSucceededпубликуется как обычно, фулфилмент этих доменов просто не происходит — оплата подтверждена, чек/выдача — по стандартной деградации suggests-зависимости. - Достигнут
max_attempts_per_order→ checkout отдаёт понятную ошибку «слишком много попыток оплаты, свяжитесь с поддержкой», новые попытки создания платежа для заказа отклоняются до сброса лимита администратором.
Донорский код
| Что взять | Путь |
|---|---|
| Интеграция ЮKassa, эскроу-логика | notal/src/app/Services/ |
| Подписки/рекуррентные платежи (Cashier) | er/project/src/app/Domain/Billing/ |
| Платежи, эскроу, арбитраж споров | freelance/project/src/app/Domains/{Payments,Escrow,Arbitration}/ |
Импорт истории со старой площадки: cms:commerce-payments:import-legacy --source=<профиль> [--dry-run] — маппинг платежей донора (у notal/er/freelance разные схемы хранения) на commerce_payments/commerce_payment_refunds; идемпотентность по external_id (повторный прогон обновляет, не дублирует); --dry-run — отчёт расхождений без записи. Прогон на копии данных — часть приёмки модуля при наличии боевого донора.
Тесты и приёмка
- [ ] Контрактный тест: повторный запрос с тем же
Idempotency-Keyне создаёт второй платёж - [ ] Переходы статус-машины валидны (нельзя
succeeded → pending), фиксируются вcommerce_payment_transitions - [ ] Обработка вебхука идемпотентна по
event_idи поexternal_id— повторная доставка (в т.ч. с разнымиevent_id) не меняет статус дважды и не запускает фулфилмент повторно - [ ] Подпись вебхука проверяется, колбэк без валидной подписи/вне окна
timestamp/nonce— отклонён (защита от replay) - [ ] Client-side redirect-check и webhook сходятся к одному статусу без гонки (колбэк раньше редиректа не создаёт рассинхрон)
- [ ] Частичный возврат уменьшает доступную к возврату сумму, повторный полный возврат сверх лимита отклоняется
- [ ] Частичная оплата не закрывает заказ; переплата фиксируется отдельной записью согласно
overpayment_policy - [ ] Платёж, зависший в
pendingдольшеpending_ttl_minutes, переводится poll-job'ом вfailed, резерв заказа освобождается - [ ]
PaymentSucceededпубликуется ровно один раз на успешный платёж и запускает фулфилмент (проверка на дублирование) - [ ] IDOR: платёж другого владельца заказа не отдаётся по
GET /payments/{id}(403/404) - [ ] Сумма платежа не принимается от клиента: поле
amountв запросе создания платежа игнорируется/отклоняется, сумма — из заказа - [ ] Kill-switch
accept_payments_enabled=falseблокирует создание новых платежей без выключения модуля - [ ] При выключении модуля заказ оформляется без онлайн-оплаты, без падения checkout
- [ ] Права
commerce-payments.manage/refundразграничивают просмотр и проведение возврата - [ ]
cms:commerce-payments:import-legacy --dry-runне пишет данные; обычный прогон идемпотентен поexternal_id - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут API, внешний шлюз в тестах замокан (без реальных вызовов)
- [ ] Тестовая БД — только
commerce-payments_test;migrate:fresh/refresh/reset,db:wipeзапрещены