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

Open
Ibochkarev wants to merge 3 commits into
betafrom
feat/issue-590-payment-lifecycle
Open

feat(core): add payment attempt lifecycle and webhook callback#604
Ibochkarev wants to merge 3 commits into
betafrom
feat/issue-590-payment-lifecycle

Conversation

@Ibochkarev

@IbochkarevIbochkarev 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 из propertiessecret / 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.

Async providers need a payment attempt distinct from msPayment and order status, plus an idempotent core webhook that maps events through OrderStatusService.
@IbochkarevIbochkarev added the enhancement New feature or request label Aug 19, 2026
Checkout now fails when initiate() cannot record an attempt after send(), and getPaymentLink goes through PaymentService instead of a second gateway call.
@Ibochkarev
Ibochkarev requested a review from biz87August 19, 2026 04:38
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.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancementNew 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

1 participant

@Ibochkarev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

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

Open
Ibochkarev wants to merge 3 commits into
betafrom
feat/issue-590-payment-lifecycle
Open

feat(core): add payment attempt lifecycle and webhook callback#604
Ibochkarev wants to merge 3 commits into
betafrom
feat/issue-590-payment-lifecycle

Conversation

@Ibochkarev

@IbochkarevIbochkarev 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 из propertiessecret / 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.

Async providers need a payment attempt distinct from msPayment and order status, plus an idempotent core webhook that maps events through OrderStatusService.
@IbochkarevIbochkarev added the enhancement New feature or request label Aug 19, 2026
Checkout now fails when initiate() cannot record an attempt after send(), and getPaymentLink goes through PaymentService instead of a second gateway call.
@Ibochkarev
Ibochkarev requested a review from biz87August 19, 2026 04:38
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.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancementNew 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

1 participant

@Ibochkarev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Open
Ibochkarev wants to merge 3 commits into
betafrom
feat/issue-590-payment-lifecycle
Open

feat(core): add payment attempt lifecycle and webhook callback#604
Ibochkarev wants to merge 3 commits into
betafrom
feat/issue-590-payment-lifecycle

Conversation

@Ibochkarev

@IbochkarevIbochkarev 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 из propertiessecret / 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.

Async providers need a payment attempt distinct from msPayment and order status, plus an idempotent core webhook that maps events through OrderStatusService.
@IbochkarevIbochkarev added the enhancement New feature or request label Aug 19, 2026
Checkout now fails when initiate() cannot record an attempt after send(), and getPaymentLink goes through PaymentService instead of a second gateway call.
@Ibochkarev
Ibochkarev requested a review from biz87August 19, 2026 04:38
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.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancementNew 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

1 participant

@Ibochkarev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Open
Ibochkarev wants to merge 3 commits into
betafrom
feat/issue-590-payment-lifecycle
Open

feat(core): add payment attempt lifecycle and webhook callback#604
Ibochkarev wants to merge 3 commits into
betafrom
feat/issue-590-payment-lifecycle

Conversation

@Ibochkarev

@IbochkarevIbochkarev 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 из propertiessecret / 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.

Async providers need a payment attempt distinct from msPayment and order status, plus an idempotent core webhook that maps events through OrderStatusService.
@IbochkarevIbochkarev added the enhancement New feature or request label Aug 19, 2026
Checkout now fails when initiate() cannot record an attempt after send(), and getPaymentLink goes through PaymentService instead of a second gateway call.
@Ibochkarev
Ibochkarev requested a review from biz87August 19, 2026 04:38
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.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancementNew 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

1 participant

@Ibochkarev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

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

Open
Ibochkarev wants to merge 3 commits into
betafrom
feat/issue-590-payment-lifecycle
Open

feat(core): add payment attempt lifecycle and webhook callback#604
Ibochkarev wants to merge 3 commits into
betafrom
feat/issue-590-payment-lifecycle

Conversation

@Ibochkarev

@IbochkarevIbochkarev 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 из propertiessecret / 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.

Async providers need a payment attempt distinct from msPayment and order status, plus an idempotent core webhook that maps events through OrderStatusService.
@IbochkarevIbochkarev added the enhancement New feature or request label Aug 19, 2026
Checkout now fails when initiate() cannot record an attempt after send(), and getPaymentLink goes through PaymentService instead of a second gateway call.
@Ibochkarev
Ibochkarev requested a review from biz87August 19, 2026 04:38
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.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancementNew 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

1 participant

@Ibochkarev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Open
Ibochkarev wants to merge 3 commits into
betafrom
feat/issue-590-payment-lifecycle
Open

feat(core): add payment attempt lifecycle and webhook callback#604
Ibochkarev wants to merge 3 commits into
betafrom
feat/issue-590-payment-lifecycle

Conversation

@Ibochkarev

@IbochkarevIbochkarev 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 из propertiessecret / 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.

Async providers need a payment attempt distinct from msPayment and order status, plus an idempotent core webhook that maps events through OrderStatusService.
@IbochkarevIbochkarev added the enhancement New feature or request label Aug 19, 2026
Checkout now fails when initiate() cannot record an attempt after send(), and getPaymentLink goes through PaymentService instead of a second gateway call.
@Ibochkarev
Ibochkarev requested a review from biz87August 19, 2026 04:38
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.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancementNew 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

1 participant

@Ibochkarev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Open
Ibochkarev wants to merge 3 commits into
betafrom
feat/issue-590-payment-lifecycle
Open

feat(core): add payment attempt lifecycle and webhook callback#604
Ibochkarev wants to merge 3 commits into
betafrom
feat/issue-590-payment-lifecycle

Conversation

@Ibochkarev

@IbochkarevIbochkarev 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 из propertiessecret / 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.

Async providers need a payment attempt distinct from msPayment and order status, plus an idempotent core webhook that maps events through OrderStatusService.
@IbochkarevIbochkarev added the enhancement New feature or request label Aug 19, 2026
Checkout now fails when initiate() cannot record an attempt after send(), and getPaymentLink goes through PaymentService instead of a second gateway call.
@Ibochkarev
Ibochkarev requested a review from biz87August 19, 2026 04:38
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.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancementNew 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

1 participant

@Ibochkarev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

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

Open
Ibochkarev wants to merge 3 commits into
betafrom
feat/issue-590-payment-lifecycle
Open

feat(core): add payment attempt lifecycle and webhook callback#604
Ibochkarev wants to merge 3 commits into
betafrom
feat/issue-590-payment-lifecycle

Conversation

@Ibochkarev

@IbochkarevIbochkarev 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 из propertiessecret / 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.

Async providers need a payment attempt distinct from msPayment and order status, plus an idempotent core webhook that maps events through OrderStatusService.
@IbochkarevIbochkarev added the enhancement New feature or request label Aug 19, 2026
Checkout now fails when initiate() cannot record an attempt after send(), and getPaymentLink goes through PaymentService instead of a second gateway call.
@Ibochkarev
Ibochkarev requested a review from biz87August 19, 2026 04:38
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.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancementNew 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

1 participant

@Ibochkarev