Repository navigation
2.x final review: stuck auto-capture, silent sync declines, per-operation callback url, docs and test gaps #71
Description
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 atgetGateway();$defaultConfig = [];is unused. - D2 README:177-187 details table:
balanceis also written byAuthorize/Capture;statealso byNotify(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
400on 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 tokenAuthorizeActionmints" (it isCreatePaymentLinkAction), CLAUDE.md's e2e section still presentsHeaderAwareGetHttpRequestActionas harness-encoded (it ships insrc/Bridge). - D6
.gitattributes:CLAUDE.mdandcomposer-dependency-analyser.phpare exported in the dist.
Tests
- T1 R1 / R2 / R3 scenarios (none exist).
- T2
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). - T3
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. - T4
infection.json.distgates 65/70 vs. an actual 89/89 — raise to ~85 so a regression fails CI. - T5 Small:
Api's defaultautoCapture=falseis unpinned; the twomarkUnknown()calls are undetectable (GetHumanStatusstarts unknown —markNew()before executing kills both). - T6 Local note:
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)
- SDK: per-operation
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. - Plugin: forces
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.
Activity
Let a programmatic Capture settle a payment whose Quickpay-side auto-…
Drop the Operations helpers nothing calls any more
Refuse to queue a capture/refund/cancel while one is still in flight
Drop the Operations helpers nothing calls any more
Replace payum's plain-PHP GetHttpRequest action with the header-aware…
Let a programmatic Capture settle a payment whose Quickpay-side auto-…
Refuse to queue a capture/refund/cancel while one is still in flight
loevgaard commentedon Aug 17, 2026
Status (2026-08-17)
Merged (7): #72 (R2), #74 (R7), #75 (R5, R6), #76 (T2–T5), #77 (D1, D2, D4, D5, D6 + A2's docs half), #78 (A3), #79 (A4). Every one went in green on all 21 checks;
2.xis at fbfaa0a.Open (2), stacked #73 → #80:
Capturesettles a payment whose Quickpay-side auto-capture was declined; the return trip stays a no-op. Rebased onto current2.xand retargeted after Throw OperationRejectedException when a synchronized capture/refund/cancel is declined #72 landed. Wants the live check with test card1000 0000 0000 0032before merging: pay in aCaptureflow, confirm the return trip logsdonewith statusauthorized, thencomposer e2e:operate -- capture <number>— expect one capture issued by the gateway (before: a silent no-op). Please also note the shape of Quickpay's declined attempt (pending: false, non-20000qp_status_code) — that is what the tests assume.OperationPendingException) while a capture, refund or cancel is still pending. A policy call: Cancel and explicit-amount Refund gain aGET, and a second instalment has to wait for the first to settle. The PR lays out the trade-off; saying no just closes it.Still needing a live check, no PR:
stateflip topendingduring an async operation? Onee2e:operate refund+ an immediatestatusanswers it; if yes,StatusAction'spendingbranch should decide likeprocessed(last approved op, then balance).QuickPay-Callback-Urlon capture/refund/cancel (in Quickpay's swagger for all three). One curl on an authorized e2e payment with the header set to its notify-token url, watching the listener, tells whether the account-wide url and the order_id-resolving endpoint can be retired. Needs an SDK minor first (per-operationcallbackUrl), then a small gateway change and a much shorter Callbacks section.Cross-repo, updated: the plugin's
2.xhas meanwhile replaced the forceduse_authorize+auto_capturecheckbox with a capture mode that maps touse_authorize(A2 ✅ on its side), and its Convert now uses the target url and carries the currency-drift guard (A5 ✅). Still open there: itsNotifyActioncontroller doesn't catch Payum replies (an invalid checksum surfaces as a500instead of the gateway's400), andPaymentProcessorcompletes on "no exception" — which is exactly why R1/R2 matter and are now in.loevgaard commentedon Aug 17, 2026
Live checks done (2026-08-17, harness behind a Cloudflare quick tunnel — expose was in its free-tier cooldown)
A1 ✅ —
QuickPay-Callback-Urlworks, end to end.POST /payments/612072447/refund(amount 250) with the header set to that payment's own Payum notify-token url through the tunnel:202body's newrefundoperation carriescallback_url= the header value;CALLBACK OK via=token payum_status=captured quickpay_id=612072447— delivered to the per-payment url, HMAC valid,NotifyAction→ConfirmPaymentran, statuscaptured(partial refund, balance 750 — the balance rule doing its job);callback_success: true,callback_response_code: 200on the operation. Same again on 612072074.So the account-wide url and the order_id-resolving endpoint are avoidable once the SDK can send the header → Setono/quickpay-php-sdk#22 (also asks for a typed
Link::$autoCapture). Gateway PR follows the SDK minor. For contrast: the API-issued capture on 612102024 without the header showscallback_url: null— nowhere.R4 ✅ —
stateispendingduring an async operation, not just during 3-D Secure. 50 ms poll around the second refund:(The
202body itself also saysstate: pendingwith the pre-operation balance.)StatusActionmapped that tomarkPending(), so a captured payment reportedpendingfor that window. → #81:pendingnow decides likeprocessed, from the last approved operation; only nothing-approved-yet ispending.R1 ✅ — #73 verified, both halves. Paid a
Captureflow with1000 0000 0000 0032: Quickpay recordsauthorize 20000then its owncapture 40000 "Rejected test operation", pending: false(the shape the tests assume), statenew,link.auto_capture: true; both callbacks verified, return tripdone→ "Payment authorized". Thene2e:operate capture: on2.xit printed "requested" and issued nothing (still 2 ops — the stuck case); on #73's branch it issued a capture (op#3). A second payment with the listener on #73's code: the return trip issued nothing (TOKEN URL /capture → done, still 2 ops). Details on the PR.Two things surfaced along the way:
OperationRejectedExceptionfired on the asynchronous path: Quickpay answered the (test-card) capture with the operation already completed and declined. The docblock anticipated it; good to have it seen live.operate.phponly caughtQuickpayException, so that surfaced as a fatal → e2e operate: report gateway exceptions as a failed operation #82 (harness only).Open now: #73 (R1, verified — ready to merge), #81 (R4), #82 (harness), #80 (R3, policy; stacked on #73). SDK: quickpay-php-sdk#22.
Refuse to queue a capture/refund/cancel while one is still in flight
Refuse to queue a capture/refund/cancel while one is still in flight
loevgaard commentedon Aug 17, 2026
Closing out
Everything from this review that lives in this repository has landed on
2.x(at 2081039): #72, #73, #74, #75, #76, #77, #78, #79, #80, #81, #82 — eleven PRs, every one green on all 21 checks. R1, R4 and A1 were verified against the live API before their fixes went in (see the comments above).Capturea permanent no-opstateispendingduring any async operationnew; strict link amountpayum/core ^1.7.5QuickPay-Callback-Urlper operationuse_authorize/auto_capture, Convert2.x; README half in #77HeaderAwareGetHttpRequestActionOperationshelpersoperate.phpcatchLeft open on purpose, tracked elsewhere:
$model['callback_url']on capture/refund/cancel, then shrink the Callbacks docs. Worth doing before2.0.0if the SDK release is quick — it removes the account-wide-url requirement for every consumer.NotifyActioncontroller doesn't catch Payum replies (400→500);PaymentProcessorcompletes on "no exception" (R1/R2 now make that safe).Closing.
loevgaard commentedon Aug 17, 2026
Follow-up: SDK 1.1.0 shipped today; #83 upgrades to
^1.1and acts on its notes —Operation::isApproved()/isOfType()under our helpers,Shopsystemon the create request, andConvertfind-or-create viafindByOrderId()(Quickpay rejects duplicateorder_ids — verified live — and under Sylius the Payum payment number is the order number, so a retry after a decline used to die at Convert; a never-paid payment is now adopted, a paid one is refused with a clear error). Not in 1.1: the per-operationQuickPay-Callback-Url(quickpay-php-sdk#22) — A1's gateway half still waits on that.loevgaard commentedon Aug 17, 2026
SDK 1.2.0 shipped with #22/#25 → #84: operation callbacks now go to the notify token (
callbackUrl:on capture/refund/cancel — verified live through the gateway),Operationsdeleted in favour of the SDK'sPayment/Operationviews, typedLink::$autoCapture, the SDK's realorder_idrule. That closes A1's gateway half — the last item from this review that was waiting on anything.