Commit 60341ab9c6d for woocommerce

commit 60341ab9c6dc38d5433d0640cedd6b171c1892d8
Author: Mike Jolley <mike.jolley@me.com>
Date:   Fri Oct 23 15:54:23 2026 +0100

    Fix removing order items before the order is first saved (#70110)

    * Fix remove_item() leaving unsaved order items on the order

    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

    * Add changelog entry for removing unsaved order items

    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

    * Add tests for removing saved, unsaved and duplicate order items

    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

    * Fix removing duplicate unsaved order item entries

    ---------

    Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/54697-remove-unsaved-order-items b/plugins/woocommerce/changelog/54697-remove-unsaved-order-items
new file mode 100644
index 00000000000..7f9ab1b4731
--- /dev/null
+++ b/plugins/woocommerce/changelog/54697-remove-unsaved-order-items
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix order items not being removed when an extension removes them before the order is first saved.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index 218b96fc200..b3186a92058 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -1430,8 +1430,8 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 	 * Get an order item object, based on its type.
 	 *
 	 * @since  3.0.0
-	 * @param  int  $item_id ID of item to get.
-	 * @param  bool $load_from_db Prior to 3.2 this item was loaded direct from WC_Order_Factory, not this object. This param is here for backwards compatibility with that. If false, uses the local items variable instead.
+	 * @param  int|string $item_id ID of item to get, or the temporary key of an unsaved item when $load_from_db is false.
+	 * @param  bool       $load_from_db Prior to 3.2 this item was loaded direct from WC_Order_Factory, not this object. This param is here for backwards compatibility with that. If false, uses the local items variable instead.
 	 * @return WC_Order_Item|false
 	 */
 	public function get_item( $item_id, $load_from_db = true ) {
@@ -1482,10 +1482,26 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 		return apply_filters( 'woocommerce_get_items_key', '', $item );
 	}

+	/**
+	 * Get the key an item is stored under in this order's items list.
+	 *
+	 * Unsaved items are stored under a temporary key such as 'new:fee_lines0' rather than their ID.
+	 * Falls back to the item ID when the item isn't in the list, e.g. a filtered copy of a saved item.
+	 *
+	 * @param WC_Order_Item $item Order item.
+	 * @return int|string
+	 */
+	private function get_stored_item_key( $item ) {
+		$items_key = $this->get_items_key( $item );
+		$item_key  = $items_key && isset( $this->items[ $items_key ] ) ? array_search( $item, $this->items[ $items_key ], true ) : false;
+
+		return false === $item_key ? $item->get_id() : $item_key;
+	}
+
 	/**
 	 * Remove item from the order.
 	 *
-	 * @param int $item_id Item ID to delete.
+	 * @param int|string $item_id Item ID, or the temporary key of an unsaved item, to delete.
 	 * @return false|void
 	 */
 	public function remove_item( $item_id ) {
@@ -1496,9 +1512,13 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 			return false;
 		}

+		if ( ( $this->items[ $items_key ][ $item_id ] ?? null ) !== $item ) {
+			$item_id = $this->get_stored_item_key( $item );
+		}
+
 		// Unset and remove later.
 		$this->items_to_delete[] = $item;
-		unset( $this->items[ $items_key ][ $item->get_id() ] );
+		unset( $this->items[ $items_key ][ $item_id ] );
 	}

 	/**
@@ -1903,9 +1923,9 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 		}

 		// Remove the coupon line.
-		foreach ( $coupons as $item_id => $coupon ) {
+		foreach ( $coupons as $coupon ) {
 			if ( wc_is_same_coupon( $coupon->get_code(), $code ) ) {
-				$this->remove_item( $item_id );
+				$this->remove_item( $this->get_stored_item_key( $coupon ) );
 				$coupon_object = new WC_Coupon( $code );

 				if ( $has_recorded ) {
@@ -2484,7 +2504,7 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 			$tax_rate_id = $tax->get_rate_id();
 			// Remove taxes which no longer exist for cart/shipping.
 			if ( ( ! array_key_exists( $tax_rate_id, $cart_taxes ) && ! array_key_exists( $tax_rate_id, $shipping_taxes ) ) || in_array( $tax_rate_id, $saved_rate_ids, true ) ) {
-				$this->remove_item( $tax->get_id() );
+				$this->remove_item( $this->get_stored_item_key( $tax ) );
 				continue;
 			}

diff --git a/plugins/woocommerce/includes/class-wc-order-factory.php b/plugins/woocommerce/includes/class-wc-order-factory.php
index 7cf55d9a239..7e6147be3da 100644
--- a/plugins/woocommerce/includes/class-wc-order-factory.php
+++ b/plugins/woocommerce/includes/class-wc-order-factory.php
@@ -166,7 +166,7 @@ class WC_Order_Factory {
 	/**
 	 * Get order item.
 	 *
-	 * @param int $item_id Order item ID to get.
+	 * @param int|string $item_id Order item ID, or numeric string ID, to get.
 	 * @return WC_Order_Item|false if not found
 	 */
 	public static function get_order_item( $item_id = 0 ) {
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 40e7fb88a2b..70ffb4116b1 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
@@ -1831,6 +1831,264 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * Create an unsaved order item of the given type.
+	 *
+	 * @param string $type Order item type.
+	 * @param string $name Item name (the code for coupons).
+	 * @return WC_Order_Item
+	 */
+	private function create_unsaved_order_item( string $type, string $name = 'Unsaved item' ): WC_Order_Item {
+		$classes = array(
+			'line_item' => WC_Order_Item_Product::class,
+			'fee'       => WC_Order_Item_Fee::class,
+			'shipping'  => WC_Order_Item_Shipping::class,
+			'coupon'    => WC_Order_Item_Coupon::class,
+			'tax'       => WC_Order_Item_Tax::class,
+		);
+
+		$item = new $classes[ $type ]();
+		$item->set_name( $name );
+		return $item;
+	}
+
+	/**
+	 * Provides order item types.
+	 *
+	 * @return array<string, array{string}>
+	 */
+	public static function provide_order_item_types(): array {
+		return array(
+			'line item' => array( 'line_item' ),
+			'fee'       => array( 'fee' ),
+			'shipping'  => array( 'shipping' ),
+			'coupon'    => array( 'coupon' ),
+			'tax'       => array( 'tax' ),
+		);
+	}
+
+	/**
+	 * @testdox remove_item() should remove an unsaved item by its temporary key, so it is not saved with the order.
+	 * @dataProvider provide_order_item_types
+	 *
+	 * @param string $type Order item type.
+	 */
+	public function test_remove_item_removes_unsaved_item( string $type ): void {
+		$order = new WC_Order();
+		$order->add_item( $this->create_unsaved_order_item( $type ) );
+		$item_key = array_key_first( $order->get_items( $type ) );
+
+		$order->remove_item( $item_key );
+
+		$this->assertEmpty( $order->get_items( $type ), 'The unsaved item should be removed from the order straight away.' );
+
+		$order->save();
+
+		$this->assertEmpty( wc_get_order( $order->get_id() )->get_items( $type ), 'The removed item should not be saved.' );
+	}
+
+	/**
+	 * @testdox remove_item() should remove only the requested entry when the same unsaved item is added twice.
+	 * @dataProvider provide_order_item_types
+	 *
+	 * @param string $type Order item type.
+	 */
+	public function test_remove_item_preserves_other_entry_for_same_unsaved_object( string $type ): void {
+		$order = new WC_Order();
+		$item  = $this->create_unsaved_order_item( $type );
+		$order->add_item( $item );
+		$order->add_item( $item );
+		$item_keys = array_keys( $order->get_items( $type ) );
+
+		$order->remove_item( $item_keys[1] );
+
+		$this->assertSame( array( $item_keys[0] => $item ), $order->get_items( $type ), 'Only the entry at the requested key should be removed.' );
+
+		$order->save();
+
+		$this->assertCount( 1, wc_get_order( $order->get_id() )->get_items( $type ), 'The remaining entry should still be saved.' );
+	}
+
+	/**
+	 * @testdox remove_item() should remove an unsaved item from a woocommerce_before_order_object_save callback.
+	 */
+	public function test_remove_item_removes_unsaved_item_during_before_save_hook(): void {
+		$order = new WC_Order();
+		$order->add_item( $this->create_unsaved_order_item( 'line_item', 'Original' ) );
+
+		add_action(
+			'woocommerce_before_order_object_save',
+			function ( $order ) {
+				foreach ( $order->get_items() as $item_id => $item ) {
+					if ( 'Original' === $item->get_name() ) {
+						$order->remove_item( $item_id );
+						$order->add_item( $this->create_unsaved_order_item( 'line_item', 'Replacement' ) );
+					}
+				}
+			}
+		);
+
+		$order->save();
+
+		$names = array_map( fn( $item ) => $item->get_name(), wc_get_order( $order->get_id() )->get_items() );
+		$this->assertSame( array( 'Replacement' ), array_values( $names ), 'Only the replacement item should be saved.' );
+	}
+
+	/**
+	 * @testdox remove_item() should still defer deleting saved items, removed by int or numeric string ID, until the order is saved.
+	 * @dataProvider provide_order_item_types
+	 *
+	 * @param string $type Order item type.
+	 */
+	public function test_remove_item_defers_deleting_saved_items_until_save( string $type ): void {
+		$order = new WC_Order();
+		$order->add_item( $this->create_unsaved_order_item( $type, 'first' ) );
+		$order->add_item( $this->create_unsaved_order_item( $type, 'second' ) );
+		$order->save();
+		list( $first_id, $second_id ) = array_keys( $order->get_items( $type ) );
+
+		$order->remove_item( $first_id );
+		$order->remove_item( (string) $second_id );
+
+		$this->assertEmpty( $order->get_items( $type ), 'The saved items should be removed from the order straight away.' );
+		$this->assertInstanceOf( WC_Order_Item::class, WC_Order_Factory::get_order_item( $first_id ), 'The item removed by int ID should stay in the database until the order is saved.' );
+		$this->assertInstanceOf( WC_Order_Item::class, WC_Order_Factory::get_order_item( $second_id ), 'The item removed by string ID should stay in the database until the order is saved.' );
+
+		$order->save();
+
+		$this->assertFalse( WC_Order_Factory::get_order_item( $first_id ), 'The item removed by int ID should be deleted when the order is saved.' );
+		$this->assertFalse( WC_Order_Factory::get_order_item( $second_id ), 'The item removed by string ID should be deleted when the order is saved.' );
+	}
+
+	/**
+	 * @testdox remove_item() should not remove a saved item when given its ID with a leading zero, a decimal or an exponent.
+	 * @testWith ["0%d"]
+	 *           ["%d.5"]
+	 *           ["%de0"]
+	 *
+	 * @param string $format sprintf() format that turns the item ID into the string to pass.
+	 */
+	public function test_remove_item_ignores_non_canonical_numeric_string_ids( string $format ): void {
+		$order = new WC_Order();
+		$order->add_item( $this->create_unsaved_order_item( 'fee' ) );
+		$order->save();
+		$item_id = array_key_first( $order->get_items( 'fee' ) );
+
+		$this->assertFalse( $order->remove_item( sprintf( $format, $item_id ) ) );
+		$this->assertArrayHasKey( $item_id, $order->get_items( 'fee' ), 'The item should stay on the order.' );
+
+		$order->save();
+
+		$this->assertArrayHasKey( $item_id, wc_get_order( $order->get_id() )->get_items( 'fee' ), 'The item should stay in the database.' );
+	}
+
+	/**
+	 * @testdox remove_item() should remove the item it resolves when a woocommerce_order_get_items filter changes the keys.
+	 */
+	public function test_remove_item_removes_resolved_item_when_filter_swaps_keys(): void {
+		$order = new WC_Order();
+		$order->add_item( $this->create_unsaved_order_item( 'fee', 'First' ) );
+		$order->add_item( $this->create_unsaved_order_item( 'fee', 'Second' ) );
+		$order->save();
+		list( $first_id, $second_id ) = array_keys( $order->get_items( 'fee' ) );
+
+		add_filter(
+			'woocommerce_order_get_items',
+			function ( $items ) use ( $first_id, $second_id ) {
+				if ( isset( $items[ $first_id ], $items[ $second_id ] ) ) {
+					list( $items[ $first_id ], $items[ $second_id ] ) = array( $items[ $second_id ], $items[ $first_id ] );
+				}
+				return $items;
+			}
+		);
+
+		// A freshly loaded order resolves the key through the filtered list, so the first key returns the second fee.
+		wp_cache_flush();
+		$reloaded = wc_get_order( $order->get_id() );
+		$reloaded->remove_item( $first_id );
+		$remaining_ids = array_map( fn( $item ) => $item->get_id(), $reloaded->get_items( 'fee' ) );
+		$reloaded->save();
+
+		$this->assertSame( array( $first_id ), array_values( $remaining_ids ), 'The resolved item should be removed from the order.' );
+		$this->assertFalse( WC_Order_Factory::get_order_item( $second_id ), 'The resolved item should be deleted.' );
+		$this->assertInstanceOf( WC_Order_Item_Fee::class, WC_Order_Factory::get_order_item( $first_id ), 'The other item should be kept.' );
+	}
+
+	/**
+	 * @testdox remove_coupon() should remove an unsaved coupon line.
+	 */
+	public function test_remove_coupon_removes_unsaved_coupon_line(): void {
+		$order = new WC_Order();
+		$order->add_item( $this->create_unsaved_order_item( 'coupon', 'unsaved-coupon' ) );
+
+		$this->assertTrue( $order->remove_coupon( 'unsaved-coupon' ) );
+		$this->assertEmpty( $order->get_coupon_codes(), 'The coupon line should be removed from the order straight away.' );
+
+		$order->save();
+
+		$this->assertEmpty( wc_get_order( $order->get_id() )->get_coupon_codes(), 'The removed coupon line should not be saved.' );
+	}
+
+	/**
+	 * @testdox remove_coupon() should remove only the matching coupon when a woocommerce_order_get_items filter renumbers the keys.
+	 */
+	public function test_remove_coupon_removes_matching_coupon_when_filter_renumbers_keys(): void {
+		$order = new WC_Order();
+		$order->add_item( $this->create_unsaved_order_item( 'coupon', 'first-coupon' ) );
+		$order->add_item( $this->create_unsaved_order_item( 'coupon', 'second-coupon' ) );
+		$order->save();
+
+		add_filter( 'woocommerce_order_get_items', 'array_values' );
+
+		$order->remove_coupon( 'second-coupon' );
+		$order->save();
+
+		$this->assertSame( array( 'first-coupon' ), wc_get_order( $order->get_id() )->get_coupon_codes() );
+	}
+
+	/**
+	 * @testdox update_taxes() should remove an unsaved tax line that duplicates the rate of a saved one.
+	 */
+	public function test_update_taxes_removes_unsaved_duplicate_tax_line(): void {
+		$tax_rate_id = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => '',
+				'tax_rate'          => '10.0000',
+				'tax_rate_name'     => 'Tax',
+				'tax_rate_priority' => '1',
+				'tax_rate_order'    => '1',
+			)
+		);
+		$fee         = $this->create_unsaved_order_item( 'fee' );
+		$fee->set_taxes( array( 'total' => array( $tax_rate_id => '1' ) ) );
+
+		$order = new WC_Order();
+		$order->add_item( $fee );
+		$order->update_taxes();
+		$saved_tax_id = array_key_first( $order->get_taxes() );
+
+		$duplicate = new WC_Order_Item_Tax();
+		$duplicate->set_rate_id( $tax_rate_id );
+		$order->add_item( $duplicate );
+		$order->update_taxes();
+
+		$this->assertSame( array( $saved_tax_id ), array_keys( $order->get_taxes() ), 'Only the saved tax line should remain on the order.' );
+		$this->assertSame( array( $saved_tax_id ), array_keys( wc_get_order( $order->get_id() )->get_taxes() ), 'The duplicate tax line should not be saved.' );
+	}
+
+	/**
+	 * @testdox update_taxes() should remove an unsaved tax line whose rate is no longer used by the order.
+	 */
+	public function test_update_taxes_removes_obsolete_unsaved_tax_line(): void {
+		$order = new WC_Order();
+		$order->add_item( $this->create_unsaved_order_item( 'tax' ) );
+
+		$order->update_taxes();
+
+		$this->assertEmpty( $order->get_taxes(), 'The obsolete tax line should be removed from the order.' );
+		$this->assertEmpty( wc_get_order( $order->get_id() )->get_taxes(), 'The obsolete tax line should not be saved.' );
+	}
+
 	/**
 	 * Create a product item for deferred deletion tests.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/OrderControllerTests.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/OrderControllerTests.php
index 136cb1e7b06..39517b552c6 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/OrderControllerTests.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/OrderControllerTests.php
@@ -230,6 +230,29 @@ class OrderControllerTests extends \WC_Unit_Test_Case {
 		$this->assertEquals( 1, ( new \WC_Coupon( 'draft-global' ) )->get_usage_count(), 'usage_count must not be decremented for a draft that never recorded it' );
 	}

+	/**
+	 * @testdox Existing-order validation removes an invalid coupon line that has not been saved yet.
+	 */
+	public function test_validate_existing_order_before_payment_removes_unsaved_invalid_coupon() {
+		$coupon = CouponHelper::create_coupon( 'unsaved-exhausted', 'publish', array( 'usage_limit' => 1 ) );
+		$coupon->increase_usage_count();
+
+		$order = WC_Helper_Order::create_order();
+		$item  = new \WC_Order_Item_Coupon();
+		$item->set_code( $coupon->get_code() );
+		$order->add_item( $item );
+
+		try {
+			$this->sut->validate_existing_order_before_payment( $order );
+			$this->fail( 'Expected a RouteException for the exhausted coupon.' );
+		} catch ( RouteException $e ) {
+			$this->assertEquals( 409, $e->getCode() );
+		}
+
+		$this->assertEmpty( $order->get_coupon_codes(), 'The coupon line should be removed from the order.' );
+		$this->assertEmpty( wc_get_order( $order->get_id() )->get_coupon_codes(), 'The coupon line should not be saved.' );
+	}
+
 	/**
 	 * test_validate_order_before_payment_invalid_email.
 	 */