[PIWOO-841] Prevent duplicate processing for orders with final status or already paid in Mollie WebhookHandler#1182
Open
Biont wants to merge 1 commit intodev/developfrom
Open
Conversation
…paid in Mollie WebhookHandler
danielhuesken
approved these changes
Apr 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1159
Problem
When a customer makes multiple payment attempts on the same order (e.g. returning to the order-pay URL after abandoning a first attempt), an expired-payment webhook can cancel an order that has already been successfully paid.
onWebhookExpiredhas two guards meant to prevent this:!$order->needs_payment(): skips processing if WooCommerce considers the order paid$molliePaymentId !== $payment->id: skips processing if a newer transaction is on recordBoth can be bypassed in practice:
Scenario A (primary — sequential): A new payment attempt resets the order to
pending. The customer completes that payment and the order moves toprocessing. If Mollie subsequently delivers an expired webhook for that same transaction (reproducible via test-mode dashboard), Guard 1 can still returntrueand Guard 2 passes because the IDs match — the handler proceeds to cancel a paid order. Reported at ~5% frequency on affected installations.Scenario B (secondary — race condition): Two webhooks for the same order arrive in close succession (paid + expired). If the expired webhook's PHP process reads the order before the paid webhook's process has committed
payment_complete()and the_mollie_paid_and_processedflag to the database, both guards see apendingorder and proceed to cancel it.A related structural gap:
onWebhookCanceledalready has anisFinalOrderStatusguard;onWebhookExpiredhad no equivalent.Solution
Two additions at the top of
onWebhookExpired, before the existing guards:1.
isFinalOrderStatuscheck — mirrors the guard already present inonWebhookCanceled. Returns early forcompleted,refunded, andcanceledorders. This was simply missing from the expired path.2. Strengthened
$alreadyPaidcheck — replaces the bareneeds_payment()call with a three-way condition:!$order->needs_payment()— the original guard, retained=== 'processing'— explicit status check independent of WC'sneeds_payment()filter chain;processingis not inFINAL_STATUSESso the first guard above doesn't cover it_mollie_paid_and_processed— set by the paid-webhook handler immediately afterpayment_complete(), before the order note and subscription logic; checking it narrows the race window for Scenario BRationale & known limitations
The
isFinalOrderStatusaddition is a straightforward parity fix withonWebhookCanceled. The$alreadyPaidexpansion is defensive layering: each condition catches a slightly different failure mode and together they make the "already paid" check robust against both sequential and concurrent webhook delivery.Scenario B is not fully eliminated. The race condition is a TOCTOU problem across two PHP processes with no shared in-process state. WooCommerce has no public API for a guaranteed cache-busting DB re-read that is also safe under HPOS. The only complete fix would be either a transient-based per-order mutex (serialising concurrent webhooks) or a Mollie API call immediately before the destructive write to verify payment status on Mollie's authoritative side. Both are intentionally deferred: they touch a broader surface, carry their own failure modes, and belong in a separate focused change. The
_mollie_paid_and_processedcheck meaningfully shrinks the race window and is a safe interim improvement.