Commit 49441af86a0 for woocommerce

commit 49441af86a02ed076f676054a1958a407c11d7ee
Author: Brandon Kraft <public@brandonkraft.com>
Date:   Fri Jun 5 08:51:10 2026 -0500

    Fix order items being lost when order resume fails mid-checkout (#64326)

diff --git a/plugins/woocommerce/changelog/order-resume-item-loss-WOOPLUG-6382 b/plugins/woocommerce/changelog/order-resume-item-loss-WOOPLUG-6382
new file mode 100644
index 00000000000..39e22e05822
--- /dev/null
+++ b/plugins/woocommerce/changelog/order-resume-item-loss-WOOPLUG-6382
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent order items from being lost when order resume fails between removing items and saving the order.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index 37130e5a108..051a9eae379 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -88,6 +88,30 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 	 */
 	protected $items_to_delete = array();

+	/**
+	 * Bulk order item types scheduled for deletion on save().
+	 *
+	 * Populated by remove_order_items() with a specific item type and processed by
+	 * save_items(), so that deletion happens atomically alongside persistence of any
+	 * replacement items. Superseded by $bulk_delete_all_items_pending, which removes
+	 * every item type.
+	 *
+	 * @since 10.9.0
+	 * @var array<string>
+	 */
+	protected $item_types_to_bulk_delete = array();
+
+	/**
+	 * Whether every order item type should be deleted on the next save().
+	 *
+	 * Set by remove_order_items() when called with no type (so every item type
+	 * should be removed). Processed and reset in save_items().
+	 *
+	 * @since 10.9.0
+	 * @var bool
+	 */
+	protected $bulk_delete_all_items_pending = false;
+
 	/**
 	 * Stores meta in cache for future reads.
 	 *
@@ -279,6 +303,46 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 	protected function save_items() {
 		$items_changed = false;

+		if ( $this->bulk_delete_all_items_pending ) {
+			$this->data_store->delete_items( $this );
+			$this->bulk_delete_all_items_pending = false;
+			$this->item_types_to_bulk_delete     = array();
+			$items_changed                       = true;
+
+			/**
+			 * Trigger action after removing all order line items from the database.
+			 *
+			 * @param  WC_Order  $this  The current order object.
+			 * @param  string|null $type Order item type. Default null.
+			 *
+			 * @since 7.8.0
+			 */
+			do_action( 'woocommerce_removed_order_items', $this, null );
+		} elseif ( ! empty( $this->item_types_to_bulk_delete ) ) {
+			// Drain the queue one type at a time, dropping each entry only after delete_items() succeeds.
+			// If a delete or hook callback throws, save()'s catch handles it and any types that have not
+			// yet been processed remain queued for the next save() rather than being silently lost.
+			foreach ( array_values( array_unique( $this->item_types_to_bulk_delete ) ) as $type ) {
+				$this->data_store->delete_items( $this, $type );
+				$items_changed                   = true;
+				$this->item_types_to_bulk_delete = array_values(
+					array_filter(
+						$this->item_types_to_bulk_delete,
+						static function ( $pending ) use ( $type ) {
+							return $pending !== $type;
+						}
+					)
+				);
+
+				/**
+				 * This action is documented above.
+				 *
+				 * @since 7.8.0
+				 */
+				do_action( 'woocommerce_removed_order_items', $this, $type );
+			}//end foreach
+		}//end if
+
 		foreach ( $this->items_to_delete as $item ) {
 			$item->delete();
 			$items_changed = true;
@@ -869,49 +933,77 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 	/**
 	 * Remove all line items (products, coupons, shipping, taxes) from the order.
 	 *
-	 * @param string $type Order item type. Default null.
+	 * 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.
+	 *
+	 * @param string|null $type Order item type. Default null (remove every type).
 	 * @return void
 	 */
 	public function remove_order_items( $type = null ) {

+		// Guard against extension code passing non-string values. Anything not
+		// a string or null is ignored so it never reaches the deferred queue or
+		// the data store. Surface the misuse so it's traceable rather than silent.
+		if ( null !== $type && ! is_string( $type ) ) {
+			wc_doing_it_wrong(
+				__METHOD__,
+				/* translators: %s: PHP type that was passed instead of a string. */
+				sprintf( esc_html__( 'remove_order_items() expects a string item type or null; received %s.', 'woocommerce' ), esc_html( gettype( $type ) ) ),
+				'10.9.0'
+			);
+			return;
+		}
+
 		/**
 		 * Trigger action before removing all order line items. Allows you to track order items.
 		 *
 		 * @param  WC_Order  $this  The current order object.
-		 * @param  string $type Order item type. Default null.
+		 * @param  string|null $type Order item type. Default null.
 		 *
 		 * @since 7.8.0
 		 */
 		do_action( 'woocommerce_remove_order_items', $this, $type );

-		// Unsaved orders (id 0) have no persisted items — skip the data store round-trip.
+		// Unsaved orders (id 0) have no persisted items — there's nothing to defer for deletion.
 		$has_persisted_items = $this->get_id() > 0;

 		if ( ! empty( $type ) ) {
 			if ( $has_persisted_items ) {
-				$this->data_store->delete_items( $this, $type );
+				$this->item_types_to_bulk_delete[] = $type;
 			}

 			$group = $this->type_to_group( $type );

 			if ( $group ) {
-				unset( $this->items[ $group ] );
+				// Set to an empty array (rather than unset) so that subsequent get_items() calls
+				// return the in-memory "removed" state without re-reading the still-present rows
+				// from the data store.
+				$this->items[ $group ] = array();
 			}
 		} else {
 			if ( $has_persisted_items ) {
-				$this->data_store->delete_items( $this );
+				$this->bulk_delete_all_items_pending = true;
+				$this->item_types_to_bulk_delete     = array();
+			}
+			$type_to_group = $this->get_item_types_to_group();
+			// Union with currently populated keys so any group already loaded into
+			// $this->items (including by direct manipulation) is reset too. This
+			// matches the historical "wipe everything" semantics that the original
+			// $this->items = array() provided.
+			$groups = array_unique(
+				array_merge( array_values( $type_to_group ), array_keys( $this->items ) )
+			);
+			foreach ( $groups as $group ) {
+				$this->items[ $group ] = array();
 			}
-			$this->items = array();
 		}
-		/**
-		 * Trigger action after removing all order line items.
-		 *
-		 * @param  WC_Order  $this  The current order object.
-		 * @param  string $type Order item type. Default null.
-		 *
-		 * @since 7.8.0
-		 */
-		do_action( 'woocommerce_removed_order_items', $this, $type );
 	}

 	/**
@@ -921,11 +1013,33 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 	 * @return string
 	 */
 	protected function type_to_group( $type ) {
-		$type_to_group = apply_filters(
+		$type_to_group = $this->get_item_types_to_group();
+		return $type_to_group[ $type ] ?? '';
+	}
+
+	/**
+	 * Return the item type -> group mapping, after applying the
+	 * woocommerce_order_type_to_group filter so extension-registered types are
+	 * included.
+	 *
+	 * @since 10.9.0
+	 * @return array<string, string>
+	 */
+	protected function get_item_types_to_group() {
+		/**
+		 * Filter the order item type -> group mapping.
+		 *
+		 * Allows extensions to register custom order item types so they participate
+		 * in operations such as get_items() grouping and bulk in-memory clearing.
+		 *
+		 * @since 3.0.0
+		 *
+		 * @param array<string, string> $item_types_to_group The default mapping.
+		 */
+		return (array) apply_filters(
 			'woocommerce_order_type_to_group',
 			$this->item_types_to_group
 		);
-		return $type_to_group[ $type ] ?? '';
 	}

 	/**
diff --git a/plugins/woocommerce/includes/class-wc-checkout.php b/plugins/woocommerce/includes/class-wc-checkout.php
index 6d476bf7869..4ba18230ac8 100644
--- a/plugins/woocommerce/includes/class-wc-checkout.php
+++ b/plugins/woocommerce/includes/class-wc-checkout.php
@@ -470,6 +470,23 @@ class WC_Checkout {
 			// Save the order.
 			$order_id = $order->save();

+			// Defense-in-depth: if the cart still has items but the persisted order has none,
+			// the save silently dropped every line item (e.g. a $wpdb->insert() returning false
+			// in save_items()). Re-read the order from the data store so we check DB state, not
+			// the in-memory copy that save_items() populated. Bail out so the caller can retry
+			// rather than completing a paid-but-empty order.
+			//
+			// This is intentionally narrow: it catches the catastrophic "no items at all" case,
+			// not partial item loss where some inserts succeed and some fail. Wider parity checks
+			// would need to reconcile quantities across products, bundles, fees, shipping, etc.,
+			// which is out of scope for the silent-failure guard.
+			if ( WC()->cart->get_cart_contents_count() > 0 ) {
+				$persisted_order = wc_get_order( $order_id );
+				if ( ! $persisted_order || 0 === count( $persisted_order->get_items() ) ) {
+					throw new Exception( __( 'Order items could not be saved. Please try again.', 'woocommerce' ) );
+				}
+			}
+
 			/**
 			 * Action hook fired after an order is created used to add custom meta to the order.
 			 *
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 c19343f08cd..70b7b366721 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
@@ -635,4 +635,284 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
 		};
 		// phpcs:enable Squiz.Commenting
 	}
+
+	/**
+	 * @testdox Should defer bulk item deletion until save() is called.
+	 */
+	public function test_remove_order_items_defers_db_deletion_until_save() {
+		$order    = WC_Helper_Order::create_order();
+		$order_id = $order->get_id();
+
+		$this->assertGreaterThan( 0, count( wc_get_order( $order_id )->get_items() ), 'Precondition: order should have line items in the DB.' );
+		$this->assertGreaterThan( 0, count( wc_get_order( $order_id )->get_items( 'shipping' ) ), 'Precondition: order should have shipping items in the DB.' );
+
+		$order->remove_order_items();
+
+		$reloaded_before_save = wc_get_order( $order_id );
+		$this->assertNotEmpty( $reloaded_before_save->get_items(), 'Line items should still be present in the DB before save().' );
+		$this->assertNotEmpty( $reloaded_before_save->get_items( 'shipping' ), 'Shipping items should still be present in the DB before save().' );
+
+		$order->save();
+
+		$reloaded_after_save = wc_get_order( $order_id );
+		$this->assertCount( 0, $reloaded_after_save->get_items(), 'Line items should be removed from the DB after save().' );
+		$this->assertCount( 0, $reloaded_after_save->get_items( 'shipping' ), 'Shipping items should be removed from the DB after save().' );
+	}
+
+	/**
+	 * @testdox Should keep original items in the DB if save() never runs after remove_order_items().
+	 */
+	public function test_remove_order_items_preserves_db_items_if_save_not_called() {
+		$order    = WC_Helper_Order::create_order();
+		$order_id = $order->get_id();
+
+		$original_line_item_ids = array_keys( $order->get_items() );
+		$original_shipping_ids  = array_keys( $order->get_items( 'shipping' ) );
+
+		$order->remove_order_items();
+
+		unset( $order );
+		wp_cache_flush();
+
+		$reloaded = wc_get_order( $order_id );
+		$this->assertSame(
+			$original_line_item_ids,
+			array_keys( $reloaded->get_items() ),
+			'Line items should remain intact when remove_order_items() is not followed by save().'
+		);
+		$this->assertSame(
+			$original_shipping_ids,
+			array_keys( $reloaded->get_items( 'shipping' ) ),
+			'Shipping items should remain intact when remove_order_items() is not followed by save().'
+		);
+	}
+
+	/**
+	 * @testdox Should only remove items of the requested type when a type is passed.
+	 */
+	public function test_remove_order_items_by_type_defers_db_deletion() {
+		$order    = WC_Helper_Order::create_order();
+		$order_id = $order->get_id();
+
+		$order->remove_order_items( 'line_item' );
+
+		$before_save = wc_get_order( $order_id );
+		$this->assertNotEmpty( $before_save->get_items(), 'Line items should still be in the DB before save().' );
+		$this->assertNotEmpty( $before_save->get_items( 'shipping' ), 'Shipping items should still be in the DB before save().' );
+
+		$order->save();
+
+		$after_save = wc_get_order( $order_id );
+		$this->assertCount( 0, $after_save->get_items(), 'Line items should be removed after save().' );
+		$this->assertNotEmpty( $after_save->get_items( 'shipping' ), 'Shipping items should not be removed when only line_item was requested.' );
+	}
+
+	/**
+	 * @testdox Pre-hook fires immediately and post-hook fires after the deferred DB delete during save.
+	 */
+	public function test_remove_order_items_action_hooks_fire_at_correct_times() {
+		$order = WC_Helper_Order::create_order();
+
+		$pre_calls    = array();
+		$post_calls   = array();
+		$expected_log = array(
+			array(
+				'order_id' => $order->get_id(),
+				'type'     => 'line_item',
+			),
+		);
+
+		$pre_callback  = function ( $fired_order, $type ) use ( &$pre_calls ) {
+			$pre_calls[] = array(
+				'order_id' => $fired_order->get_id(),
+				'type'     => $type,
+			);
+		};
+		$post_callback = function ( $fired_order, $type ) use ( &$post_calls ) {
+			$post_calls[] = array(
+				'order_id' => $fired_order->get_id(),
+				'type'     => $type,
+			);
+		};
+
+		add_action( 'woocommerce_remove_order_items', $pre_callback, 10, 2 );
+		add_action( 'woocommerce_removed_order_items', $post_callback, 10, 2 );
+
+		try {
+			$order->remove_order_items( 'line_item' );
+
+			$this->assertSame(
+				$expected_log,
+				$pre_calls,
+				'woocommerce_remove_order_items should fire once when removal is requested.'
+			);
+			$this->assertSame(
+				array(),
+				$post_calls,
+				'woocommerce_removed_order_items should not fire until the deferred DB delete runs in save().'
+			);
+
+			$order->save();
+
+			$this->assertSame(
+				$expected_log,
+				$post_calls,
+				'woocommerce_removed_order_items should fire once with the requested type after save() commits the delete.'
+			);
+		} finally {
+			remove_action( 'woocommerce_remove_order_items', $pre_callback, 10 );
+			remove_action( 'woocommerce_removed_order_items', $post_callback, 10 );
+		}//end try
+	}
+
+	/**
+	 * @testdox Post-hook fires once with null type after save() commits a full removal.
+	 */
+	public function test_remove_order_items_post_hook_for_all_types_fires_with_null_after_save() {
+		$order = WC_Helper_Order::create_order();
+
+		$post_calls    = array();
+		$post_callback = function ( $fired_order, $type ) use ( &$post_calls ) {
+			$post_calls[] = array(
+				'order_id' => $fired_order->get_id(),
+				'type'     => $type,
+			);
+		};
+
+		add_action( 'woocommerce_removed_order_items', $post_callback, 10, 2 );
+
+		try {
+			$order->remove_order_items();
+
+			$this->assertSame( array(), $post_calls, 'Post-hook should not fire before save().' );
+
+			$order->save();
+		} finally {
+			remove_action( 'woocommerce_removed_order_items', $post_callback, 10 );
+		}
+
+		$this->assertSame(
+			array(
+				array(
+					'order_id' => $order->get_id(),
+					'type'     => null,
+				),
+			),
+			$post_calls,
+			'Post-hook should fire once with a null type after a full remove_order_items() commits in save().'
+		);
+	}
+
+	/**
+	 * @testdox Should retain queued item types when a post-hook callback throws so a subsequent save() can drain them.
+	 */
+	public function test_save_items_preserves_queued_types_when_post_hook_throws() {
+		$order    = WC_Helper_Order::create_order();
+		$order_id = $order->get_id();
+
+		$order->remove_order_items( 'line_item' );
+		$order->remove_order_items( 'shipping' );
+
+		$hook_calls = array();
+		$callback   = function ( $fired_order, $type ) use ( &$hook_calls ) {
+			$hook_calls[] = $type;
+			if ( 'line_item' === $type ) {
+				throw new RuntimeException( 'simulated hook failure' );
+			}
+		};
+
+		add_action( 'woocommerce_removed_order_items', $callback, 10, 2 );
+
+		try {
+			$order->save();
+
+			// Intermediate checkpoint: line_item was processed (its post-hook threw),
+			// shipping should remain queued. Reload from the data store so we're
+			// asserting persisted state rather than the in-memory order.
+			$after_first_save = wc_get_order( $order_id );
+			$this->assertCount( 0, $after_first_save->get_items(), 'Line items should be removed from the DB after the first save().' );
+			$this->assertNotEmpty( $after_first_save->get_items( 'shipping' ), 'Shipping items should remain in the DB until the retry save() drains them.' );
+			$this->assertSame( array( 'line_item' ), $hook_calls, 'Only the line_item post-hook should have fired during the first save().' );
+
+			$order->save();
+		} finally {
+			remove_action( 'woocommerce_removed_order_items', $callback, 10 );
+		}
+
+		$reloaded = wc_get_order( $order_id );
+		$this->assertCount( 0, $reloaded->get_items(), 'Line items should be removed from the DB across the two saves.' );
+		$this->assertCount( 0, $reloaded->get_items( 'shipping' ), 'Shipping items should be removed from the DB on the retry save after the first hook threw.' );
+		$this->assertSame(
+			array( 'line_item', 'shipping' ),
+			$hook_calls,
+			'Post-hook should fire for each type exactly once across the two saves, even though the first call threw.'
+		);
+	}
+
+	/**
+	 * @testdox Should ignore non-string item types and trigger _doing_it_wrong.
+	 */
+	public function test_remove_order_items_rejects_non_string_type() {
+		$order = WC_Helper_Order::create_order();
+
+		$this->setExpectedIncorrectUsage( 'WC_Abstract_Order::remove_order_items' );
+
+		$pre_calls = array();
+		$pre_hook  = function ( $fired_order, $type ) use ( &$pre_calls ) {
+			$pre_calls[] = $type;
+		};
+		add_action( 'woocommerce_remove_order_items', $pre_hook, 10, 2 );
+
+		try {
+			$order->remove_order_items( array( 'line_item' ) );
+		} finally {
+			remove_action( 'woocommerce_remove_order_items', $pre_hook, 10 );
+		}
+
+		$this->assertSame(
+			array(),
+			$pre_calls,
+			'Pre-hook should not fire for a non-string type — the guard returns before the hook.'
+		);
+		$this->assertNotEmpty( $order->get_items(), 'Line items should remain in memory since the guard ignored the malformed type.' );
+	}
+
+	/**
+	 * @testdox Should clear in-memory items for extension-registered groups when remove_order_items() is called without a type.
+	 */
+	public function test_remove_order_items_clears_custom_filter_groups() {
+		$order   = WC_Helper_Order::create_order();
+		$adjust  = function ( $type_to_group ) {
+			$type_to_group['custom_unit_test_type'] = 'custom_unit_test_lines';
+			return $type_to_group;
+		};
+		$reflect = new ReflectionClass( $order );
+
+		add_filter( 'woocommerce_order_type_to_group', $adjust );
+
+		try {
+			$items_prop = $reflect->getProperty( 'items' );
+			$items_prop->setAccessible( true );
+			$items = $items_prop->getValue( $order );
+
+			$items['custom_unit_test_lines'] = array( 'sentinel' );
+			$items_prop->setValue( $order, $items );
+
+			$order->remove_order_items();
+
+			$items_after = $items_prop->getValue( $order );
+			$this->assertArrayHasKey(
+				'custom_unit_test_lines',
+				$items_after,
+				'Filter-registered groups should be present as keys after the in-memory clear.'
+			);
+			$this->assertSame(
+				array(),
+				$items_after['custom_unit_test_lines'],
+				'Filter-registered group should be cleared to an empty array — not left with stale entries.'
+			);
+		} finally {
+			remove_filter( 'woocommerce_order_type_to_group', $adjust );
+		}
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
index a5da25d9e38..e12689bc026 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
@@ -268,4 +268,40 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
 		// Assert that the login form is present.
 		$this->assertStringContainsString( 'woocommerce-form-login', $output );
 	}
+
+	/**
+	 * @testdox Returns WP_Error when line items fail to persist to the DB despite save() completing.
+	 */
+	public function test_create_order_returns_error_when_items_not_persisted() {
+		global $wpdb;
+
+		$product = WC_Helper_Product::create_simple_product();
+		WC()->cart->add_to_cart( $product->get_id() );
+
+		$simulate_silent_insert_failure = function ( $order ) use ( $wpdb ) {
+			$wpdb->delete(
+				$wpdb->prefix . 'woocommerce_order_items',
+				array( 'order_id' => $order->get_id() )
+			);
+			wp_cache_flush();
+		};
+		add_action( 'woocommerce_after_order_object_save', $simulate_silent_insert_failure );
+
+		$data = array(
+			'ship_to_different_address' => false,
+			'payment_method'            => WC_Gateway_BACS::ID,
+			'billing_email'             => 'customer@example.com',
+		);
+
+		try {
+			$result = $this->sut->create_order( $data );
+		} finally {
+			remove_action( 'woocommerce_after_order_object_save', $simulate_silent_insert_failure );
+			WC()->cart->empty_cart();
+		}
+
+		$this->assertInstanceOf( WP_Error::class, $result, 'create_order() should return a WP_Error when line items were not persisted.' );
+		$this->assertSame( 'checkout-error', $result->get_error_code(), 'Error code should come from the checkout try/catch path.' );
+		$this->assertStringContainsString( 'Order items could not be saved', $result->get_error_message(), 'Error message should surface the defense-in-depth guard message.' );
+	}
 }