The Goal
When a customer completes checkout on a Drupal Commerce website, the cart order is "placed." No additional changes can be made to the order by the customer. Administrative users, with access permissions, can typically edit these "placed" orders. However, significant changes like the addition/removal or an order item or an order item quantity/pricing change can be problematic. The changes can be made, but... afterwards, things like taxes, discounts, shipping charges, etc. may not be correct. And it turns out there is a very good reason for this.
Drupal Commerce provides a powerful and flexible framework for things like discounts, taxes, and automatic order processing. At the heart of this functionality is the Order Refresh process which only operates on cart/draft orders. It does not run on orders that have been "placed." The Order Refresh process does a number of important things, including:
- Runs an "availability" order processor to remove items that are no longer available.
- Ensures the order email address is in sync with the customer's email.
- Updates order item pricing to reflect current values.
- Recalculates discounts applied to an order.
- Executes any custom order processors.
- Recalculates charges, like taxes, applied to an order.
- Removes any zero quantity order items.
There are many excellent reasons why this Order Refresh process should not ever run on a non-draft/cart order, including the complexity of the actions performed as part of the process. On the other hand, there are valid use-cases in which you might want order refresh to run for a particular "placed" order that needs to be updated by an administrative user. You can read some discussion on this topic in this Drupal Commerce issue. And here.
My personal preference is that the Order Refresh functionality should remain as-is, since individual Drupal Commerce websites will have custom business logic related to when/if non-draft orders should be refreshed. For instance, you might have a situation in which you only want to recalculate taxes but not apply the full Order Refresh process to your order. Here, I describe an approach for how to trigger the full Order Refresh, for an individual "placed" order, using a bit of custom code.
A Solution
We can trigger Order Refresh for non-draft orders programmatically, using a single line of code (preceding an order save).
The line of code used to trigger refresh is:
$order->setRefreshState(OrderInterface::REFRESH_ON_SAVE);
I've used this simple solution within custom order event subscribers, views field plugins, and forms, including a custom override for the core Drupal Commerce Order Edit form.
Implementing this solution is relatively straightforward. What is not necessarily straightforward is working through your specific business logic to determine when/if you should automatically trigger order refresh. At the very least, you should almost definitely avoid making updates to "Completed" orders that require order refresh.
Additionally, documentation/communication about this functionality is important. For example, if administrative users are permitted to change orders significantly after checkout completion, a procedure should be in place for communication/notifications to customers. That could mean an email/phone call to the customer. Or it could mean a policy of Re-sending the Order Receipt after changes are made.
One caveat: If you allow administrative users to edit the Contact Email for orders, you will also need a patch for a single line of code change in the Order Refresh service class. This patch disables this aspect of the Order Refresh functionality:
- Ensures the order email address is in sync with the customer's email.
The potentially problematic bit of code is:
if ($customer->isAuthenticated()) {
if ($order->getEmail() && $order->getEmail() != $customer->getEmail()) {
$order->setEmail($customer->getEmail());
}
The problem is that it makes it impossible for an administrative user to change the Contact Email for an order to anything other than the customer's email. Fortunately, the fix is a simple change to the condition:
if ($customer->isAuthenticated() && $order->getState()->getId() == 'draft') {
So draft/cart orders will still get processed normally; the contact email will be left untouched for placed orders. The full patch is included in the "Implementation" section below. Also included is example code for overriding the standard admin order edit form so that updates will trigger order refresh.
Implementation:
This is an example of custom code for overriding the Drupal Commerce core order edit form. In it, we disable the Submit button for orders in certain states (of a custom workflow). That essentially makes the form read-only when updates are not permitted. And then in the save()
method, we trigger the Order Refresh functionality for not-completed, default-type orders. All other orders are saved without refresh:
<?php
namespace Drupal\my_module\Form;
use Drupal\commerce_order\Form\OrderForm;
use Drupal\Core\Form\FormStateInterface;
use Drupal\commerce_order\Entity\OrderInterface;
/**
* Provides the order edit form.
*/
class MyOrderEditForm extends OrderForm {
/**
* {@inheritdoc}
*/
protected function actions(array $form, FormStateInterface $form_state) {
/** @var \Drupal\commerce_order\Entity\OrderInterface $order */
$order = $this->entity;
// Disable submit button based on state.
$readonly_states = ['canceled'];
if ($order->bundle() == 'default') {
$readonly_states = array_merge($readonly_states, ['packed', 'shipped']);
}
$actions = parent::actions($form, $form_state);
if (in_array($order->getState()->getId(), $readonly_states)) {
$actions['submit']['#disabled'] = TRUE;
}
return $actions;
}
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
/** @var \Drupal\commerce_order\Entity\OrderInterface $order */
$order = $this->entity;
if ($order->bundle() == 'default') {
// Refresh order if not completed.
if (empty($order->getCompletedTime())) {
$order->setRefreshState(OrderInterface::REFRESH_ON_SAVE);
}
}
parent::save($form, $form_state);
}
}
To actually override the order edit form, we use hook_entity_type_build()
, like this:
use Drupal\my_module\Form\MyOrderEditForm;
/**
* Implements hook_entity_type_build().
*/
function my_module_entity_type_build(array &$entity_types) {
$entity_types['commerce_order']->setFormClass('edit', MyOrderEditForm::class);
}
Here is the full patch for the one-line change to the OrderRefresh class described above:
diff --git a/modules/order/src/OrderRefresh.php b/modules/order/src/OrderRefresh.php
index c04a327..e1caf15 100644
--- a/modules/order/src/OrderRefresh.php
+++ b/modules/order/src/OrderRefresh.php
@@ -157,7 +157,7 @@ class OrderRefresh implements OrderRefreshInterface {
// For authenticated users, maintain the order email in sync with the
// customer's email.
- if ($customer->isAuthenticated()) {
+ if ($customer->isAuthenticated() && $order->getState()->getId() == 'draft') {
if ($order->getEmail() && $order->getEmail() != $customer->getEmail()) {
$order->setEmail($customer->getEmail());
}
Comments