Describe the Bug
When a PayPal payment attempt is declined and the customer then completes the same order with a different payment gateway, PayPal Payments forces the now-paid order into failed — permanently and unrecoverably from the admin UI.
Two defects combine:
Defect A — a paid order is forced to failed, in a loop.
A declined PayPal attempt leaves _ppcp_paypal_intent = AUTHORIZE on the order. No PayPal authorization was ever created. The customer subsequently pays with another gateway, that gateway captures successfully and calls payment_complete(), moving the order to processing. The woocommerce_order_status_changed listener in modules/ppcp-wc-gateway/src/WCGatewayModule.php (~lines 257–296) then sees intent AUTHORIZE and not-captured, attempts capture_authorized_payment(), fails (there is nothing to capture), and calls:
$wc_order->update_status('failed', __('Could not capture the payment.', 'woocommerce-paypal-payments')); // line 296
Because the listener fires on every transition into processing or completed, a merchant cannot recover the order from wp-admin — each manual attempt flips straight back to failed and re-sends the customer the "Processing order" email. In our case the customer received four order confirmations for one purchase.
Two aggravating details in that block:
- A caught
Throwable falls through to the same update_status('failed') as a clean false, so transient API/network errors are indistinguishable from genuine declines.
- There is no check that PayPal is the gateway that is actually settling this order in the current lifecycle, and no check that the order has already been paid by someone else.
Defect B — payment_method is permanently locked to ppcp-gateway.
modules/ppcp-wc-gateway/src/WCGatewayModule.php (~lines 678–692), on woocommerce_before_order_object_save:
if (!$order->get_meta(PayPalGateway::ORDER_ID_META_KEY)) return;
$payment_method = $order->get_payment_method();
if ($payment_method && strpos($payment_method, 'ppcp-') === 0) return;
if ($order->get_payment_method() === CreditCardGateway::ID) return;
$order->set_payment_method(PayPalGateway::ID);
While _ppcp_paypal_order_id exists on an order, any save reassigns payment_method back to ppcp-gateway, regardless of which gateway actually took the money. payment_method_title is not touched, so the admin order screen displays the real gateway's title while the stored method says PayPal. $order->set_payment_method('some_other_gateway'); $order->save(); silently does nothing.
Consequences beyond the failed status: the order is attributed to the wrong processor in gateway reporting, and a refund issued from WooCommerce is routed to PayPal rather than the gateway that actually holds the funds.
To Reproduce
- Set PayPal Payments to Intent: Authorize with Capture on status change: enabled (
woocommerce-ppcp-settings → "intent":"authorize", "capture_on_status_change":true).
- Have a second gateway enabled at checkout (in our case WooCommerce Gateway Affirm 3.0.8; any second gateway that calls
payment_complete() should reproduce it).
- As a customer, place an order and choose PayPal. Use an instrument that PayPal declines — we received
INSTRUMENT_DECLINED ("The instrument presented was either declined by the processor or bank, or it can't be used for this payment"). The order goes pending → failed. The order now carries _ppcp_paypal_intent = AUTHORIZE and _ppcp_paypal_order_id, with no authorization behind them.
- On the same order, pay again using the second gateway. It captures successfully and calls
payment_complete().
- Observe the order move to
processing — and then immediately back to failed with the note "Could not capture the payment. Order status changed from Processing to Failed."
- As an admin, set the order to Processing in wp-admin. It flips back to
failed again, and the customer is emailed again. Repeats indefinitely.
- Additionally: attempt
$order->set_payment_method('<other_gateway_id>'); $order->save(); — the value silently reverts to ppcp-gateway (Defect B).
Screenshots
Not applicable — the evidence is in the order notes and gateway logs. Order note sequence from the affected order (reverse chronological, all within the same minute):
Could not capture the payment. Order status changed from Processing to Failed.
Email "Failed order" sent.
Email "Processing order" sent.
Email "New order" sent.
Payment via <second gateway> (<charge id redacted>).
Captured charge of $4,300.00 (charge ID <redacted> / event ID <redacted>)
Second gateway's own log for the same order, showing the capture unambiguously succeeded:
00:43:03 Notice handleWcApi - Start redirect for <gateway> Auth
00:43:03 Notice handleWcApi - Processing payment for order <id> with checkout token <redacted>
00:43:04 Notice handleWcApi - Received charge id <redacted> for order <id>.
00:43:04 Notice capture_charge - Start capture
00:43:10 Notice capture_charge - Info: Successfully captured 430000 for order <id>
No PayPal capture attempt is logged at the moment of the flip, and none is logged on subsequent manual status changes — the failure occurs before any API call, because there is no authorization to capture.
Expected Behavior
The auto-capture listener should not fail an order that PayPal is not settling. Specifically:
- Verify a PayPal authorization actually exists before concluding that capture failed. A declined attempt that never produced an authorization is not a capture failure.
- Do not override an order that is already paid by another gateway. A check on the order's current payment method, or on whether the order is already paid via a different processor, would prevent this entirely.
- Distinguish a caught
Throwable from a legitimate decline — a transient API error should not silently mark a paid order failed.
- Clean up
_ppcp_paypal_intent (and ideally _ppcp_paypal_order_id) when a PayPal attempt is declined, so stale state cannot arm this later.
- For Defect B: the
woocommerce_before_order_object_save reassignment should not apply once another gateway has taken payment, and it should not make payment_method unwritable through the public WC_Order API. At minimum it should be filterable.
Actual Behavior
A fully paid order is forced to failed on every entry into processing/completed, with no way to recover it through wp-admin. The customer is re-emailed an order confirmation on each attempt. The stored payment_method remains ppcp-gateway regardless of which gateway holds the funds, so refunds would route to the wrong processor and gateway revenue reporting is wrong.
In our case a $4,300 course enrolment sat in failed while the money was captured and settled at the other processor. Downstream automation (Zapier → CRM → student enrolment) keyed on order status, so the student was not enrolled despite having paid. Recovery required deleting _ppcp_paypal_intent directly via WP-CLI.
Environment
- WordPress Version:
- WooCommerce Version: 11.0.1 (HPOS enabled, data sync disabled)
- Plugin Version: 4.1.2
- Browser and Version: not browser-dependent — reproduced server-side and via WP-CLI
- Any other plugins installed: WooCommerce Gateway Affirm 3.0.8 (the gateway that actually took payment), FunnelKit Funnel Builder Pro (custom checkout), FunnelKit Stripe, WooCommerce Zapier (Tectalic). ~61 active plugins total. PHP 8.1.34, MariaDB 10.6.19.
Additional Details
Order meta at the time of the failure (customer-identifying values redacted):
payment_method: ppcp-gateway <- despite the other gateway taking the money
_ppcp_paypal_intent = AUTHORIZE <- stale, from the declined attempt
_ppcp_paypal_order_id = <redacted>
_ppcp_paypal_payer_email = <redacted>
_ppcp_paypal_payment_source = paypal
_wc_gateway_affirm_authorized_amount = 430000
_wc_gateway_affirm_captured_total = 430000 <- $4,300 captured by the other gateway
Deleting _ppcp_paypal_intent alone stops the failure loop — confirming the stale meta is the trigger. payment_method could not be corrected in the same operation because of Defect B; it reverts on save while _ppcp_paypal_order_id is present.
Control case: a separate order paid through the same second gateway on the same site, by a customer who never started a PayPal attempt, completed normally and stayed in processing. The only difference is the presence of the stale PayPal meta.
Scope on our site: a scan of every failed order found exactly one affected order, so this needs the specific sequence (PayPal decline → retry on another gateway on the same order) rather than being a common path. That makes it rare, but the failure mode is silent and involves captured money, so the impact per occurrence is high.
Related report: [woocommerce/woocommerce#66234](https://github.com/woocommerce/woocommerce/issues/66234) — "Inconsistency regarding the payment method in an order" (open, no maintainer response), reported against PayPal Payments 4.0.4. That report describes Defect B only, and was filed against the WooCommerce core repository rather than this plugin, which may be why it has not been triaged. Defect A does not appear to have been reported anywhere.
Suggested minimal fix for Defect A: in the woocommerce_order_status_changed listener, bail out before update_status('failed') when the order carries payment/capture metadata from a different gateway, or when no PayPal authorization can be retrieved for the order. The existing woocommerce_paypal_payments_auto_capture_statuses filter allows merchants to work around this locally, but the default behaviour should not fail an order that another gateway has already settled.
System status
<!-- Paste WooCommerce → Status → Get system report here, with identifying info redacted. -->
Describe the Bug
When a PayPal payment attempt is declined and the customer then completes the same order with a different payment gateway, PayPal Payments forces the now-paid order into
failed— permanently and unrecoverably from the admin UI.Two defects combine:
Defect A — a paid order is forced to
failed, in a loop.A declined PayPal attempt leaves
_ppcp_paypal_intent = AUTHORIZEon the order. No PayPal authorization was ever created. The customer subsequently pays with another gateway, that gateway captures successfully and callspayment_complete(), moving the order toprocessing. Thewoocommerce_order_status_changedlistener inmodules/ppcp-wc-gateway/src/WCGatewayModule.php(~lines 257–296) then sees intentAUTHORIZEand not-captured, attemptscapture_authorized_payment(), fails (there is nothing to capture), and calls:Because the listener fires on every transition into
processingorcompleted, a merchant cannot recover the order from wp-admin — each manual attempt flips straight back tofailedand re-sends the customer the "Processing order" email. In our case the customer received four order confirmations for one purchase.Two aggravating details in that block:
Throwablefalls through to the sameupdate_status('failed')as a cleanfalse, so transient API/network errors are indistinguishable from genuine declines.Defect B —
payment_methodis permanently locked toppcp-gateway.modules/ppcp-wc-gateway/src/WCGatewayModule.php(~lines 678–692), onwoocommerce_before_order_object_save:While
_ppcp_paypal_order_idexists on an order, any save reassignspayment_methodback toppcp-gateway, regardless of which gateway actually took the money.payment_method_titleis not touched, so the admin order screen displays the real gateway's title while the stored method says PayPal.$order->set_payment_method('some_other_gateway'); $order->save();silently does nothing.Consequences beyond the failed status: the order is attributed to the wrong processor in gateway reporting, and a refund issued from WooCommerce is routed to PayPal rather than the gateway that actually holds the funds.
To Reproduce
woocommerce-ppcp-settings→"intent":"authorize","capture_on_status_change":true).payment_complete()should reproduce it).INSTRUMENT_DECLINED("The instrument presented was either declined by the processor or bank, or it can't be used for this payment"). The order goespending→failed. The order now carries_ppcp_paypal_intent = AUTHORIZEand_ppcp_paypal_order_id, with no authorization behind them.payment_complete().processing— and then immediately back tofailedwith the note "Could not capture the payment. Order status changed from Processing to Failed."failedagain, and the customer is emailed again. Repeats indefinitely.$order->set_payment_method('<other_gateway_id>'); $order->save();— the value silently reverts toppcp-gateway(Defect B).Screenshots
Not applicable — the evidence is in the order notes and gateway logs. Order note sequence from the affected order (reverse chronological, all within the same minute):
Second gateway's own log for the same order, showing the capture unambiguously succeeded:
No PayPal capture attempt is logged at the moment of the flip, and none is logged on subsequent manual status changes — the failure occurs before any API call, because there is no authorization to capture.
Expected Behavior
The auto-capture listener should not fail an order that PayPal is not settling. Specifically:
Throwablefrom a legitimate decline — a transient API error should not silently mark a paid orderfailed._ppcp_paypal_intent(and ideally_ppcp_paypal_order_id) when a PayPal attempt is declined, so stale state cannot arm this later.woocommerce_before_order_object_savereassignment should not apply once another gateway has taken payment, and it should not makepayment_methodunwritable through the publicWC_OrderAPI. At minimum it should be filterable.Actual Behavior
A fully paid order is forced to
failedon every entry intoprocessing/completed, with no way to recover it through wp-admin. The customer is re-emailed an order confirmation on each attempt. The storedpayment_methodremainsppcp-gatewayregardless of which gateway holds the funds, so refunds would route to the wrong processor and gateway revenue reporting is wrong.In our case a $4,300 course enrolment sat in
failedwhile the money was captured and settled at the other processor. Downstream automation (Zapier → CRM → student enrolment) keyed on order status, so the student was not enrolled despite having paid. Recovery required deleting_ppcp_paypal_intentdirectly via WP-CLI.Environment
Additional Details
Order meta at the time of the failure (customer-identifying values redacted):
Deleting
_ppcp_paypal_intentalone stops the failure loop — confirming the stale meta is the trigger.payment_methodcould not be corrected in the same operation because of Defect B; it reverts on save while_ppcp_paypal_order_idis present.Control case: a separate order paid through the same second gateway on the same site, by a customer who never started a PayPal attempt, completed normally and stayed in
processing. The only difference is the presence of the stale PayPal meta.Scope on our site: a scan of every failed order found exactly one affected order, so this needs the specific sequence (PayPal decline → retry on another gateway on the same order) rather than being a common path. That makes it rare, but the failure mode is silent and involves captured money, so the impact per occurrence is high.
Related report: [woocommerce/woocommerce#66234](https://github.com/woocommerce/woocommerce/issues/66234) — "Inconsistency regarding the payment method in an order" (open, no maintainer response), reported against PayPal Payments 4.0.4. That report describes Defect B only, and was filed against the WooCommerce core repository rather than this plugin, which may be why it has not been triaged. Defect A does not appear to have been reported anywhere.
Suggested minimal fix for Defect A: in the
woocommerce_order_status_changedlistener, bail out beforeupdate_status('failed')when the order carries payment/capture metadata from a different gateway, or when no PayPal authorization can be retrieved for the order. The existingwoocommerce_paypal_payments_auto_capture_statusesfilter allows merchants to work around this locally, but the default behaviour should not fail an order that another gateway has already settled.System status