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.' );
+ }
}