Commit 80c1a71005b for woocommerce

commit 80c1a71005bd89dd1a3af1e03e002261c74a38d6
Author: Thomas Roberts <5656702+opr@users.noreply.github.com>
Date:   Fri Aug 28 14:12:32 2026 +0100

    Preserve custom order item deletion behavior (#68107)

    * Preserve custom order item deletion behavior

    * Add changelog entry for order item deletion fix

    * Test custom order item deletion override

    * Restore synchronous custom order item deletion

    * Cover replacement items in custom deletion tests

    * Clarify custom store deferred deletion opt-in

    * Clarify deferred item deletion opt-in

    * Fall back when custom stores lack item deletion

    * Cache deferred item deletion support

diff --git a/plugins/woocommerce/changelog/fix-order-item-delete-overrides b/plugins/woocommerce/changelog/fix-order-item-delete-overrides
new file mode 100644
index 00000000000..640780a537b
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-order-item-delete-overrides
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve custom data store cleanup when removing order items.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index 6fe5343db48..ad074f6836c 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -105,6 +105,13 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 	 */
 	protected $temp_item_id_counter = 0;

+	/**
+	 * Whether the data store supports deferred item deletion.
+	 *
+	 * @var bool|null Null until first checked.
+	 */
+	private $data_store_supports_deferred_item_deletion = null;
+
 	/**
 	 * Bulk order item types scheduled for deletion on save().
 	 *
@@ -358,6 +365,54 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 		}
 	}

+	/**
+	 * Determine whether the data store supports deferred item deletion.
+	 *
+	 * @return bool
+	 */
+	private function data_store_supports_deferred_item_deletion(): bool {
+		if ( null !== $this->data_store_supports_deferred_item_deletion ) {
+			return $this->data_store_supports_deferred_item_deletion;
+		}
+
+		/**
+		 * Data store wrapper.
+		 *
+		 * @var WC_Data_Store $data_store
+		 */
+		$data_store = $this->data_store;
+
+		if ( ! $data_store->has_callable( 'delete_items' ) ) {
+			$this->data_store_supports_deferred_item_deletion = true;
+			return true;
+		}
+
+		$data_store_class = $data_store->get_current_class_name();
+		$is_cpt_store     = is_a( $data_store_class, Abstract_WC_Order_Data_Store_CPT::class, true );
+
+		if ( ! $is_cpt_store ) {
+			// Standalone data stores opt in to deferred deletion by providing this optional method.
+			$this->data_store_supports_deferred_item_deletion = $data_store->has_callable( 'delete_items_by_ids' );
+			return $this->data_store_supports_deferred_item_deletion;
+		}
+
+		$delete_items_method = new ReflectionMethod( $data_store_class, 'delete_items' );
+		if ( Abstract_WC_Order_Data_Store_CPT::class === $delete_items_method->getDeclaringClass()->getName() ) {
+			$this->data_store_supports_deferred_item_deletion = true;
+			return true;
+		}
+
+		if ( ! $data_store->has_callable( 'delete_items_by_ids' ) ) {
+			$this->data_store_supports_deferred_item_deletion = false;
+			return false;
+		}
+
+		$delete_items_by_ids_method = new ReflectionMethod( $data_store_class, 'delete_items_by_ids' );
+
+		$this->data_store_supports_deferred_item_deletion = Abstract_WC_Order_Data_Store_CPT::class !== $delete_items_by_ids_method->getDeclaringClass()->getName();
+		return $this->data_store_supports_deferred_item_deletion;
+	}
+
 	/**
 	 * Get IDs of items currently persisted for this order.
 	 *
@@ -1055,18 +1110,12 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 	/**
 	 * Remove all line items (products, coupons, shipping, taxes) from the order.
 	 *
-	 * The items are cleared from the in-memory order immediately, but the database
-	 * deletion is deferred until the next call to save(). This keeps the checkout
-	 * "resume order" flow atomic: if anything between here and save() throws, the
-	 * previously persisted items remain intact in the database. As a consequence,
-	 * the `woocommerce_removed_order_items` action now fires from save_items()
-	 * (after the actual DB delete completes) rather than synchronously from this
-	 * method — listeners that observe the persisted state continue to see it as
-	 * before, but listeners pairing pre/post on the same call stack will see
-	 * the post-hook fire at save() time.
+	 * The items are cleared from the in-memory order immediately, but core data stores defer
+	 * database deletion until the next call to save(). Custom stores overriding `delete_items()`
+	 * without also overriding `delete_items_by_ids()` retain the historical synchronous behavior.
 	 *
 	 * @param string|null $type Order item type. Default null (remove every type).
-	 * @throws Exception If persisted item IDs cannot be read.
+	 * @throws Exception If persisted item IDs cannot be read or synchronous item deletion fails.
 	 * @return void
 	 */
 	public function remove_order_items( $type = null ) {
@@ -1095,10 +1144,16 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 		do_action( 'woocommerce_remove_order_items', $this, $type );

 		// Unsaved orders (id 0) have no persisted items — there's nothing to defer for deletion.
-		$has_persisted_items = $this->get_id() > 0;
+		$has_persisted_items  = $this->get_id() > 0;
+		$delete_synchronously = ! $this->data_store_supports_deferred_item_deletion();
+
+		if ( $delete_synchronously && $has_persisted_items ) {
+			// @phpstan-ignore-next-line -- Required order data store method forwarded by WC_Data_Store::__call().
+			$this->data_store->delete_items( $this, $type );
+		}

 		if ( ! empty( $type ) ) {
-			if ( $has_persisted_items ) {
+			if ( $has_persisted_items && ! $delete_synchronously ) {
 				$item_ids = $this->get_persisted_item_ids( $type );

 				if ( $this->bulk_delete_all_items_pending ) {
@@ -1129,7 +1184,7 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 				$this->items[ $group ] = array();
 			}
 		} else {
-			if ( $has_persisted_items ) {
+			if ( $has_persisted_items && ! $delete_synchronously ) {
 				$item_ids = $this->get_persisted_item_ids();

 				foreach ( $this->item_ids_to_bulk_delete_by_type as $typed_item_ids ) {
@@ -1157,6 +1212,15 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 				$this->items[ $group ] = array();
 			}
 		}
+
+		if ( $delete_synchronously ) {
+			/**
+			 * This action is documented in save_items().
+			 *
+			 * @since 7.8.0
+			 */
+			do_action( 'woocommerce_removed_order_items', $this, $type );
+		}
 	}

 	/**
diff --git a/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php
index f218e05e3e4..d4c9d7e9fb3 100644
--- a/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php
@@ -832,6 +832,8 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme
 	/**
 	 * Delete selected order items by ID.
 	 *
+	 * Custom order data stores that override this method opt in to deferred item deletion. The IDs are captured when items are removed and deleted during order save.
+	 *
 	 * @since 11.1.0
 	 *
 	 * @param WC_Order $order Order object.
diff --git a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
index c13609b26c7..d50e0419526 100644
--- a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
+++ b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
@@ -1538,13 +1538,164 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Should synchronously invoke custom data store item deletion behavior.
+	 * @testWith [null]
+	 *           ["line_item"]
+	 *
+	 * @param string|null $type Item type, or null for every type.
+	 */
+	public function test_remove_order_items_invokes_custom_data_store_delete_items( $type ) {
+		$order                 = WC_Helper_Order::create_order();
+		$original_data_store   = $order->get_data_store();
+		$line_item_ids         = array_keys( $order->get_items() );
+		$shipping_item_ids     = array_keys( $order->get_items( 'shipping' ) );
+		$expected_deleted_ids  = null === $type ? array_merge( $line_item_ids, $shipping_item_ids ) : $line_item_ids;
+		$expected_retained_ids = null === $type ? array() : $shipping_item_ids;
+
+		// phpcs:disable Squiz.Commenting -- Anonymous test double methods are self-explanatory.
+		$custom_data_store = new class( $original_data_store ) extends WC_Order_Data_Store_CPT {
+			public $deleted_item_types = array();
+
+			private $delegate;
+
+			public function __construct( $delegate ) {
+				$this->delegate = $delegate;
+			}
+
+			public function update( &$order ) {
+				return $this->delegate->update( $order );
+			}
+
+			public function delete_items( $order, $type = null ) {
+				$this->deleted_item_types[] = $type;
+				return $this->delegate->delete_items( $order, $type );
+			}
+		};
+		// phpcs:enable Squiz.Commenting
+
+		$data_store_filter  = static function () use ( $custom_data_store ) {
+			return $custom_data_store;
+		};
+		$removed_item_types = array();
+		$removed_hook       = static function ( $hook_order, $hook_type ) use ( &$removed_item_types ) {
+			$removed_item_types[] = $hook_type;
+		};
+		add_filter( 'woocommerce_order_data_store', $data_store_filter, PHP_INT_MAX );
+		add_action( 'woocommerce_removed_order_items', $removed_hook, 10, 2 );
+
+		try {
+			$reflection = new ReflectionProperty( WC_Data::class, 'data_store' );
+			$reflection->setAccessible( true );
+			$reflection->setValue( $order, new WC_Data_Store( 'order' ) );
+
+			$order->remove_order_items( $type );
+
+			$this->assertSame( array( $type ), $custom_data_store->deleted_item_types, 'The custom delete_items() implementation should run synchronously with the requested type.' );
+			$this->assertSame( array( $type ), $removed_item_types, 'The post-removal hook should run synchronously with the requested type.' );
+			foreach ( $expected_deleted_ids as $item_id ) {
+				$this->assertFalse( WC_Order_Factory::get_order_item( $item_id ), 'Items selected for removal should be deleted synchronously.' );
+			}
+			foreach ( $expected_retained_ids as $item_id ) {
+				$this->assertInstanceOf( WC_Order_Item::class, WC_Order_Factory::get_order_item( $item_id ), 'Items of other types should be retained.' );
+			}
+
+			$order->save();
+
+			$this->assertSame( array( $type ), $custom_data_store->deleted_item_types, 'Saving should not invoke the custom delete_items() implementation again.' );
+			$this->assertSame( array( $type ), $removed_item_types, 'Saving should not fire the post-removal hook again.' );
+		} finally {
+			remove_filter( 'woocommerce_order_data_store', $data_store_filter, PHP_INT_MAX );
+			remove_action( 'woocommerce_removed_order_items', $removed_hook, 10 );
+		}
+	}
+
+	/**
+	 * @testdox Should defer deletion when a custom data store opts in to ID-based deletion.
+	 */
+	public function test_remove_order_items_defers_custom_data_store_id_deletion() {
+		$order               = WC_Helper_Order::create_order();
+		$original_items      = $order->get_items();
+		$product             = current( $original_items )->get_product();
+		$original_data_store = $order->get_data_store();
+		$original_item_ids   = array_merge( array_keys( $original_items ), array_keys( $order->get_items( 'shipping' ) ) );
+
+		// phpcs:disable Squiz.Commenting -- Anonymous test double methods are self-explanatory.
+		$custom_data_store = new class( $original_data_store ) extends WC_Order_Data_Store_CPT {
+			public $delete_items_call_count = 0;
+
+			public $deleted_item_id_batches = array();
+
+			private $delegate;
+
+			public function __construct( $delegate ) {
+				$this->delegate = $delegate;
+			}
+
+			public function update( &$order ) {
+				return $this->delegate->update( $order );
+			}
+
+			public function delete_items( $order, $type = null ) {
+				++$this->delete_items_call_count;
+				return $this->delegate->delete_items( $order, $type );
+			}
+
+			public function delete_items_by_ids( $order, $ids ) {
+				$this->deleted_item_id_batches[] = $ids;
+				return $this->delegate->delete_items_by_ids( $order, $ids );
+			}
+		};
+		// phpcs:enable Squiz.Commenting
+
+		$data_store_filter = static function () use ( $custom_data_store ) {
+			return $custom_data_store;
+		};
+		add_filter( 'woocommerce_order_data_store', $data_store_filter, PHP_INT_MAX );
+
+		try {
+			$reflection = new ReflectionProperty( WC_Data::class, 'data_store' );
+			$reflection->setAccessible( true );
+			$reflection->setValue( $order, new WC_Data_Store( 'order' ) );
+
+			$order->remove_order_items();
+
+			$this->assertSame( 0, $custom_data_store->delete_items_call_count, 'The legacy deletion override should not run when custom ID-based deletion is available.' );
+			$this->assertSame( array(), $custom_data_store->deleted_item_id_batches, 'ID-based deletion should remain deferred until save().' );
+			foreach ( $original_item_ids as $item_id ) {
+				$this->assertInstanceOf( WC_Order_Item::class, WC_Order_Factory::get_order_item( $item_id ), 'Items should remain persisted until save().' );
+			}
+
+			$replacement_item = $this->create_deferred_deletion_test_item( $product, 'Early-saved replacement' );
+			$replacement_item->add_meta_data( '_custom_id_deletion_test', 'preserved', true );
+			$order->add_item( $replacement_item );
+			$replacement_item->set_order_id( $order->get_id() );
+			$replacement_item_id = $replacement_item->save();
+
+			$order->save();
+		} finally {
+			remove_filter( 'woocommerce_order_data_store', $data_store_filter, PHP_INT_MAX );
+		}
+
+		$this->assertSame( 0, $custom_data_store->delete_items_call_count, 'The legacy deletion override should not run during save().' );
+		$this->assertCount( 1, $custom_data_store->deleted_item_id_batches, 'The custom ID-based deletion override should run once during save().' );
+		$this->assertEqualsCanonicalizing( $original_item_ids, $custom_data_store->deleted_item_id_batches[0], 'The custom ID-based deletion override should receive the snapshotted item IDs.' );
+		foreach ( $original_item_ids as $item_id ) {
+			$this->assertFalse( WC_Order_Factory::get_order_item( $item_id ), 'Snapshotted items should be deleted during save().' );
+		}
+		$persisted_replacement = WC_Order_Factory::get_order_item( $replacement_item_id );
+		$this->assertInstanceOf( WC_Order_Item::class, $persisted_replacement, 'An item saved after removal should not be deleted during save().' );
+		$this->assertSame( 'preserved', $persisted_replacement->get_meta( '_custom_id_deletion_test' ), 'The replacement item metadata should be preserved.' );
+	}
+
 	/**
 	 * @testdox Should preserve replacement items with a legacy custom data store lacking ID snapshot and deletion methods.
 	 */
 	public function test_remove_order_items_preserves_replacements_with_custom_data_store_fallback() {
-		$order          = WC_Helper_Order::create_order();
-		$original_items = $order->get_items();
-		$product        = current( $original_items )->get_product();
+		$order             = WC_Helper_Order::create_order();
+		$original_items    = $order->get_items();
+		$original_item_ids = array_merge( array_keys( $original_items ), array_keys( $order->get_items( 'shipping' ) ) );
+		$product           = current( $original_items )->get_product();

 		$original_data_store = $order->get_data_store();
 		// phpcs:disable Squiz.Commenting -- Anonymous test double methods are self-explanatory.
@@ -1557,7 +1708,7 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
 			}

 			public function has_callable( string $method ): bool {
-				return in_array( $method, array( 'get_item_ids', 'delete_items_by_ids' ), true ) ? false : $this->delegate->has_callable( $method );
+				return in_array( $method, array( 'delete_items', 'get_item_ids', 'delete_items_by_ids' ), true ) ? false : $this->delegate->has_callable( $method );
 			}

 			public function update( &$data ) {
@@ -1565,7 +1716,7 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
 			}

 			public function get_current_class_name() {
-				return $this->delegate->get_current_class_name();
+				return get_class( $this );
 			}

 			public function __call( $method, $parameters ) {
@@ -1580,6 +1731,10 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {

 		$order->remove_order_items();

+		foreach ( $original_item_ids as $item_id ) {
+			$this->assertInstanceOf( WC_Order_Item::class, WC_Order_Factory::get_order_item( $item_id ), 'Items should remain persisted until save().' );
+		}
+
 		$early_saved_item = $this->create_deferred_deletion_test_item( $product, 'Early-saved replacement' );
 		$early_saved_item->add_meta_data( '_fallback_test', 'preserved', true );
 		$order->add_item( $early_saved_item );