Progress
Follow-up to #57 — a final review of 2.x (at 1a6cfc2 / v2.0.0-beta.1) for anything that review did not cover. Each finding that can be fixed here gets its own PR (stacked where they touch the same code); this table is updated as PRs open and land.
| # |
Finding |
PR |
| R1 |
Rejected Quickpay-side auto-capture leaves the payment stuck: Capture is a permanent no-op |
#73 ✅ merged (verified live) |
| R2 |
In synchronized mode a rejected capture/refund/cancel returns as success |
#72 ✅ merged |
| R3 |
No in-flight guard on the money operations (retry queues a second capture/refund/cancel) |
#80 ✅ merged |
| R4 |
state IS pending during an async operation (verified live) → StatusAction reported pending mid-refund |
#81 ✅ merged |
| R5 |
quickpayPaymentId => null throws instead of markNew in GetStatus/Sync |
#75 ✅ merged |
| R6 |
CreatePaymentLinkAction still casts amount with a bare (int) after #62 |
#75 ✅ merged |
| R7 |
payum/core ^1.6 admits versions where GetHttpRequest::$headers is a dynamic property → ^1.7.5 |
#74 ✅ merged |
| A1 |
Operation callbacks can be routed per request with the QuickPay-Callback-Url header — the account-wide url is avoidable |
verified live; SDK: Setono/quickpay-php-sdk#22, then a gateway PR |
| A2 |
auto_capture deprecation vs. setono/sylius-quickpay-plugin; README Sylius section |
plugin side resolved on its 2.x (capture mode → use_authorize, no more auto_capture); README half in #77 ✅ merged |
| A3 |
Auto-register HeaderAwareGetHttpRequestAction when payum's plain-PHP bridge is in place |
#78 ✅ merged |
| A4 |
Vestigial Operations helpers (latest, isLatestApproved, authorizedAmount) |
#79 ✅ merged |
| A5 |
Plugin's Convert sets continue_url = after url, lacks the currency guard |
resolved on the plugin's 2.x (target url + currency guard) |
| D1–D6 |
Docs: broken config example, details table, synchronized wording, retries, stale comments, .gitattributes |
#77 ✅ merged (D3 in #72) |
| T2–T5 |
Test gaps: factory credentials/aliases/options, token-factory wiring in the integration test, infection gates, small mutants |
#76 ✅ merged (T1 scenarios in #72/#73/#80) |
| — |
e2e operate.php only caught QuickpayException (found during the live checks) |
#82 ✅ merged |
Verified against the code, the SDK 1.0.0 sources, payum/core 1.7.7, Quickpay's published swagger (api.quickpay.net/docs/v10/merchant/api/payments) and the Sylius plugin. R1 and R2 were confirmed with throwaway tests against the mock HTTP seam. Baseline: composer all green, infection MSI 89% (42 escaped mutants).
Robustness
R1 (high) — A rejected Quickpay-side auto-capture leaves the payment stuck: Capture is a permanent no-op
CaptureAction.php:61-69 returns unconditionally when the link carries auto_capture and an approved authorize exists. That covers "Quickpay captured" and "Quickpay's capture is queued" — but also "Quickpay's capture was rejected" (test card 1000 0000 0000 0032, listed in the harness). Then the state stays new, GetStatus → authorized, and every Capture from then on — with or without capture_amount — fetches and returns without issuing anything. Nothing in the gateway can ever capture that payment; only the Quickpay manager can. Confirmed offline: authorize 20000 + capture 40000 + link.auto_capture=true → Capture issues no request.
Sharpest consequence: setono/sylius-quickpay-plugin's PaymentProcessor runs GetStatus, sees not-captured, executes Capture, gets no exception → the Sylius payment transitions to completed with no money captured.
Fix: no-op only while a capture is approved/pending or not yet recorded (the race window right after authorization); when the latest capture attempt is rejected, Quickpay tried once and will not retry. The return trip (a token-carrying Capture) should still not move money — the customer has to land somewhere and the shop sees authorized — but a programmatic Capture (the merchant settling; the plugin's complete transition) captures through the API. Needs the live check with card …0032 that #57 never ran.
R2 (high) — In synchronized mode a rejected capture/refund/cancel returns as success
?synchronized makes Quickpay answer 200 with the completed operation — completed, not approved: a decline is a 2xx with qp_status_code ≠ 20000. The SDK maps only the HTTP status to exceptions (Client.php:353-376), and CaptureAction.php:71-77 / RefundAction.php:37-43 / CancelAction.php:36 ignore the returned Payment entirely (right for the async snapshot, wrong for the sync outcome). Confirmed offline: synchronized capture, response operation 40000 → no exception, capture_amount consumed. README:42/155 and UPGRADE:146 present synchronized as "block until settled" — a caller (again the plugin: complete transition → Capture → no exception → completed) reasonably reads that as "and tell me if it failed".
Fix: after the call, look at the returned payment's newest operation of that type; if it is not pending and not approved → throw a typed OperationRejectedException (Quickpay status code/message on it) before Amounts::consume(). Cheap to do unconditionally: async responses carry the operation as pending, so nothing changes there.
R3 (medium, policy call) — No in-flight guard on the money operations
AuthorizeAction.php:53-57 refuses to act while a capture is pending, but on the plain-link path CaptureAction issues a capture with a capture already pending, RefundAction refunds the (pre-operation!) balance with a refund already pending, and CancelAction cancels with a cancel pending. A retry after a timeout, or a double click, queues a second operation; whether Quickpay rejects the duplicate at queue time or only at processing time is unverified. Suggest hasPending(<same type>) → LogicException ("still pending — wait for the callback or use synchronized"). It also blocks a legitimate second instalment issued within seconds of the first, and it costs the explicit-amount refund and the cancel a fetch they do not make today — hence a policy call.
R4 (medium, verify live) — Does state become pending during an async operation?
StatusAction.php:64-66 maps pending → markPending(). If Quickpay flips a captured payment to pending while a refund is in flight (the SDK docs only say the 202 snapshot keeps the pre-operation state), then #65's last-approved-operation fix — only reached in processed — does not apply, and a captured payment reports pending mid-refund. The suite assumes both shapes (pending for a queued capture in CaptureActionTest::linkAutoCapturesProvider, processed for a pending refund in StatusActionTest:202). One e2e:operate refund + immediate status settles it. If pending does occur, decide it like processed: last approved operation, then balance.
R5 (low) — quickpayPaymentId => null throws instead of markNew
StatusAction.php:35 / SyncAction.php:44 guard with offsetExists(), which is true for a null value (verified against payum's ArrayObject), so Details::paymentId() throws "execute Convert first" where GetStatus should answer new (and Sylius would then Convert). ConvertPaymentAction uses isset(). Align on "null means absent".
R6 (low) — CreatePaymentLinkAction.php:59 casts amount with a bare (int)
The link amount is what Quickpay authorizes; Amounts was made strict for exactly this (#62), but the link path still truncates '249.99' to 249. Reuse the strict read. Also worth a doc line: capture_amount is ignored on the interactive Capture (the link auto-captures the full link amount).
R7 (low) — payum/core ^1.6 allows versions where GetHttpRequest::$headers is a dynamic property
#[\AllowDynamicProperties] landed in payum/core 1.7.5 (2024-09). Below that, on PHP ≥ 8.2, HeaderAwareGetHttpRequestAction (and payum's own Symfony bridge) emit a deprecation per callback, and PHP 9 makes it fatal. phpstan.neon.dist already assumes ≥ 1.7.5. Bump to ^1.7.5.
Architecture
A1 (high) — The "two callback urls" problem has an API-level fix the gateway is not using
Quickpay's swagger for POST /payments/{id}/capture|refund|cancel lists a request header QuickPay-Callback-Url — "Specify the callback url (overrides merchant default callback-url)" (also on learn.quickpay.net/tech-talk/api/callback). Sending the payment's callback_url (the notify token url already in the details) on every operation would route capture/refund/cancel callbacks to the per-payment Payum notify token — no account-wide url, no order_id-resolving endpoint (README:146-155, UPGRADE:123-148, examples/e2e/listen.php and the plugin's NotifyAction controller all exist to work around this). SDK 1.0 has no way to set a per-request header (ResourceEndpoint::postOperation), so this is: SDK minor (optional callbackUrl on capture/refund/cancel) → gateway passes $model['callback_url'] when present → the docs shrink to a footnote.
Verify live first, e.g. curl -u ":$KEY" -H 'Accept-Version: v10' -H "QuickPay-Callback-Url: <notify token url>" -d amount=1000 https://api.quickpay.net/payments/<id>/capture on an authorized e2e payment and watch the listener; the response operation's own callback_url field also shows where it went.
A2 (medium) — auto_capture deprecation vs. the Sylius plugin, and README's Sylius section
setono/sylius-quickpay-plugin's GatewayConfigurationType forces use_authorize = true (a HiddenType) and exposes auto_capture as its "capture at authorization" checkbox — i.e. the primary consumer's only way to express a sale is the option 2.0 deprecates, and README:270-274 ("Sylius drives its checkout through Capture by default … Nothing to configure") is not what a plugin-based shop gets. Either soften the deprecation until the plugin derives use_authorize from the checkbox (use_authorize = !auto_capture), or coordinate that plugin change with 2.0. Either way the README's Sylius section should point at the plugin (admin form, notify endpoint, reconcile command) rather than describe a bare GatewayFactoryBuilder service as if the admin form existed.
A3 (medium, optional) — Register HeaderAwareGetHttpRequestAction automatically
payum's core config sets payum.action.get_http_request to a plain-PHP GetHttpRequestAction instance before populateConfig() runs, so QuickpayGatewayFactory can detect get_class(...) === Payum\Core\Bridge\PlainPhp\Action\GetHttpRequestAction::class and swap in the header-aware subclass. Symfony's bridge (a different class) and any consumer override are left alone. Removes the "every callback is a 400 unless you read the README" failure mode entirely; the README snippet becomes optional.
A4 (low) — Vestigial public helpers
Operations::latest(), isLatestApproved(), authorizedAmount() (Operations.php:31-38, 110-129) have no callers in src/ since #65/#69. Beta is the time to drop them (or accept them as public API deliberately).
A5 (info) — The plugin overrides ConvertPaymentAction and sets continue_url = after url
So under the plugin the #69 return-trip design (re-run Capture/Authorize) is never exercised; the outcome comes from Sylius's after-pay GetStatus. Fine — but its Convert also lacks the currency-drift guard (#63) and the order-id length guard is its own. Worth knowing when reasoning about "what Sylius does".
Docs
- D1 README:58-74 configuration example builds a gateway with no
api_key/private_key → throws at getGateway(); $defaultConfig = []; is unused.
- D2 README:177-187 details table:
balance is also written by Authorize/Capture; state also by Notify (ConfirmPaymentAction:51). UPGRADE:96-99 same.
- D3
synchronized (README:42,155; UPGRADE:146) implies the outcome is surfaced — see R2.
- D4 Callbacks: mention that Quickpay retries up to 24 times with backoff (so a
400 on a bad checksum is retried), and A1 once verified.
- D5 Stale:
examples/e2e/listen.php:107 "notify token url AuthorizeAction built", CLAUDE.md "the notify token AuthorizeAction mints" (it is CreatePaymentLinkAction), CLAUDE.md's e2e section still presents HeaderAwareGetHttpRequestAction as harness-encoded (it ships in src/Bridge).
- D6
.gitattributes: CLAUDE.md and composer-dependency-analyser.php are exported in the dist.
Tests
- T1 R1 / R2 / R3 scenarios (none exist).
- T2
QuickpayGatewayFactoryTest: only "missing private_key" is pinned (:364-378) — dropping api_key from the required options survives mutation; nothing asserts the apikey alias / api_key reaches the client (send one request through the mock and check Authorization); nothing asserts order_prefix/language/private_key land on Api from a full config (4 escaped CastString mutants, factory :105-108).
- T3
GatewayIntegrationTest:96-99 presets callback_url "since no token factory is wired" — so the one aware interface that matters most (GenericTokenFactoryAwareInterface on CreatePaymentLinkAction) is never exercised through the factory-built gateway. Pass 'payum.extension.token_factory' => new GenericTokenFactoryExtension(new StubTokenFactory()) (what PayumBuilder does) and drop the preset.
- T4
infection.json.dist gates 65/70 vs. an actual 89/89 — raise to ~85 so a regression fails CI.
- T5 Small:
Api's default autoCapture=false is unpinned; the two markUnknown() calls are undetectable (GetHumanStatus starts unknown — markNew() before executing kills both).
- T6 Local note:
composer infection on PHP 8.4 dies in the initial run (exit 143 under infection's own thecodingmachine/safe deprecation flood); --initial-tests-php-options='-d error_reporting=24575' works.
Cross-repo (for the SDK / plugin trackers)
- SDK: per-operation
QuickPay-Callback-Url (A1); model Link::$autoCapture (the swagger has it; the gateway reads raw['link']['auto_capture']); document/handle sync-mode rejected operations (R2) — a latestOperation(type) helper would serve both.
- Plugin: forces
use_authorize and depends on the deprecated auto_capture (A2); its NotifyAction controller does not catch Payum replies (Reply\Base extends LogicException), so an invalid checksum surfaces as a 500 rather than the gateway's 400; PaymentProcessor completes on "no exception" — R1/R2 make that unsafe.
Suggested order
R2 → R1 (with the …0032 live check) → A1 live check (decides SDK work + docs) → R3/R7/D1–D6/T2–T4 (small, independent) → A2 decision with the plugin → A3/R5/R6/A4 optional polish.
Progress
Follow-up to #57 — a final review of
2.x(at 1a6cfc2 /v2.0.0-beta.1) for anything that review did not cover. Each finding that can be fixed here gets its own PR (stacked where they touch the same code); this table is updated as PRs open and land.Captureis a permanent no-opsynchronizedmode a rejected capture/refund/cancel returns as successstateISpendingduring an async operation (verified live) →StatusActionreportedpendingmid-refundquickpayPaymentId => nullthrows instead ofmarkNewinGetStatus/SyncCreatePaymentLinkActionstill castsamountwith a bare(int)after #62payum/core ^1.6admits versions whereGetHttpRequest::$headersis a dynamic property →^1.7.5QuickPay-Callback-Urlheader — the account-wide url is avoidableauto_capturedeprecation vs.setono/sylius-quickpay-plugin; README Sylius section2.x(capture mode →use_authorize, no moreauto_capture); README half in #77 ✅ mergedHeaderAwareGetHttpRequestActionwhen payum's plain-PHP bridge is in placeOperationshelpers (latest,isLatestApproved,authorizedAmount)continue_url = after url, lacks the currency guard2.x(target url + currency guard)synchronizedwording, retries, stale comments,.gitattributesoperate.phponly caughtQuickpayException(found during the live checks)Verified against the code, the SDK 1.0.0 sources, payum/core 1.7.7, Quickpay's published swagger (
api.quickpay.net/docs/v10/merchant/api/payments) and the Sylius plugin. R1 and R2 were confirmed with throwaway tests against the mock HTTP seam. Baseline:composer allgreen, infection MSI 89% (42 escaped mutants).Robustness
R1 (high) — A rejected Quickpay-side auto-capture leaves the payment stuck:
Captureis a permanent no-opCaptureAction.php:61-69returns unconditionally when the link carriesauto_captureand an approved authorize exists. That covers "Quickpay captured" and "Quickpay's capture is queued" — but also "Quickpay's capture was rejected" (test card1000 0000 0000 0032, listed in the harness). Then the state staysnew,GetStatus→authorized, and everyCapturefrom then on — with or withoutcapture_amount— fetches and returns without issuing anything. Nothing in the gateway can ever capture that payment; only the Quickpay manager can. Confirmed offline: authorize20000+ capture40000+link.auto_capture=true→Captureissues no request.Sharpest consequence:
setono/sylius-quickpay-plugin'sPaymentProcessorrunsGetStatus, sees not-captured, executesCapture, gets no exception → the Sylius payment transitions to completed with no money captured.Fix: no-op only while a capture is approved/pending or not yet recorded (the race window right after authorization); when the latest capture attempt is rejected, Quickpay tried once and will not retry. The return trip (a token-carrying
Capture) should still not move money — the customer has to land somewhere and the shop seesauthorized— but a programmaticCapture(the merchant settling; the plugin'scompletetransition) captures through the API. Needs the live check with card …0032 that #57 never ran.R2 (high) — In
synchronizedmode a rejected capture/refund/cancel returns as success?synchronizedmakes Quickpay answer200with the completed operation — completed, not approved: a decline is a 2xx withqp_status_code ≠ 20000. The SDK maps only the HTTP status to exceptions (Client.php:353-376), andCaptureAction.php:71-77/RefundAction.php:37-43/CancelAction.php:36ignore the returnedPaymententirely (right for the async snapshot, wrong for the sync outcome). Confirmed offline: synchronized capture, response operation40000→ no exception,capture_amountconsumed. README:42/155 and UPGRADE:146 presentsynchronizedas "block until settled" — a caller (again the plugin:completetransition →Capture→ no exception → completed) reasonably reads that as "and tell me if it failed".Fix: after the call, look at the returned payment's newest operation of that type; if it is not pending and not approved → throw a typed
OperationRejectedException(Quickpay status code/message on it) beforeAmounts::consume(). Cheap to do unconditionally: async responses carry the operation as pending, so nothing changes there.R3 (medium, policy call) — No in-flight guard on the money operations
AuthorizeAction.php:53-57refuses to act while a capture is pending, but on the plain-link pathCaptureActionissues a capture with a capture alreadypending,RefundActionrefunds the (pre-operation!)balancewith a refund already pending, andCancelActioncancels with a cancel pending. A retry after a timeout, or a double click, queues a second operation; whether Quickpay rejects the duplicate at queue time or only at processing time is unverified. SuggesthasPending(<same type>)→LogicException("still pending — wait for the callback or usesynchronized"). It also blocks a legitimate second instalment issued within seconds of the first, and it costs the explicit-amount refund and the cancel a fetch they do not make today — hence a policy call.R4 (medium, verify live) — Does
statebecomependingduring an async operation?StatusAction.php:64-66mapspending→markPending(). If Quickpay flips a captured payment topendingwhile a refund is in flight (the SDK docs only say the202snapshot keeps the pre-operation state), then #65's last-approved-operation fix — only reached inprocessed— does not apply, and a captured payment reportspendingmid-refund. The suite assumes both shapes (pendingfor a queued capture inCaptureActionTest::linkAutoCapturesProvider,processedfor a pending refund inStatusActionTest:202). Onee2e:operate refund+ immediatestatussettles it. Ifpendingdoes occur, decide it likeprocessed: last approved operation, then balance.R5 (low) —
quickpayPaymentId => nullthrows instead ofmarkNewStatusAction.php:35/SyncAction.php:44guard withoffsetExists(), which istruefor a null value (verified against payum'sArrayObject), soDetails::paymentId()throws "execute Convert first" whereGetStatusshould answernew(and Sylius would then Convert).ConvertPaymentActionusesisset(). Align on "null means absent".R6 (low) —
CreatePaymentLinkAction.php:59castsamountwith a bare(int)The link amount is what Quickpay authorizes;
Amountswas made strict for exactly this (#62), but the link path still truncates'249.99'to249. Reuse the strict read. Also worth a doc line:capture_amountis ignored on the interactiveCapture(the link auto-captures the full link amount).R7 (low) —
payum/core ^1.6allows versions whereGetHttpRequest::$headersis a dynamic property#[\AllowDynamicProperties]landed in payum/core 1.7.5 (2024-09). Below that, on PHP ≥ 8.2,HeaderAwareGetHttpRequestAction(and payum's own Symfony bridge) emit a deprecation per callback, and PHP 9 makes it fatal.phpstan.neon.distalready assumes ≥ 1.7.5. Bump to^1.7.5.Architecture
A1 (high) — The "two callback urls" problem has an API-level fix the gateway is not using
Quickpay's swagger for
POST /payments/{id}/capture|refund|cancellists a request headerQuickPay-Callback-Url— "Specify the callback url (overrides merchant default callback-url)" (also on learn.quickpay.net/tech-talk/api/callback). Sending the payment'scallback_url(the notify token url already in the details) on every operation would route capture/refund/cancel callbacks to the per-payment Payum notify token — no account-wide url, no order_id-resolving endpoint (README:146-155, UPGRADE:123-148,examples/e2e/listen.phpand the plugin'sNotifyActioncontroller all exist to work around this). SDK 1.0 has no way to set a per-request header (ResourceEndpoint::postOperation), so this is: SDK minor (optionalcallbackUrloncapture/refund/cancel) → gateway passes$model['callback_url']when present → the docs shrink to a footnote.Verify live first, e.g.
curl -u ":$KEY" -H 'Accept-Version: v10' -H "QuickPay-Callback-Url: <notify token url>" -d amount=1000 https://api.quickpay.net/payments/<id>/captureon an authorized e2e payment and watch the listener; the response operation's owncallback_urlfield also shows where it went.A2 (medium) —
auto_capturedeprecation vs. the Sylius plugin, and README's Sylius sectionsetono/sylius-quickpay-plugin'sGatewayConfigurationTypeforcesuse_authorize = true(aHiddenType) and exposesauto_captureas its "capture at authorization" checkbox — i.e. the primary consumer's only way to express a sale is the option 2.0 deprecates, and README:270-274 ("Sylius drives its checkout through Capture by default … Nothing to configure") is not what a plugin-based shop gets. Either soften the deprecation until the plugin derivesuse_authorizefrom the checkbox (use_authorize = !auto_capture), or coordinate that plugin change with 2.0. Either way the README's Sylius section should point at the plugin (admin form, notify endpoint, reconcile command) rather than describe a bareGatewayFactoryBuilderservice as if the admin form existed.A3 (medium, optional) — Register
HeaderAwareGetHttpRequestActionautomaticallypayum's core config sets
payum.action.get_http_requestto a plain-PHPGetHttpRequestActioninstance beforepopulateConfig()runs, soQuickpayGatewayFactorycan detectget_class(...) === Payum\Core\Bridge\PlainPhp\Action\GetHttpRequestAction::classand swap in the header-aware subclass. Symfony's bridge (a different class) and any consumer override are left alone. Removes the "every callback is a 400 unless you read the README" failure mode entirely; the README snippet becomes optional.A4 (low) — Vestigial public helpers
Operations::latest(),isLatestApproved(),authorizedAmount()(Operations.php:31-38, 110-129) have no callers insrc/since #65/#69. Beta is the time to drop them (or accept them as public API deliberately).A5 (info) — The plugin overrides
ConvertPaymentActionand setscontinue_url = after urlSo under the plugin the #69 return-trip design (re-run
Capture/Authorize) is never exercised; the outcome comes from Sylius's after-payGetStatus. Fine — but its Convert also lacks the currency-drift guard (#63) and the order-id length guard is its own. Worth knowing when reasoning about "what Sylius does".Docs
api_key/private_key→ throws atgetGateway();$defaultConfig = [];is unused.balanceis also written byAuthorize/Capture;statealso byNotify(ConfirmPaymentAction:51). UPGRADE:96-99 same.synchronized(README:42,155; UPGRADE:146) implies the outcome is surfaced — see R2.400on a bad checksum is retried), and A1 once verified.examples/e2e/listen.php:107"notify token url AuthorizeAction built", CLAUDE.md "the notify tokenAuthorizeActionmints" (it isCreatePaymentLinkAction), CLAUDE.md's e2e section still presentsHeaderAwareGetHttpRequestActionas harness-encoded (it ships insrc/Bridge)..gitattributes:CLAUDE.mdandcomposer-dependency-analyser.phpare exported in the dist.Tests
QuickpayGatewayFactoryTest: only "missingprivate_key" is pinned (:364-378) — droppingapi_keyfrom the required options survives mutation; nothing asserts theapikeyalias /api_keyreaches the client (send one request through the mock and checkAuthorization); nothing assertsorder_prefix/language/private_keyland onApifrom a full config (4 escapedCastStringmutants, factory :105-108).GatewayIntegrationTest:96-99presetscallback_url"since no token factory is wired" — so the one aware interface that matters most (GenericTokenFactoryAwareInterfaceonCreatePaymentLinkAction) is never exercised through the factory-built gateway. Pass'payum.extension.token_factory' => new GenericTokenFactoryExtension(new StubTokenFactory())(whatPayumBuilderdoes) and drop the preset.infection.json.distgates 65/70 vs. an actual 89/89 — raise to ~85 so a regression fails CI.Api's defaultautoCapture=falseis unpinned; the twomarkUnknown()calls are undetectable (GetHumanStatusstarts unknown —markNew()before executing kills both).composer infectionon PHP 8.4 dies in the initial run (exit 143 under infection's ownthecodingmachine/safedeprecation flood);--initial-tests-php-options='-d error_reporting=24575'works.Cross-repo (for the SDK / plugin trackers)
QuickPay-Callback-Url(A1); modelLink::$autoCapture(the swagger has it; the gateway readsraw['link']['auto_capture']); document/handle sync-mode rejected operations (R2) — alatestOperation(type)helper would serve both.use_authorizeand depends on the deprecatedauto_capture(A2); itsNotifyActioncontroller does not catch Payum replies (Reply\Base extends LogicException), so an invalid checksum surfaces as a500rather than the gateway's400;PaymentProcessorcompletes on "no exception" — R1/R2 make that unsafe.Suggested order
R2 → R1 (with the …0032 live check) → A1 live check (decides SDK work + docs) → R3/R7/D1–D6/T2–T4 (small, independent) → A2 decision with the plugin → A3/R5/R6/A4 optional polish.