Skip to content

feat(core): add payment attempt lifecycle and webhook callback - #604

Merged
biz87 merged 4 commits into
betafrom
feat/issue-590-payment-lifecycle
Sep 16, 2026
Merged

biz87 merged 4 commits into
betafrom
feat/issue-590-payment-lifecycle

Conversation

@Ibochkarev

@Ibochkarev Ibochkarev commented Aug 19, 2026

Copy link
Copy Markdown
Member

Описание

У заказа был только status_id. Async-провайдеру некуда было сохранить external_id, и в core не было общего webhook-входа.

Появились таблицы ms3_payment_attempts и ms3_payment_attempt_events (уникальные (payment_method_id, provider, external_id) и (attempt_id, event_type, provider_event_id)), сервис PaymentLifecycleService (DI ms3_payment_lifecycle) и публичный POST /api/v1/payment/webhook/{payment_method_id} без customer token. Тело только raw JSON, не $_POST. Невалидный JSON даёт 400, необработанный Throwable — 500 и запись в лог.

Смена статуса заказа идёт только через OrderStatusChanger. Провайдеры не пишут status_id. Повторный успешный callback идемпотентен: unique event в одной транзакции с UPDATE попытки (writeWithEvent), затем ensure(). Если заказ уже в целевом статусе, ensure() не вызывает change() — replay paid на fixed-статусе не даёт 409.

Статусы попытки: pending, authorized, paid, failed, cancelled, refunded, partially_refunded. DefaultPayment attempt не создаёт: в send() нет payment_id / external_id. Политики ms3_payment_on_failed_status и ms3_payment_on_refunded_status (по умолчанию 5, 0 оставляет заказ).

getPaymentLink идёт через PaymentService::resolvePaymentLink: ссылка открытой попытки этого метода (pending/authorized), иначе send() + initiate(). Если initiate() после успешного send() упал, checkout получает success => false и ms3_err_payment_attempt_record. apply() и refund() коммитят одинаково: writeWithEventensure().

Webhook paid / refunded / partially_refunded требуют non-empty externalId и paid ещё amount. Если в webhook есть currency, она должна совпасть с attempt. Paid amount = attempt amount, attempt amount = текущий order.cost. Refund требует provider event id. Over-refund не режется молча. external_id чужого метода не закрывает текущую попытку. Attempt на callback не создаётся с нуля. resolveAttempt только читает: external_id пишется в commit.

Секреты msPayment.properties убраны из OrdersPageService, ms3_get_order и писем (PaymentPublicFields). Эталонный HMAC: PaymentWebhookHmac и Payment::verifyWebhookHmac() (secret из properties secret / secret_key / webhook_secret, затем ms3_payment_secret). Конкретный эквайер в этот PR не входит.

Тип изменений

  • Новая функциональность (non-breaking change)

Связанные Issues

Closes #590

Как это было протестировано?

Локальный CI-гейт (без полной установки MODX/MySQL):

cd core/components/minishop3
composer ci:php
# php -l 648 files, exit 0
# smoke 90, exit 0
# PHPUnit 294 tests, 708 assertions, 9 skipped (@group mysql without DSN), 2 deprecations, exit 0

composer stan:prepare && composer stan
# [OK] No errors, exit 0

Vue не менялся, npm run lint:ci не запускался.

  • Автоматические тесты (composer ci:php, composer stan)
  • Ручное тестирование webhook с реальным провайдером
  • Тестирование на разных версиях PHP/MODX

Конфигурация тестирования:

  • MiniShop3: ветка feat/issue-590-payment-lifecycle от beta (коммиты 11721446, e7dcb7b0, b2ccc755)
  • MODX: не поднимался
  • PHP: 8.4.17

Скриншоты (если применимо)

N/A, backend/API.

Чеклист

  • Код соответствует стилю проекта
  • Добавлены/обновлены комментарии в сложных местах
  • Изменения не ломают существующую функциональность (PaymentProviderInterface и DefaultPayment без изменений контракта send/receive)
  • Лексиконы добавлены на двух языках (ru/en)
  • PHPStan проходит без новых ошибок (composer stan)
  • ESLint (npm run lint:ci) — N/A, Vue не тронут
  • CHANGELOG.md — не трогал, запись на релиз

Дополнительные заметки

Контракт провайдера: PaymentWebhookHandlerInterface::verifyWebhook($rawBody, $payload, $headers, $method) + PaymentLifecycleService. Старые провайдеры с собственным webhook продолжают работать. Новый вход opt-in.

Fenom-чанки, которые читали payment.properties, больше не получают секреты. Это цель AC.

Follow-up b2ccc755 закрывает blocker/high из ревью #604: replay на fixed, атомарный commit, currency, stale cost, externalId для финансовых событий, read-only resolveAttempt, без partialRefund().

Вне scope этого issue: конкретный эквайер, 3DS, UI попыток в менеджере, timeout pending, #569 payment/list.

@Ibochkarev Ibochkarev added the enhancement New feature or request label Aug 19, 2026
@Ibochkarev
Ibochkarev requested a review from biz87 August 19, 2026 04:38
@AgelxNash AgelxNash mentioned this pull request Sep 6, 2026
16 tasks
AgelxNash pushed a commit to AgelxNash/MiniShop3 that referenced this pull request Sep 6, 2026
…k callback

Conflict resolution: keep PR 596/603 status gate (lifecycle ports + inventory)
in OrderStatusService, add ensure() as OrderStatusChanger impl; registry deps
merged (order_status -> order_log, lifecycle_ports, inventory; payment_lifecycle
-> order_status); docblock example switched to PaymentLifecycle API.
@AgelxNash

Copy link
Copy Markdown

Этот PR включён в тестовую интеграционную сборку всех открытых PR MiniShop3: AgelxNash/MiniShop3, ветка integration/open-prs-20260906 (28/28 открытых).

Сборка нужна, чтобы проверить совместимость взаимозависимых серий PR до их мержа — при последовательном слиянии они конфликтуют друг с другом. Это не ревью и не конкурирующий PR: авторство сохранено (1 PR = 1 коммит с исходным автором), ветка пересобирается по мере обновления PR.

Как вошёл в сборку: Конфликт разрешён: ensure() добавлен — OrderStatusService реализует контракт OrderStatusChanger поверх lifecycle gate #596 и inventory #603; в карте зависимостей ms3_payment_lifecycle → ms3_order_status; docblock-пример Payment::receive() переключён на PaymentLifecycle API (сторона этого PR).

@AgelxNash

Copy link
Copy Markdown

Привет! Просто пожелание: удачи с этим PR 🚀 Работа нужная — пусть рассмотрят и смержат как можно скорее. Успехов!

@biz87

biz87 commented Sep 14, 2026

Copy link
Copy Markdown
Member

Проверил. С текущей beta сливается чисто, smoke, PHPUnit и PHPStan зелёные. Подтвердилось по коду:

  • оплата сверяется и с суммой попытки, и с текущим order.cost, валюта — если передана;
  • повтор события безопасен: FOR UPDATE в writeWithEvent() плюс уникальный индекс (attempt_id, event_type, provider_event_id);
  • вебхук не создаёт попытку, не закрывает попытку другого способа оплаты и не проводит заказ в финальном статусе;
  • DefaultPayment вебхук не принимает, так что из коробки новый маршрут ничего не открывает;
  • properties способа оплаты больше не попадают в ms3_get_order, кабинет и письма.

Порядок вливания и ребейз

После вливания любого PR доменной линии остальные конфликтуют. Предлагаю порядок #605#604: после вливания #605 этот PR нужно ребейзнуть на beta. Заодно CI прогонится с testbench — последний прогон здесь был до его появления.

Главное место при ребейзе — elements/snippets/ms3_get_order.php. #605 не знает про PaymentPublicFields: у него остаётся 'payment' => ($payment = $msOrder->getOne('Payment')) ? $payment->toArray() : [], а рядом добавляется 'shipments'. Если при разрешении конфликта оставить обе строки, в массиве будет два ключа payment, победит нижний — и сниппет снова начнёт отдавать properties. Нужно оставить PaymentPublicFields::fromEntityOrEmpty($payment) и добавить shipments. Остальные конфликты — ServiceRegistryFactories.php, tests/bootstrap.php, tests/stubs/StubMsOrder.php, tests/DeliveryPaymentCatalogRoutesTest.php — механические.

Проверка подписи

Между публичным маршрутом и проведением оплаты стоит только verifyWebhook(), и он целиком на классе провайдера: ядро подпись само не проверяет, verifyWebhookHmac() — необязательный хелпер. Требовать HMAC в ядре не нужно, схемы у провайдеров разные. Но стоит:

  • в докблоке PaymentWebhookHandlerInterface::verifyWebhook() прямо написать, что метод отвечает за подлинность запроса и заглушка return true позволяет провести оплату поддельным запросом: resolveAttempt() находит попытку по external_id или просто по заказу из события;
  • там же отметить, что при нескольких провайдерах лучше задавать свой секрет в properties каждого способа, а не полагаться на общий ms3_payment_secret.

Про ensure(): в #596 есть второй механизм того же — $options['idempotent']. Как их свести, написал в #596.

Async providers need a payment attempt distinct from msPayment and order status, plus an idempotent core webhook that maps events through OrderStatusService.
Checkout now fails when initiate() cannot record an attempt after send(), and getPaymentLink goes through PaymentService instead of a second gateway call.
Replay no longer re-runs change() when the order is already paid, and
attempt fields plus the idempotency event persist in one write. Financial
callbacks now require matching currency, current order cost, and externalId.
@Ibochkarev
Ibochkarev force-pushed the feat/issue-590-payment-lifecycle branch from ccc1e17 to 15787f4 Compare September 14, 2026 17:26
@Ibochkarev

Copy link
Copy Markdown
Member Author

@biz87 Спасибо — после вливания #605 ветка перебазирована на актуальную beta.

Конфликты:

  • ms3_get_order.php: оставлен PaymentPublicFields::fromEntityOrEmpty($payment) + shipments из feat(core): add shipment lifecycle and delivery webhook #605 (без сырого toArray() / properties).
  • ServiceRegistryFactories, bootstrap, DeliveryPaymentCatalogRoutesTest, StubMsOrder — объединены shipment + payment.

Подпись: в докблоке PaymentWebhookHandlerInterface::verifyWebhook() явно: метод — единственный gate подлинности; return true позволяет поддельному запросу дойти до resolveAttempt(); при нескольких провайдерах предпочтительнее секрет в msPayment.properties, а не общий ms3_payment_secret.

Smoke wiring + PHPUnit payment/webhook 40/40 — ok. CI с testbench должен подхватить tip.

ms3_payment_attempts / ms3_payment_attempt_events are PDO-backed like
shipments and have no xPDO map; testbench schema check must skip them.
@biz87
biz87 merged commit eb77392 into beta Sep 16, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Core: introduce a payment lifecycle abstraction for async providers

3 participants