Description

When canceling an offsite payment (e.g., PayPal, 2C2P), the PaymentCheckoutController::cancelPage() method retrieves the order entity from the route parameters. This order may be stale, especially if a previous cancel or payment notification request has already updated the order.

This issue becomes more likely when:

  • Multiple cancel requests are triggered within a short time (e.g., user clicking "Back" multiple times)

The controller then passes this potentially outdated $order into:

$payment_gateway_plugin->onCancel($order, $request)

$checkout_flow_plugin->redirectToStep($previous_step_id)

Although cancelPage() itself does not modify or save the order, these plugin methods often do. If the $order object is stale, saving it will trigger the following exception:
Drupal\commerce_order\Exception\OrderVersionMismatchException: Attempted to save order 1234 with version 8. Current version is 10

Steps To Reproduce

  1. Configure an offsite payment gateway that supports both redirect and server-side notification (e.g., 2C2P, PayPal).
  2. Begin the checkout process and proceed to the payment gateway.
  3. Cancel the payment in a way that triggers multiple browser-based cancel requests (e.g., double-clicking back or refreshing).
  4. Observe that one request updates the order successfully.
  5. Subsequent requests fail with OrderVersionMismatchException when plugins attempt to save the outdated order.

Proposed Solution

Reload the order using loadForUpdate() inside cancelPage(), before passing it to plugins. Also, update the route parameters with the reloaded order, since the checkout flow and payment plugins rely on the route-matched version.

Issue fork commerce-3539545

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

mohammad-fayoumi created an issue. See original summary.

mohammad-fayoumi’s picture

Status: Active » Needs review
jsacksick’s picture

Version: 8.x-2.x-dev » 3.x-dev

Ok so 8.x-2.x is no longer maintained, we just need an MR for 3.x.
Additionally, the checkout flow plugin now has a setOrder() method that we could use here without the need to interfere with the route match I believe, unless some other code is relying on it?

So we could update the code to look like the following:

$checkout_flow_plugin = $checkout_flow->getPlugin();
$checkout_flow_plugin->setOrder($order);

Technically the payment plugins shouldn't rely on the route match parameters from the onCancel() method as the order is passed there?

jsacksick’s picture

Issue tags: -mismatch

I did implement the change proposed, See https://git.drupalcode.org/project/commerce/-/merge_requests/485/diffs?c....

Since we're making this change in cancelPage(), I wonder if we should do the same from returnPage() (i.e. set the order on the checkout flow plugin rather than messing with the route match parameters but a bit more hesitant to change this).

jsacksick’s picture

The change from the commit linked in #8 introduced PHPSTAN issues. Slightly adapted the change, See: https://git.drupalcode.org/project/commerce/-/merge_requests/485/diffs?c...

jsacksick’s picture

I actually decided to rewrite returnPage() as well for consistency.
Plugins using the route match rather than the passed order should be rewritten. But I don't believe this is the case, I think the OrderVersionMismatchException was thrown due to the checkout step redirection login from the checkout flow plugin which was always using the order from the route match. Using the setter, the order is set afresh after loading it via loadForUpdate().

jsacksick’s picture

Status: Needs review » Fixed

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.