Skip to content

Refuse to queue a capture/refund/cancel while one is still in flight - #80

Merged
loevgaard merged 1 commit into
2.xfrom
fix/pending-operation-guard
Aug 17, 2026
Merged

loevgaard merged 1 commit into
2.xfrom
fix/pending-operation-guard

Conversation

@loevgaard

Copy link
Copy Markdown
Member

Implements R3 of #71 — flagged there as a policy call, so this is the proposal in mergeable form; say no and it goes away. Stacked on #73 (which is stacked on #72); retarget to 2.x before merging its base.

What

Operations run asynchronously by default: Quickpay queues them and answers before the acquirer has, and until the callback (or a Sync/GetStatus) reports the outcome the payment's balance/state are the pre-operation ones. A second operation issued meanwhile races the first — a Capture retried after a timeout takes the money twice, a Refund refunds the stale balance again, a Cancel races a capture. AuthorizeAction already refuses to act on a pending capture; the money operations did not look.

Now CaptureAction (plain-link path), RefundAction and CancelAction fetch the payment first and, while a capture, refund or cancel is pending, throw Exception\OperationPendingException (a Payum RuntimeException with getPaymentId() and the pending getOperation() — the sibling of #72's OperationRejectedException) before anything is sent; a capture_amount/refund_amount instruction survives. Rule: one money operation at a time on a payment. synchronized has no in-flight window and is unaffected.

The trade-offs (why it's a policy call)

  • Cancel and explicit-amount Refund gain a GET they did not make before (Capture and default Refund already fetched). The README/UPGRADE promise "an explicit refund_amount skips the fetch entirely" is reversed; the balance the fetch brings is written into the details like everywhere else.
  • A second instalment issued within seconds of the first has to wait for it to settle. In practice Quickpay settles in seconds and instalments are days apart, but it is a new refusal.
  • It closes the sequential retry window only — two truly concurrent requests both fetch before either posts. That is inherent without idempotency keys, which Quickpay's API does not have.

The zero-cost alternative — guard only where the payment is already in hand (Capture, default-path Refund) — is inconsistent to document ("a pending refund is refused… unless you passed refund_amount"), so I went for the consistent rule and put the cost on the table.

Tests / docs

Guard tests for all three actions (pending capture / refund / cancel), the existing Cancel and explicit-Refund tests updated for fetch-first; README (partial operations), docs/UPGRADE-2.0.md (new section), CLAUDE.md. composer all and the dependency analyser green (335 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.52%. Comparing base (4728461) to head (fecbcb5).

Additional details and impacted files
@@             Coverage Diff              @@
##                2.x      #80      +/-   ##
============================================
+ Coverage     98.44%   98.52%   +0.07%     
- Complexity      208      215       +7     
============================================
  Files            19       20       +1     
  Lines           516      542      +26     
============================================
+ Hits            508      534      +26     
  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.

Operations run asynchronously by default: Quickpay queues them and
answers before the acquirer has, and until the callback (or a
Sync/GetStatus) reports the outcome the payment's balance and state are
the pre-operation ones. A second operation issued meanwhile races the
first — a Capture retried after a timeout takes the money twice, a
Refund refunds the stale balance again, a Cancel races a capture.
AuthorizeAction already refused to act on a pending capture; the money
operations did not look.

CaptureAction (plain-link path), RefundAction and CancelAction now
fetch the payment first — Cancel and an explicit-amount Refund did not
before, one GET each — and OperationPendingException::assertNoneInFlight()
throws while a capture, refund or cancel is pending, before anything is
sent, so a capture_amount/refund_amount instruction survives. The
exception is a Payum RuntimeException carrying the payment id and the
pending operation, the sibling of OperationRejectedException. The
`synchronized` option has no in-flight window and is unaffected.

Refs #71 (R3).
Base automatically changed from fix/rejected-auto-capture to 2.x August 17, 2026 11:07
@loevgaard
loevgaard force-pushed the fix/pending-operation-guard branch from bcf698e to fecbcb5 Compare August 17, 2026 11:07
@loevgaard
loevgaard merged commit 2081039 into 2.x Aug 17, 2026
40 checks passed
@loevgaard
loevgaard deleted the fix/pending-operation-guard branch August 17, 2026 11:10
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