Make sure the price of a locked order is never updated especially when a promotion expires.

Created on 7 January 2025, 4 months ago

Hello,

With 3D secure verifications, offsite payments such as Paypal, or long offsite checkout flows like Stripe Checkout, customers can be away from the site for some time with their order locked. By the time they come back, a promotion can have expired or a new one can be active.

As of right now, you can witness that expiring a promotion while waiting for the customer to confirm/finish an offsite payment will result in the order's total amount being modified and not corresponding to the amount that really has been paid anymore. This even though the order was locked.

We should make sure that the price of a locked order never changes.

To ensure this, I believe the following functions should be updated to do nothing if the order is locked or not 'draft' anymore:
Order::recalculateTotalPrice
OrderRefresh::refresh
PromotionOrderProcessor::process
There might be other functions that need reinforcement, these are the only ones I found needed to be secured so far.

If you face this problem too and would like to fix this while this is not officially included yet, here is how I've implemented this on our site with no need for a patch:

I've created 3 custom classes:

class MyModuleOrder extends Order {
  public function recalculateTotalPrice() {
    if ($this->isLocked() || $this->getState()->value !== 'draft') {
      return $this;
    }
    return parent::recalculateTotalPrice();
  }
}

class MyModuleOrderRefresh extends OrderRefresh {
  public function refresh(OrderInterface $order) {
    if ($order->isLocked() || $order->getState()->value !== 'draft') {
      return;
    }
    parent::refresh($order);
  }
}

class MyModulePromotionOrderProcessor extends PromotionOrderProcessor {
  public function process(OrderInterface $order) {
    if ($order->isLocked() || $order->getState()->value !== 'draft') {
      return;
    }
    parent::process($order);
  }
}

In order to override Commerce's default classes with mine, this is what I did:
For Order it's pretty simple:

function my_module_entity_type_alter(array &$entity_types) {
  if (isset($entity_types['commerce_order'])) {
    $entity_types['commerce_order']->setClass(MyModuleOrder::class);
  }
}

For OrderRefresh and PromotionOrderProcessor, because they are Services, we need to use a custom ServiceProvider that extends ServiceProviderBase as decribred here:
https://www.drupal.org/docs/drupal-apis/services-and-dependency-injectio...
Be aware to name it exactly in PascalCase MyModuleNameServiceProvider and place it in my_module/src/ so that it is detected:

class MyModuleNameServiceProvider extends ServiceProviderBase {
  public function alter(ContainerBuilder $container) {
    $order_refresh = $container->getDefinition('commerce_order.order_refresh');
    $order_refresh->setClass(MyModuleOrderRefresh::class);

    $promotion_order_processor = $container->getDefinition('commerce_promotion.promotion_order_processor');
    $promotion_order_processor->setClass(MyModulePromotionOrderProcessor::class);
  }
}
🐛 Bug report
Status

Active

Version

2.40

Component

Order

Created by

🇫🇷France nicolas bouteille

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Comments & Activities

  • Issue created by @nicolas bouteille
  • 🇮🇱Israel jsacksick

    Skipping the total recalculation for non draft orders is not the way to go... On several projects I'm working on, the totals / taxes / promotions are reapplied in case an order was "under delivered". We are simply skipping the price refresh / recalculation. But the order total should always reflect the order items total + adjustments, no matter what the state of the order is.

  • 🇫🇷France nicolas bouteille

    Maybe "non draft" is too wide and should be replaced by "is completed or canceled". But the point is the order's items price should not be recalculated on an order which payment has already been initialized or processed. I suggest you try out the scenario I described:

    As of right now, you can witness that expiring a promotion while waiting for the customer to confirm/finish an offsite payment will result in the order's total amount being modified and not corresponding to the amount that really has been paid anymore. This even though the order was locked.

Production build 0.71.5 2024