Skip to content

ТЗ — Платежи (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_paymentsid, order_id, gateway_code, status, amount, currency_code, idempotency_key, external_idстатус-машина, amount — integer minor units + currency_code, не float
commerce_payment_transitionspayment_id, from_status, to_status, reason, created_atappend-only журнал переходов, BRIN-индекс по created_at
commerce_payment_refundsid, payment_id, amount, type (full|partial), status, reasonвозвраты, частичные и полные; type/status — PHP Enum
commerce_payment_reconciliationid, 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/валидатором):

ИсточникКаналПоляВалидация
Форма checkoutAPI POST /api/v1/checkout/paymentsorder_id, gateway_codeorder_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-inexternal_id/transaction_id, status, signature, timestamp/nonceподпись обязательна (см. Безопасность); event_id дедуп на уровне webhooks-in, external_id дедуп на уровне статус-машины платежа
Возврат (админ)Filament / API POST /admin/payments/{id}/refundamount, type (full|partial), reasonamount ≤ (сумма платежа − сумма уже сделанных возвратов); 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/Refundedpayload — см. «События и обмен»
АдминFilament-список, GET /admin/payments, GET /admin/payments/reconciliationконверт {data, meta}, keyset-пагинация
Оператор сверки/бухгалтерэкспорт отчёта сверкиJSON (commerce_payment_reconciliation.report), скачиваемый отчёт расхождений

Настройки (группа commerce-payments)

КлючТипДефолтaffectsPageCacheОписание
commerce-payments.enabled_gatewaysarray[]нетСписок активных кодов шлюзов на checkout
commerce-payments.default_gatewaystringnullнетШлюз по умолчанию, если один активен
commerce-payments.refund_requires_approvalbooltrueнетВозврат требует подтверждения администратором
commerce-payments.idempotency_ttl_hoursint24нетСрок жизни Idempotency-Key для повторных запросов
commerce-payments.reconciliation_auto_daysint1нетПериодичность автосверки в днях
commerce-payments.max_payment_amountint (minor units)10000000нетМаксимальная сумма разового платежа (лимит) — заградительный порог от ошибок ввода/фрода
commerce-payments.max_attempts_per_orderint5нетЛимит попыток оплаты на один заказ — защита от перебора карт
commerce-payments.pending_ttl_minutesint30нетTTL платежа в pending до принудительной проверки статуса poll-job'ом
commerce-payments.overpayment_policyenum (refund|balance)refundнетЧто делать с излишком при переплате: вернуть или зачислить на баланс
commerce-payments.accept_payments_enabledbooltrueнетKill-switch: аварийная остановка приёма новых платежей (подозрение на фрод/сбой шлюза) без выключения модуля целиком — история, возвраты, сверка продолжают работать

API

МетодПутьДоступНазначение
POST/api/v1/checkout/paymentsauth/guest (Idempotency-Key обязателен)Создание платежа по заказу, выбор шлюза
GET/api/v1/payments/{id}auth (владелец заказа)Статус платежа
POST/api/v1/admin/payments/{id}/refundadmin (commerce-payments.manage)Возврат (полный/частичный)
GET/api/v1/admin/paymentsadmin (commerce-payments.view)Список платежей с фильтрами по статусу/шлюзу (keyset-пагинация)
GET/api/v1/admin/payments/reconciliationadmin (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-in1 (событие 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/audit1 (событие)исходящеезапись финансовых операций (создание/возврат/сверка) в аудит
cms/attack-monitor1 (событие, при включении)исходящееаномалии (серия отказов, подозрительные возвраты)

Фоновая работа

Очередь 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-чек коннектора);
  • типовые инциденты:
    1. Шлюз не шлёт колбэки → проверить доставку в cms/webhooks-in (лог входящих) → cms:commerce-payments:retry-webhooks --json;
    2. Платежи массово зависают в pending → проверить health коннектора шлюза → ручной запуск poll/reconcile за период; при системном сбое шлюза — kill-switch accept_payments_enabled=false;
    3. Сверка показывает массовые расхождения → не автоисправлять — выгрузить GET /admin/payments/reconciliation, свести вручную с бухгалтерией/шлюзом;
    4. Двойной фулфилмент на один платёж → проверить commerce_payment_transitions на дубль перехода — баг идемпотентности, требует хотфикса, не рутинная операция;
    5. Возврат завис без подтверждения → статус у шлюза через status-метод контракта, ручная фиксация в commerce_payment_refunds только при подтверждённом факте.
  • бэкап/рестор: таблицы commerce_payments* — часть регулярного бэкапа БД (append-only журналы восстанавливаются штатно); денормализованных агрегатов модуль не хранит — доступный остаток возврата всегда Σ по журналу, пересчёт после рестора не требуется.

Производительность и кеш

Ожидаемый объём — тысячи платежей в день на крупном инсталле (пиковые часы распродаж — десятки в минуту). Горячие пути: создание платежа на checkout (синхронная часть — только редирект-URL, ≤2 с), обработка колбэка в очереди, список платежей в админке (keyset).

Индексы: commerce_paymentsorder_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, секрет подписи) — только .envconfig('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 запрещены

Внутренняя база знаний студии