Commit 90d5e6a6308 for woocommerce

commit 90d5e6a6308228fd896e34a02d6f1e2ff573ae29
Author: Mike Jolley <mike.jolley@me.com>
Date:   Tue Nov 10 14:14:33 2026 +0000

    Rebuild resumed checkout items when item hooks have callbacks (#70563)

    * Rebuild resumed checkout order items when item hooks have callbacks

    * Add changelog entry for the resumed order item hook fallback

diff --git a/plugins/woocommerce/changelog/wooairr-671-rebuild-items-with-hook-callbacks b/plugins/woocommerce/changelog/wooairr-671-rebuild-items-with-hook-callbacks
new file mode 100644
index 00000000000..b861be86327
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooairr-671-rebuild-items-with-hook-callbacks
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Preserve item hooks on classic checkout retries; refines unreleased item reuse.
+
diff --git a/plugins/woocommerce/includes/class-wc-checkout.php b/plugins/woocommerce/includes/class-wc-checkout.php
index 29bb2f15fd2..a6e7d68ee66 100644
--- a/plugins/woocommerce/includes/class-wc-checkout.php
+++ b/plugins/woocommerce/includes/class-wc-checkout.php
@@ -502,8 +502,8 @@ class WC_Checkout {

 				/**
 				 * Filters whether a resumed classic checkout order keeps the items that haven't changed, so their IDs
-				 * stay the same. Kept items don't run the woocommerce_checkout_create_order_*_item hooks again. Return false
-				 * to remove every item and add it again from the cart.
+				 * stay the same. Orders with item creation or removal callbacks always rebuild their items for compatibility.
+				 * Return false to rebuild items even when those hooks have no callbacks.
 				 *
 				 * @since 11.4.0
 				 *
@@ -512,7 +512,7 @@ class WC_Checkout {
 				 */
 				$keep_items = apply_filters( 'woocommerce_checkout_keep_order_items_on_resume', true, $order );

-				if ( true === $keep_items && $order instanceof WC_Order ) {
+				if ( true === $keep_items && $order instanceof WC_Order && ! $this->has_order_item_callbacks() ) {
 					// The rest of the items are added again from the cart later.
 					$this->kept_order_item_types = $this->remove_changed_order_items( $order, $cart_hashes );
 				} else {
@@ -664,6 +664,54 @@ class WC_Checkout {
 		}
 	}

+	/**
+	 * Whether callbacks require the original item creation and removal hooks on a checkout retry.
+	 *
+	 * @return bool
+	 */
+	private function has_order_item_callbacks(): bool {
+		global $wp_filter;
+
+		$hooks = array(
+			'woocommerce_checkout_create_order_line_item_object',
+			'woocommerce_checkout_create_order_line_item',
+			'woocommerce_checkout_create_order_fee_item',
+			'woocommerce_checkout_create_order_shipping_item',
+			'woocommerce_checkout_create_order_tax_item',
+			'woocommerce_checkout_create_order_coupon_item',
+			'woocommerce_remove_order_items',
+			'woocommerce_removed_order_items',
+		);
+		foreach ( $hooks as $hook ) {
+			if ( has_filter( $hook ) ) {
+				return true;
+			}
+		}
+
+		// Core always registers a forwarder; only its legacy listeners require a rebuild.
+		foreach ( $wp_filter['woocommerce_new_order_item']->callbacks ?? array() as $callbacks ) {
+			foreach ( $callbacks as $callback ) {
+				$function = $callback['function'];
+				$handler  = is_array( $function ) ? ( $function[0] ?? null ) : null;
+				if (
+					! $handler instanceof WC_Deprecated_Action_Hooks ||
+					WC_Deprecated_Action_Hooks::class !== get_class( $handler ) ||
+					'maybe_handle_deprecated_hook' !== ( $function[1] ?? null )
+				) {
+					return true;
+				}
+
+				foreach ( $handler->get_old_hooks( 'woocommerce_new_order_item' ) as $hook ) {
+					if ( has_action( $hook ) ) {
+						return true;
+					}
+				}
+			}
+		}
+
+		return false;
+	}
+
 	/**
 	 * Whether set_data_from_cart() should add items of a type, or keep the ones create_order() kept on a resumed order.
 	 *
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 9e5f51a0c9b..f226ef6984f 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
@@ -1379,6 +1379,10 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
 	 * @testdox Resuming a pending order with an unchanged cart keeps its item IDs.
 	 */
 	public function test_create_order_resume_keeps_item_ids_when_cart_is_unchanged(): void {
+		$this->assertNotFalse(
+			has_action( 'woocommerce_new_order_item', array( WC()->deprecated_hook_handlers['actions'], 'maybe_handle_deprecated_hook' ) ),
+			'The core legacy hook forwarder must not prevent item reuse.'
+		);
 		$order_id        = $this->create_order_with_line_item_and_fee();
 		$before          = $this->get_order_item_ids_by_type( $order_id );
 		$line_item_calls = did_action( 'woocommerce_checkout_create_order_line_item' );
@@ -1394,7 +1398,7 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Resuming a pending order whose $item_type cart section changed replaces only those items, and fires woocommerce_remove_order_items once for them.
+	 * @testdox Resuming a pending order whose $item_type cart section changed replaces only those items.
 	 * @testWith ["fee"]
 	 *           ["shipping"]
 	 *           ["tax"]
@@ -1407,20 +1411,11 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
 		$before   = $this->get_order_item_ids_by_type( $order_id );
 		$this->change_cart_section( $order_id, $item_type );
 		$line_item_calls = did_action( 'woocommerce_checkout_create_order_line_item' );
-		$removed_types   = array();
-		add_action(
-			'woocommerce_remove_order_items',
-			function ( $order, $type ) use ( &$removed_types ) {
-				unset( $order );
-				$removed_types[] = $type;
-			},
-			10,
-			2
-		);
+		$removal_calls   = did_action( 'woocommerce_remove_order_items' );

 		$this->resume_order( $order_id );

-		$this->assertSame( array( $item_type ), $removed_types, 'Only the changed type should be removed, once.' );
+		$this->assertSame( $removal_calls + 1, did_action( 'woocommerce_remove_order_items' ), 'Only the changed type should be removed, once.' );
 		$this->assert_items_replaced( $before, $this->get_order_item_ids_by_type( $order_id ), array( $item_type ) );
 		$this->assertSame( $line_item_calls, did_action( 'woocommerce_checkout_create_order_line_item' ), 'Kept line items should not be created again.' );
 		$this->assertSame( (float) WC()->cart->get_total( 'edit' ), (float) wc_get_order( $order_id )->get_total(), 'The order total should match the cart.' );
@@ -1779,6 +1774,221 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
 		$this->assert_items_replaced( $before, $after, array_keys( $before ) );
 	}

+	/**
+	 * @testdox Resuming an order rebuilds every item when $hook has a callback, even if the callback does not change items.
+	 * @testWith ["woocommerce_checkout_create_order_line_item"]
+	 *           ["woocommerce_checkout_create_order_fee_item"]
+	 *           ["woocommerce_checkout_create_order_shipping_item"]
+	 *           ["woocommerce_checkout_create_order_tax_item"]
+	 *           ["woocommerce_checkout_create_order_coupon_item"]
+	 *           ["woocommerce_checkout_create_order_line_item_object"]
+	 *           ["woocommerce_new_order_item"]
+	 *
+	 * @param string $hook The creation hook to register after the first checkout.
+	 */
+	public function test_create_order_resume_rebuilds_items_with_creation_callbacks( string $hook ): void {
+		$order_id = $this->create_order_with_shipping_taxes_and_coupon();
+		$before   = $this->get_order_item_ids_by_type( $order_id );
+		$calls    = 0;
+		add_filter(
+			$hook,
+			function ( $value ) use ( &$calls ) {
+				++$calls;
+				return $value;
+			},
+			0
+		);
+		add_filter( 'woocommerce_checkout_keep_order_items_on_resume', '__return_true' );
+
+		$this->assertSame( $order_id, $this->resume_order( $order_id ) );
+		$after = $this->get_order_item_ids_by_type( $order_id );
+		$this->assert_items_replaced( $before, $after, array_keys( $before ) );
+		$this->assertGreaterThan( 0, $calls, 'The newly registered callback must run on retry.' );
+
+		$calls = 0;
+		$this->assertSame( $order_id, $this->resume_order( $order_id ) );
+		$this->assert_items_replaced( $after, $this->get_order_item_ids_by_type( $order_id ), array_keys( $after ) );
+		$this->assertGreaterThan( 0, $calls, 'The callback must also run on subsequent retries.' );
+		$this->assertSame( (float) WC()->cart->get_total( 'edit' ), (float) wc_get_order( $order_id )->get_total() );
+	}
+
+	/**
+	 * @testdox Resuming an order calls $hook once for all items when it has a callback.
+	 * @testWith ["woocommerce_remove_order_items"]
+	 *           ["woocommerce_removed_order_items"]
+	 *
+	 * @param string $hook The removal hook.
+	 */
+	public function test_create_order_resume_rebuilds_items_with_removal_callbacks( string $hook ): void {
+		$order_id = $this->create_order_with_shipping_taxes_and_coupon();
+		$before   = $this->get_order_item_ids_by_type( $order_id );
+		$types    = array();
+		add_action(
+			$hook,
+			function ( $order, $type ) use ( &$types ) {
+				$types[] = $type;
+			},
+			10,
+			2
+		);
+
+		$this->assertSame( $order_id, $this->resume_order( $order_id ) );
+
+		$this->assertSame( array( null ), $types, 'The removal callback must receive the original whole-order call.' );
+		$this->assert_items_replaced( $before, $this->get_order_item_ids_by_type( $order_id ), array_keys( $before ) );
+	}
+
+	/**
+	 * @testdox Resuming an order rebuilds items when the core forwarder has a listener on $hook.
+	 * @testWith ["woocommerce_order_add_shipping"]
+	 *           ["woocommerce_order_add_coupon"]
+	 *           ["woocommerce_order_add_tax"]
+	 *           ["woocommerce_order_add_fee"]
+	 *           ["woocommerce_add_shipping_order_item"]
+	 *           ["woocommerce_add_order_item_meta"]
+	 *           ["woocommerce_add_order_fee_meta"]
+	 *
+	 * @param string $hook The deprecated creation hook.
+	 */
+	public function test_create_order_resume_rebuilds_items_with_legacy_callbacks( string $hook ): void {
+		$this->setExpectedDeprecated( $hook );
+		$order_id = $this->create_order_with_shipping_taxes_and_coupon();
+		$before   = $this->get_order_item_ids_by_type( $order_id );
+		$calls    = 0;
+		add_action(
+			$hook,
+			function () use ( &$calls ) {
+				++$calls;
+			}
+		);
+
+		$this->assertSame( $order_id, $this->resume_order( $order_id ) );
+
+		$this->assertGreaterThan( 0, $calls, 'The core forwarder must call the legacy listener on retry.' );
+		$this->assert_items_replaced( $before, $this->get_order_item_ids_by_type( $order_id ), array_keys( $before ) );
+	}
+
+	/**
+	 * @testdox Resuming an order refreshes derived item data through $hook, including a callback that initially writes nothing.
+	 * @testWith ["woocommerce_checkout_create_order_line_item", "old@example.com"]
+	 *           ["woocommerce_checkout_create_order_line_item", ""]
+	 *           ["woocommerce_checkout_create_order_line_item_object", "old@example.com"]
+	 *
+	 * @param string $hook The item creation hook.
+	 * @param string $email The billing email on the first checkout.
+	 */
+	public function test_create_order_resume_refreshes_item_data_from_order( string $hook, string $email ): void {
+		add_filter(
+			$hook,
+			function ( $item, $cart_item_key, $values, $order ) {
+				if ( '' !== $order->get_billing_email() ) {
+					$item->add_meta_data( '_recipient', $order->get_billing_email(), true );
+				}
+				return $item;
+			},
+			10,
+			4
+		);
+		$order_id = $this->create_order_with_line_item_and_fee( array( 'billing_email' => $email ) );
+		$items    = wc_get_order( $order_id )->get_items();
+		$this->assertSame( $email, current( $items )->get_meta( '_recipient' ) );
+
+		$this->assertSame( $order_id, $this->resume_order( $order_id, array( 'billing_email' => 'new@example.com' ) ) );
+		$items = wc_get_order( $order_id )->get_items();
+
+		$this->assertSame( 'new@example.com', current( $items )->get_meta( '_recipient' ) );
+	}
+
+	/**
+	 * @testdox Resuming an order refreshes item metadata written directly to the database during item creation.
+	 */
+	public function test_create_order_resume_refreshes_item_data_written_during_save(): void {
+		add_action(
+			'woocommerce_new_order_item',
+			function ( $item_id, $item, $order_id ) {
+				wc_update_order_item_meta( $item_id, '_recipient', wc_get_order( $order_id )->get_billing_email() );
+			},
+			10,
+			3
+		);
+		$order_id = $this->create_order_with_line_item_and_fee( array( 'billing_email' => 'old@example.com' ) );
+		$items    = wc_get_order( $order_id )->get_items();
+		$this->assertSame( 'old@example.com', current( $items )->get_meta( '_recipient' ) );
+
+		$this->assertSame( $order_id, $this->resume_order( $order_id, array( 'billing_email' => 'new@example.com' ) ) );
+		$items = wc_get_order( $order_id )->get_items();
+
+		$this->assertSame( 'new@example.com', current( $items )->get_meta( '_recipient' ) );
+	}
+
+	/**
+	 * @testdox Resuming an order keeps item IDs after its item creation callback is removed.
+	 */
+	public function test_create_order_resume_keeps_items_after_callback_is_removed(): void {
+		add_action( 'woocommerce_checkout_create_order_line_item', '__return_null' );
+		$order_id = $this->create_order_with_line_item_and_fee();
+		$before   = $this->get_order_item_ids_by_type( $order_id );
+		remove_action( 'woocommerce_checkout_create_order_line_item', '__return_null' );
+
+		$this->assertSame( $order_id, $this->resume_order( $order_id ) );
+
+		$this->assertSame( $before, $this->get_order_item_ids_by_type( $order_id ) );
+	}
+
+	/**
+	 * @testdox Resuming a failed order detects item callbacks registered by $hook during the retry.
+	 * @testWith ["woocommerce_resume_order"]
+	 *           ["woocommerce_checkout_keep_order_items_on_resume"]
+	 *
+	 * @param string $hook The hook that registers the item callback.
+	 */
+	public function test_create_order_resume_detects_callbacks_registered_during_retry( string $hook ): void {
+		$order_id = $this->create_order_with_line_item_and_fee();
+		$order    = wc_get_order( $order_id );
+		$order->set_status( OrderStatus::FAILED );
+		$order->save();
+		$before = $this->get_order_item_ids_by_type( $order_id );
+		add_filter(
+			$hook,
+			function ( $value ) {
+				add_action( 'woocommerce_checkout_create_order_line_item', '__return_null' );
+				return $value;
+			}
+		);
+
+		$this->assertSame( $order_id, $this->resume_order( $order_id ) );
+
+		$this->assert_items_replaced( $before, $this->get_order_item_ids_by_type( $order_id ), array_keys( $before ) );
+	}
+
+	/**
+	 * @testdox Resuming an order treats an overridden legacy forwarder as an extension callback.
+	 */
+	public function test_create_order_resume_rebuilds_items_with_custom_legacy_forwarder(): void {
+		$order_id = $this->create_order_with_line_item_and_fee();
+		$before   = $this->get_order_item_ids_by_type( $order_id );
+		$handler  = new class() extends WC_Deprecated_Action_Hooks {
+			/**
+			 * Number of callback invocations.
+			 *
+			 * @var int
+			 */
+			public $calls = 0;
+
+			/**
+			 * Record invocations instead of forwarding legacy hooks.
+			 */
+			public function maybe_handle_deprecated_hook() {
+				++$this->calls;
+			}
+		};
+
+		$this->assertSame( $order_id, $this->resume_order( $order_id ) );
+
+		$this->assertGreaterThan( 0, $handler->calls );
+		$this->assert_items_replaced( $before, $this->get_order_item_ids_by_type( $order_id ), array_keys( $before ) );
+	}
+
 	/**
 	 * @testdox Resuming a pending order replaces line items that no longer match the cart, even when the cart hash still matches.
 	 */
@@ -1854,9 +2064,10 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
 	/**
 	 * Creates an order from a cart with one product and one fee.
 	 *
+	 * @param array $data Posted checkout data to add.
 	 * @return int The order ID.
 	 */
-	private function create_order_with_line_item_and_fee(): int {
+	private function create_order_with_line_item_and_fee( array $data = array() ): int {
 		$product = WC_Helper_Product::create_simple_product();
 		add_action(
 			'woocommerce_cart_calculate_fees',
@@ -1867,7 +2078,7 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
 		WC()->cart->add_to_cart( $product->get_id(), 1 );
 		WC()->cart->calculate_totals();

-		$order_id = $this->sut->create_order( array( 'payment_method' => WC_Gateway_BACS::ID ) );
+		$order_id = $this->sut->create_order( array_merge( array( 'payment_method' => WC_Gateway_BACS::ID ), $data ) );
 		$this->assertIsInt( $order_id );

 		return $order_id;
@@ -2094,14 +2305,15 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
 	/**
 	 * Runs checkout again with the order set as awaiting payment, as when a shopper retries a failed payment.
 	 *
-	 * @param int $order_id The order awaiting payment.
+	 * @param int   $order_id The order awaiting payment.
+	 * @param array $data     Posted checkout data to add.
 	 * @return int|WP_Error The order ID returned by create_order().
 	 */
-	private function resume_order( int $order_id ) {
+	private function resume_order( int $order_id, array $data = array() ) {
 		$original = WC()->session->get( 'order_awaiting_payment' );
 		WC()->session->set( 'order_awaiting_payment', $order_id );
 		try {
-			return $this->sut->create_order( array( 'payment_method' => WC_Gateway_BACS::ID ) );
+			return $this->sut->create_order( array_merge( array( 'payment_method' => WC_Gateway_BACS::ID ), $data ) );
 		} finally {
 			WC()->session->set( 'order_awaiting_payment', $original );
 		}