Skip to content

Stale _ppcp_paypal_intent=AUTHORIZE after a declined attempt forces an order paid by another gateway into a permanent failed loop #4623

Description

@rkanka21

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

  1. Set PayPal Payments to Intent: Authorize with Capture on status change: enabled (woocommerce-ppcp-settings"intent":"authorize", "capture_on_status_change":true).
  2. 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).
  3. 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 pendingfailed. The order now carries _ppcp_paypal_intent = AUTHORIZE and _ppcp_paypal_order_id, with no authorization behind them.
  4. On the same order, pay again using the second gateway. It captures successfully and calls payment_complete().
  5. 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."
  6. 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.
  7. 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:

  1. Verify a PayPal authorization actually exists before concluding that capture failed. A declined attempt that never produced an authorization is not a capture failure.
  2. 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.
  3. Distinguish a caught Throwable from a legitimate decline — a transient API error should not silently mark a paid order failed.
  4. Clean up _ppcp_paypal_intent (and ideally _ppcp_paypal_order_id) when a PayPal attempt is declined, so stale state cannot arm this later.
  5. 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. -->

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions