You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
deferred until the PRs above land (would conflict with all of them)
A thorough review of the 2.x branch: architecture, robustness, gaps compared to other Payum integrations, and test coverage. Overall the gateway is in very good shape — thin actions over a typed SDK, scalar-only details contract, balance-driven status mapping, HMAC-verified callbacks. The findings below are ordered by severity; the first one is a real production bug.
Robustness
1. Bug: a declined payment with auto_capture on puts the notify endpoint into a 500-retry loop
ConfirmPaymentAction::execute() gates the auto-capture on OperationType::Authorize === $latestOperation->type() — it never checks that the authorize was approved. A rejected authorize (declined card — a routine event in the payment window, and Quickpay sends a callback for it) has type authorize but qp_status_code40000, so Operations::authorizedAmount() returns 0, the amount comparison fails, and the action throws LogicException. That 500s the notify endpoint, and Quickpay retries a callback that can never succeed. The same happens for a pending authorize (qp_status_codenull).
The guard should be Operations::isApprovedOfType($latestOperation, OperationType::Authorize) — rejected/pending authorizes should be a silent return; the amount mismatch (a genuine anomaly) is arguably the only case worth throwing for.
There is no test for the rejected-authorize callback; shouldThrowWhenAuthorizedAmountDoesNotMatch only covers the approved-but-wrong-amount case.
2. StatusAction degrades when the trailing operation is rejected or pending
The Processed branch decides from Operations::latest() alone. Capture 1000, then a rejected refund attempt → the latest operation is an unapproved refund → markUnknown(), although 1000 is demonstrably still held (balance > 0, already in hand). Similarly an async refund still pending flips a captured payment to unknown until the callback lands. markUnknown is at least honest, but downstream consumers (Sylius state machines) treat unknown as a dead end.
Deciding from the last approved operation — or falling back to "balance > 0 → captured" — would keep the status truthful through failed/pending trailing operations. The New-state branch has the same shape: any latest operation that is not an approved authorize is markFailed, even if an approved authorize exists earlier in the list.
3. Missing quickpayPaymentId guards are inconsistent
StatusAction, SyncAction and ConfirmPaymentAction guard the missing-id case explicitly. AuthorizeAction, CaptureAction, RefundAction and CancelAction do not — (int) $model['quickpayPaymentId'] on a missing key is (int) null = 0, so you get a NotFoundException from GET /payments/0 after a network round trip instead of the clear "payment has not been created" LogicException the package already knows how to throw. AuthorizeAction even runs validateNotEmpty([...]) on four other keys but not this one.
4. The two auto-capture mechanisms overlap
When auto_capture is on, AuthorizeAction sets autoCapture: true on the payment link (Quickpay's own window-side auto-capture) andConfirmPaymentAction issues its own capture when the authorize callback arrives. If Quickpay's link-level capture works, the callback that still shows only [authorize] will trigger a second, redundant capture that Quickpay rejects (→ 500 → retry until the operations list shows the capture). It self-heals but is noisy, and the interplay is undocumented. Worth verifying live which of the two is intended; if the gateway-side capture is the deliberate one (e.g. because link auto-capture is acquirer-dependent), the link flag should probably be false.
5. Coercion sharp edges in the factory closure
(bool) (int) $config['auto_capture'] turns the string 'true' into false ((int) 'true' is 0).
(bool) $config['synchronized'] turns 'false' into true.
agreement_id => 'abc' becomes agreement id 0 rather than an error.
YAML-sourced configs that stringify booleans get silently inverted behavior. Given how carefully payment_methods rejects wrong shapes, these deserve the same strictness.
'249.99' passes is_numeric() and becomes 249. Since everything is minor units, a fractional value is always a caller bug; rejecting non-integers would catch a "sent kroner instead of øre" mistake instead of underquoting it.
7. Currency drift after creation is unreconciled
ConvertPaymentAction refreshes amount/currency in the details on every conversion, but if the currency changes after the Quickpay payment exists, the real payment keeps its original currency and nothing notices. The SDK has updatePayment(); even just throwing on a mismatch would be safer than the silent divergence. Edge case, admittedly.
Missing compared to other Payum integrations
1. Capture is not the interactive entry point — and the README's Usage example is misleading
In most Payum gateways (paypal-express, stripe, mollie bridges…), executing Capture against a fresh payment drives the whole flow, because Payum's stock capture controller — and Sylius's default checkout — executes Capture. Here Capture is strictly the money-movement call: on a fresh payment it produces a Quickpay ValidationException. That is 1.x-compatible (1.x did the same), and Authorize-as-entry is defensible for an auth/capture PSP — but nothing in the README says so, and its Usage section literally demonstrates $quickpay->execute(new Capture($model)) on a bare model.
Either document "the checkout entry point is Authorize (Sylius: use_authorize: true)" prominently, or consider having CaptureAction delegate to Authorize when there's no approved authorization yet (the common Payum pattern). At minimum this is a docs bug.
2. The header-aware GetHttpRequest action ships only as an example
payum/core's plain-PHP bridge never populates GetHttpRequest::$headers, so every non-Symfony consumer has all callbacks rejected as unsigned (400) unless they find and copy examples/e2e/HeaderAwareGetHttpRequestAction.php. That's a silent, total failure of the notify path for a whole class of consumers. The class is small and dependency-free — ship it in src/ (a Bridge/ namespace) and either register it via the core-gateway config defaults or document it in the README's installation section, not just in the upgrade guide.
3. The two-URL callback reality isn't in the README
The fact that capture/refund/cancel confirmations go to the account-wide URL — which is empty by default, so those callbacks silently vanish — lives only in docs/UPGRADE-2.0.md. That's operational knowledge every new consumer needs, not just upgraders.
4. No Sylius wiring section
Sylius shops are the stated main consumer, but the README has no "gateway config in Sylius admin + which option names to use + use_authorize" section. That would prevent the most common integration mistakes and surface the deprecated-alias story to the people it exists for.
5. Deliberate scope gaps worth stating once in the README
No card/API authorize (hosted window only — good for SAQ-A), no subscriptions/recurring (Quickpay supports them; the SDK doesn't model them yet), no payouts. Fine to omit — but saying so saves consumers a search.
Test gaps
The suite is genuinely good: offline, deterministic, asserts both Payum marks and the wire-level requests, and covers the subtle stuff (alias precedence, override consumption on failure, partial-refund status). Gaps, in order of value:
No rejected/pending-authorize callback test — the exact case behind bug Fix bug on callback when autocapture is 0 #1. Fixtures with pending: true / qp_status_code: null are entirely absent from the suite, even though async operations are the documented default.
NotifyAction::extractChecksum()'s hard-won paths are untested: lowercased header names (Symfony's HeaderBag lowercases them — that's the real production shape!), list-valued headers, non-array headers. Only the exact-case string form is exercised. Prime surviving-mutant candidates.
AuthorizeAction has one happy-path test. Untested: the no-token path (pre-set callback_url), the validateNotEmpty failure, the null-link-URL LogicException, and branding_id propagation into the link body (agreement_id is asserted; branding_id never is — precisely the "optional option silently stops being read" failure mode the factory tests guard agreement against).
No factory-wired integration test.shouldAllowCreateGateway asserts non-emptiness via reflection; nothing builds a real gateway through QuickpayGatewayFactory (with quickpay.client pointing at the mock) and runs Convert → Authorize → Notify through it. That's the test that would catch a mis-registered action or an aware-interface regression.
Smaller: StatusAction on an unmodeled state string (→ unknown), New with an empty operations list, and Infection's MSI gates at 65/70 are modest — the gaps above are likely where the surviving mutants live.
Housekeeping: PHPUnit 10 reports 21 deprecations (docblock @test/@dataProvider metadata) — relevant for the move to PHPUnit 11/12.
Progress
Each finding is being fixed in its own PR (stacked where they touch the same files). This checklist is updated as PRs open.
ConfirmPaymentActionauto-captures rejected/pending authorize → 500-retry loopStatusActiondegrades on rejected/pending trailing operationsquickpayPaymentIdguards in Authorize/Capture/Refund/Cancel'true'→ false etc.)Amountssilently truncates decimalsCaptureis not the entry point; README example misleadingHeaderAwareGetHttpRequestActioninsrc/A thorough review of the 2.x branch: architecture, robustness, gaps compared to other Payum integrations, and test coverage. Overall the gateway is in very good shape — thin actions over a typed SDK, scalar-only details contract, balance-driven status mapping, HMAC-verified callbacks. The findings below are ordered by severity; the first one is a real production bug.
Robustness
1. Bug: a declined payment with
auto_captureon puts the notify endpoint into a 500-retry loopConfirmPaymentAction::execute()gates the auto-capture onOperationType::Authorize === $latestOperation->type()— it never checks that the authorize was approved. A rejected authorize (declined card — a routine event in the payment window, and Quickpay sends a callback for it) has typeauthorizebutqp_status_code40000, soOperations::authorizedAmount()returns0, the amount comparison fails, and the action throwsLogicException. That 500s the notify endpoint, and Quickpay retries a callback that can never succeed. The same happens for a pending authorize (qp_status_codenull).The guard should be
Operations::isApprovedOfType($latestOperation, OperationType::Authorize)— rejected/pending authorizes should be a silent return; the amount mismatch (a genuine anomaly) is arguably the only case worth throwing for.There is no test for the rejected-authorize callback;
shouldThrowWhenAuthorizedAmountDoesNotMatchonly covers the approved-but-wrong-amount case.2.
StatusActiondegrades when the trailing operation is rejected or pendingThe
Processedbranch decides fromOperations::latest()alone. Capture 1000, then a rejected refund attempt → the latest operation is an unapproved refund →markUnknown(), although 1000 is demonstrably still held (balance> 0, already in hand). Similarly an async refund stillpendingflips a captured payment to unknown until the callback lands.markUnknownis at least honest, but downstream consumers (Sylius state machines) treat unknown as a dead end.Deciding from the last approved operation — or falling back to "balance > 0 → captured" — would keep the status truthful through failed/pending trailing operations. The
New-state branch has the same shape: any latest operation that is not an approved authorize ismarkFailed, even if an approved authorize exists earlier in the list.3. Missing
quickpayPaymentIdguards are inconsistentStatusAction,SyncActionandConfirmPaymentActionguard the missing-id case explicitly.AuthorizeAction,CaptureAction,RefundActionandCancelActiondo not —(int) $model['quickpayPaymentId']on a missing key is(int) null = 0, so you get aNotFoundExceptionfromGET /payments/0after a network round trip instead of the clear "payment has not been created"LogicExceptionthe package already knows how to throw.AuthorizeActioneven runsvalidateNotEmpty([...])on four other keys but not this one.4. The two auto-capture mechanisms overlap
When
auto_captureis on,AuthorizeActionsetsautoCapture: trueon the payment link (Quickpay's own window-side auto-capture) andConfirmPaymentActionissues its own capture when the authorize callback arrives. If Quickpay's link-level capture works, the callback that still shows only[authorize]will trigger a second, redundant capture that Quickpay rejects (→ 500 → retry until the operations list shows the capture). It self-heals but is noisy, and the interplay is undocumented. Worth verifying live which of the two is intended; if the gateway-side capture is the deliberate one (e.g. because link auto-capture is acquirer-dependent), the link flag should probably befalse.5. Coercion sharp edges in the factory closure
(bool) (int) $config['auto_capture']turns the string'true'into false ((int) 'true'is0).(bool) $config['synchronized']turns'false'into true.agreement_id => 'abc'becomes agreement id0rather than an error.YAML-sourced configs that stringify booleans get silently inverted behavior. Given how carefully
payment_methodsrejects wrong shapes, these deserve the same strictness.6.
Amounts::forOperation()silently truncates decimals'249.99'passesis_numeric()and becomes249. Since everything is minor units, a fractional value is always a caller bug; rejecting non-integers would catch a "sent kroner instead of øre" mistake instead of underquoting it.7. Currency drift after creation is unreconciled
ConvertPaymentActionrefreshesamount/currencyin the details on every conversion, but if the currency changes after the Quickpay payment exists, the real payment keeps its original currency and nothing notices. The SDK hasupdatePayment(); even just throwing on a mismatch would be safer than the silent divergence. Edge case, admittedly.Missing compared to other Payum integrations
1.
Captureis not the interactive entry point — and the README's Usage example is misleadingIn most Payum gateways (paypal-express, stripe, mollie bridges…), executing
Captureagainst a fresh payment drives the whole flow, because Payum's stock capture controller — and Sylius's default checkout — executesCapture. HereCaptureis strictly the money-movement call: on a fresh payment it produces a QuickpayValidationException. That is 1.x-compatible (1.x did the same), and Authorize-as-entry is defensible for an auth/capture PSP — but nothing in the README says so, and its Usage section literally demonstrates$quickpay->execute(new Capture($model))on a bare model.Either document "the checkout entry point is
Authorize(Sylius:use_authorize: true)" prominently, or consider havingCaptureActiondelegate toAuthorizewhen there's no approved authorization yet (the common Payum pattern). At minimum this is a docs bug.2. The header-aware
GetHttpRequestaction ships only as an examplepayum/core's plain-PHP bridge never populates
GetHttpRequest::$headers, so every non-Symfony consumer has all callbacks rejected as unsigned (400) unless they find and copyexamples/e2e/HeaderAwareGetHttpRequestAction.php. That's a silent, total failure of the notify path for a whole class of consumers. The class is small and dependency-free — ship it insrc/(aBridge/namespace) and either register it via the core-gateway config defaults or document it in the README's installation section, not just in the upgrade guide.3. The two-URL callback reality isn't in the README
The fact that capture/refund/cancel confirmations go to the account-wide URL — which is empty by default, so those callbacks silently vanish — lives only in
docs/UPGRADE-2.0.md. That's operational knowledge every new consumer needs, not just upgraders.4. No Sylius wiring section
Sylius shops are the stated main consumer, but the README has no "gateway config in Sylius admin + which option names to use +
use_authorize" section. That would prevent the most common integration mistakes and surface the deprecated-alias story to the people it exists for.5. Deliberate scope gaps worth stating once in the README
No card/API authorize (hosted window only — good for SAQ-A), no subscriptions/recurring (Quickpay supports them; the SDK doesn't model them yet), no payouts. Fine to omit — but saying so saves consumers a search.
Test gaps
The suite is genuinely good: offline, deterministic, asserts both Payum marks and the wire-level requests, and covers the subtle stuff (alias precedence, override consumption on failure, partial-refund status). Gaps, in order of value:
pending: true/qp_status_code: nullare entirely absent from the suite, even though async operations are the documented default.NotifyAction::extractChecksum()'s hard-won paths are untested: lowercased header names (Symfony's HeaderBag lowercases them — that's the real production shape!), list-valued headers, non-arrayheaders. Only the exact-case string form is exercised. Prime surviving-mutant candidates.AuthorizeActionhas one happy-path test. Untested: the no-token path (pre-setcallback_url), thevalidateNotEmptyfailure, the null-link-URLLogicException, andbranding_idpropagation into the link body (agreement_idis asserted;branding_idnever is — precisely the "optional option silently stops being read" failure mode the factory tests guardagreementagainst).shouldAllowCreateGatewayasserts non-emptiness via reflection; nothing builds a real gateway throughQuickpayGatewayFactory(withquickpay.clientpointing at the mock) and runs Convert → Authorize → Notify through it. That's the test that would catch a mis-registered action or an aware-interface regression.StatusActionon an unmodeled state string (→ unknown),Newwith an empty operations list, and Infection's MSI gates at 65/70 are modest — the gaps above are likely where the surviving mutants live.@test/@dataProvidermetadata) — relevant for the move to PHPUnit 11/12.Suggested priorities
ConfirmPaymentActionapproved-authorize guard (robustness Fix bug on callback when autocapture is 0 #1) with the rejected-callback test.quickpayPaymentIdguards (robustness Payum\Core\Security\GenericTokenFactoryInterface is deprecated #3).HeaderAwareGetHttpRequestActioninto the package (gaps added types, static analysis, code style analysis #2).Captureexample and add a "checkout entry point is Authorize" section (gaps Fix bug on callback when autocapture is 0 #1).StatusActionlast-approved-operation change (robustness added types, static analysis, code style analysis #2) — it changes observable status behavior.