Skip to content

Let a programmatic Capture settle a payment whose Quickpay-side auto-capture was declined - #73

Merged
loevgaard merged 1 commit into
2.xfrom
fix/rejected-auto-capture
Aug 17, 2026
Merged

loevgaard merged 1 commit into
2.xfrom
fix/rejected-auto-capture

Conversation

@loevgaard

Copy link
Copy Markdown
Member

Fixes R1 of #71. Stacked on #72 (shares Operations::latestOfType()) — retarget to 2.x before merging #72 so GitHub does not auto-close it when that branch is deleted.

The problem

On a link with auto_capture, CaptureAction returned unconditionally once the payment was authorized: Quickpay captures, so a capture from here could only double up. That also covered the case where Quickpay's own capture was declined by the acquirer (test card 1000 0000 0000 0032, "capture rejected"): Quickpay attempts it once and does not retry, the payment reports authorized, and every Capture from then on — with or without capture_amount — was a silent no-op. Nothing short of the Quickpay manager could ever take the money. Confirmed with the mock seam: authorize 20000 + capture 40000 + link.auto_capture: true → Capture issued nothing.

Sharpest consequence: setono/sylius-quickpay-plugin's PaymentProcessor runs GetStatus (not captured) → Capture → no exception → the Sylius payment completes with no money captured.

The rule now

On an auto-capture link, Capture stays a no-op while

  • a capture is approved or pending (Quickpay did / is doing it), or
  • no capture is recorded yet (the moment right after authorization, before Quickpay has recorded the one it is already making), or
  • the request carries a token — the return trip. The customer must land somewhere, and the shop sees authorized, which is the truth; the return trip never moves money.

When the newest capture is a completed decline and the Capture is programmatic (no token — the merchant settling: the shop's own code, a state-machine hook), it captures through the API like on a plain link, capture_amount included. A decline after an approved capture (an over-capture attempted elsewhere) is not Quickpay giving up and stays a no-op.

The token distinction is Payum's own: its controllers execute token-carrying requests, programmatic callers pass the details. It keeps "the return trip never captures" intact — the invariant #69 established — while giving the merchant a way to settle. The alternative (always fall through) would have re-issued a capture on the return trip and, if state goes pending for an async op (R4 in #71), parked a Sylius payment in processing until a reconcile.

Live check before merging

composer e2e:create, pay with 1000 0000 0000 0032, confirm the return trip logs done and status is authorized; then composer e2e:operate -- capture <number> — expect one capture issued by the gateway (accepted or declined by the acquirer, but issued), where before it was a silent no-op. Please also note whether Quickpay records its declined attempt as an operation with pending: false and a non-20000 qp_status_code — that is the shape the tests assume.

Docs

README (flow section), docs/UPGRADE-2.0.md, CLAUDE.md, class docblock.

Checks

composer all and the dependency analyser green locally; 328 tests.

@codecov

codecov Bot commented Aug 17, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.42%. Comparing base (fbfaa0a) to head (fc4976d).

Additional details and impacted files
@@             Coverage Diff              @@
##                2.x      #73      +/-   ##
============================================
+ Coverage     98.40%   98.42%   +0.02%     
- Complexity      199      204       +5     
============================================
  Files            19       19              
  Lines           501      509       +8     
============================================
+ Hits            493      501       +8     
  Misses            8        8              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@loevgaard
loevgaard changed the base branch from fix/synchronized-rejected-operations to 2.x August 17, 2026 09:39
@loevgaard
loevgaard force-pushed the fix/rejected-auto-capture branch from 0d477cd to 5180cfb Compare August 17, 2026 09:39
…capture was declined

On a link with auto_capture, CaptureAction returned unconditionally once
the payment was authorized: Quickpay captures, so a capture from here
could only double up. That also covered the case where Quickpay's own
capture had been DECLINED by the acquirer (test card 1000 0000 0000
0032): Quickpay attempts it once and does not retry, the payment reports
`authorized`, and every Capture from then on — with or without
capture_amount — was a silent no-op. Nothing short of the Quickpay
manager could ever take the money, and a caller completing on "no
exception" (the Sylius plugin's complete transition) completed a payment
that captured nothing.

Now the no-op holds while a capture is approved or pending, or none is
recorded yet (the moment right after authorization), or on the return
trip: a token-carrying Capture still moves no money, the customer lands
on the after url with the payment `authorized`, which is the truth. A
programmatic Capture (no token — the merchant settling) with the newest
capture a completed decline captures through the API like on a plain
link, capture_amount included. A decline after an approved capture (an
over-capture attempted elsewhere) is not Quickpay giving up and stays a
no-op.

Refs #71 (R1).
@loevgaard

Copy link
Copy Markdown
Member Author

Verified live (2026-08-17, test card 1000 0000 0000 0032, Capture flow through the e2e harness behind a tunnel):

  • After paying, Quickpay records exactly the shape the tests assume — authorize 20000, then its own capture 40000 "Rejected test operation", pending: false — state new, balance 0, link.auto_capture: true. Both callbacks arrived and verified (CALLBACK OK via=token payum_status=authorized), the return trip logged done and landed on "Payment authorized".
  • On 2.x (payment 612102024): composer e2e:operate -- capture printed "requested" and issued nothing — still 2 operations. The silent no-op this PR fixes.
  • On this branch, same payment: Capture issued a real capture — op#3 appears at Quickpay. (Quickpay declined it again — that is what this test card does — and Throw OperationRejectedException when a synchronized capture/refund/cancel is declined #72's OperationRejectedException surfaced it, on the asynchronous path even: Quickpay answered with the operation already completed and declined. operate.php only caught QuickpayException, so it showed as a fatal; separate tiny PR incoming.)
  • Return trip on this branch (second payment, 612102632, listener running this code): TOKEN URL /capture → done, still 2 operations — the token-carrying Capture moved no money, as designed.

Nothing left to verify here from my side.

@loevgaard
loevgaard merged commit 4728461 into 2.x Aug 17, 2026
21 checks passed
@loevgaard
loevgaard deleted the fix/rejected-auto-capture branch August 17, 2026 11:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant