Commit 9723423e37b for woocommerce

commit 9723423e37b0d15ddb947a6544cc614344bccbee
Author: Mike Jolley <mike.jolley@me.com>
Date:   Fri Oct 30 16:58:34 2026 +0000

    Keep order item IDs when retrying a classic checkout order (#70186)

    * Keep order item IDs when a classic checkout order is retried with an unchanged cart

    Resuming a failed or pending classic checkout order deleted and recreated
    every item, so integrations that store item IDs lost track of them. A
    resumed order now keeps each item type whose cart section hash and stored
    item fingerprints are unchanged since checkout, and rebuilds the rest.

    Builds on #69791, reusing its approach to cart hashes and most of its tests.

    Co-authored-by: Alefe Souza <contact@alefesouza.com>
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

    * Hash filtered shipping rate values when resuming a classic checkout order

    Shipping lines copy each rate's filtered cost, taxes and label, so a rate
    filter that changes between attempts could leave a kept shipping line
    with stale values.

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

    * Note in the keep-items filter docblock that kept items skip item hooks

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

    * Trim the resume tax sweep to 24 pairwise cases to save CI time

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

    * Rebuild an item type on resume when one of its items failed to save

    If an item failed to save on the previous attempt, the snapshot only listed the saved items, so the type was kept on resume and the missing item never came back. Unsaved items now get an empty fingerprint, so their type is rebuilt.

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

    * Change the cart in resume tests instead of the stored hash, and trim the tax sweep

    The section tests now rename a fee, switch shipping method and swap a coupon without changing the cart total, so the order is still resumed. Taxes keep the hash change, since rate IDs also appear in other items' taxes. The tax sweep is cut to one ordinary and one complex case.

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

    ---------

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

diff --git a/plugins/woocommerce/changelog/36851-keep-order-items-on-resume b/plugins/woocommerce/changelog/36851-keep-order-items-on-resume
new file mode 100644
index 00000000000..2c992ff5bbb
--- /dev/null
+++ b/plugins/woocommerce/changelog/36851-keep-order-items-on-resume
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep order items intact when a shopper retries a failed or unpaid classic checkout payment, so integrations still find them.
diff --git a/plugins/woocommerce/includes/class-wc-checkout.php b/plugins/woocommerce/includes/class-wc-checkout.php
index 31ecf30eb1a..7db68b20216 100644
--- a/plugins/woocommerce/includes/class-wc-checkout.php
+++ b/plugins/woocommerce/includes/class-wc-checkout.php
@@ -8,12 +8,14 @@
  * @version 3.4.0
  */

+use Automattic\WooCommerce\Enums\OrderItemType;
 use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Enums\ProductTaxStatus;
 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
 use Automattic\WooCommerce\Internal\ProductVariations\SelectedVariationName;
 use Automattic\WooCommerce\Internal\Tax\TaxRateDataStore;
+use Automattic\WooCommerce\StoreApi\Utilities\CartController;

 defined( 'ABSPATH' ) || exit;

@@ -62,6 +64,32 @@ class WC_Checkout {
 	 */
 	private $logged_in_customer = null;

+	/**
+	 * Order item types that set_data_from_cart() creates from the cart.
+	 *
+	 * @var string[]
+	 */
+	private const CART_ORDER_ITEM_TYPES = array(
+		OrderItemType::LINE_ITEM,
+		OrderItemType::FEE,
+		OrderItemType::SHIPPING,
+		OrderItemType::TAX,
+		OrderItemType::COUPON,
+	);
+
+	/**
+	 * Order meta that stores a hash of each cart section and a fingerprint of each item the cart created, so a resumed
+	 * order can keep the items that haven't changed since checkout.
+	 */
+	private const CART_ORDER_ITEMS_SNAPSHOT_META_KEY = '_cart_order_items_snapshot';
+
+	/**
+	 * Item types that create_order() kept on a resumed order, so set_data_from_cart() doesn't add them again.
+	 *
+	 * @var string[]
+	 */
+	private $kept_order_item_types = array();
+
 	/**
 	 * Gets the main WC_Checkout Instance.
 	 *
@@ -412,6 +440,7 @@ class WC_Checkout {
 		try {
 			$order_id           = absint( WC()->session->get( 'order_awaiting_payment' ) );
 			$cart_hash          = WC()->cart->get_cart_hash();
+			$cart_hashes        = $this->get_cart_section_hashes();
 			$available_gateways = WC()->payment_gateways->get_available_payment_gateways();
 			$order              = $order_id ? wc_get_order( $order_id ) : null;

@@ -432,8 +461,24 @@ class WC_Checkout {
 				 */
 				do_action( 'woocommerce_resume_order', $order_id );

-				// Remove all items - we will re-add them later.
-				$order->remove_order_items();
+				/**
+				 * 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.
+				 *
+				 * @since 11.4.0
+				 *
+				 * @param bool              $keep_items Whether to keep unchanged items. Default true.
+				 * @param WC_Abstract_Order $order      The resumed order.
+				 */
+				$keep_items = apply_filters( 'woocommerce_checkout_keep_order_items_on_resume', true, $order );
+
+				if ( true === $keep_items && $order instanceof WC_Order ) {
+					// 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 {
+					$order->remove_order_items();
+				}
 			} else {
 				$order = new WC_Order();
 			}
@@ -479,6 +524,12 @@ class WC_Checkout {
 				$order->calculate_cogs_total_value();
 			}

+			// Captured before woocommerce_checkout_create_order, so items extensions add or change there can be told apart.
+			$cart_order_items = array_map(
+				fn( WC_Order_Item $item ) => array( $item, $this->get_order_item_fingerprint( $item ) ),
+				$order->get_items( self::CART_ORDER_ITEM_TYPES )
+			);
+
 			/**
 			 * Action hook to adjust order before save.
 			 *
@@ -504,6 +555,9 @@ class WC_Checkout {
 				if ( ! $persisted_order || 0 === count( $persisted_order->get_items() ) ) {
 					throw new Exception( __( 'Order items could not be saved. Please try again.', 'woocommerce' ) );
 				}
+
+				$order->update_meta_data( self::CART_ORDER_ITEMS_SNAPSHOT_META_KEY, $this->get_cart_order_items_snapshot( $persisted_order, $cart_order_items, $cart_hashes ) );
+				$order->save_meta_data();
 			}

 			/**
@@ -532,6 +586,9 @@ class WC_Checkout {
 				do_action( 'woocommerce_checkout_order_exception', $order );
 			}
 			return new WP_Error( 'checkout-error', $e->getMessage() );
+		} finally {
+			// Reset on every path, including Errors, so a later set_data_from_cart() call adds every item type.
+			$this->kept_order_item_types = array();
 		}
 	}

@@ -551,12 +608,182 @@ class WC_Checkout {
 		$order->set_cart_tax( WC()->cart->get_cart_contents_tax() + WC()->cart->get_fee_tax() );
 		$order->set_shipping_tax( WC()->cart->get_shipping_tax() );
 		$order->set_total( WC()->cart->get_total( 'edit' ) );
-		$this->create_order_line_items( $order, WC()->cart );
-		$this->create_order_fee_lines( $order, WC()->cart );
-		$this->create_order_shipping_lines( $order, WC()->session->get( 'chosen_shipping_methods' ), WC()->shipping()->get_packages() );
-		$this->create_order_tax_lines( $order, WC()->cart );
-		$this->create_order_coupon_lines( $order, WC()->cart );
+		if ( $this->should_add_order_items( OrderItemType::LINE_ITEM ) ) {
+			$this->create_order_line_items( $order, WC()->cart );
+		}
+		if ( $this->should_add_order_items( OrderItemType::FEE ) ) {
+			$this->create_order_fee_lines( $order, WC()->cart );
+		}
+		if ( $this->should_add_order_items( OrderItemType::SHIPPING ) ) {
+			$this->create_order_shipping_lines( $order, WC()->session->get( 'chosen_shipping_methods' ), WC()->shipping()->get_packages() );
+		}
+		if ( $this->should_add_order_items( OrderItemType::TAX ) ) {
+			$this->create_order_tax_lines( $order, WC()->cart );
+		}
+		if ( $this->should_add_order_items( OrderItemType::COUPON ) ) {
+			$this->create_order_coupon_lines( $order, WC()->cart );
+		}
+	}
+
+	/**
+	 * Whether set_data_from_cart() should add items of a type, or keep the ones create_order() kept on a resumed order.
+	 *
+	 * @param string $item_type Order item type.
+	 * @return bool
+	 */
+	private function should_add_order_items( string $item_type ): bool {
+		return ! in_array( $item_type, $this->kept_order_item_types, true );
+	}
+
+	/**
+	 * Hashes each cart section that creates an order item type, using the Store API's cart hashes for fees, taxes and
+	 * coupons. Line items hash the cart's contents instead of the filterable cart hash, and shipping hashes the filtered
+	 * rate values that create_order_shipping_lines() copies, so filters can't hide a change to either.
+	 *
+	 * @return array<string, string> Hashes keyed by order item type.
+	 */
+	private function get_cart_section_hashes(): array {
+		$hashes         = ( new CartController() )->get_cart_hashes();
+		$shipping_rates = array_map(
+			fn( $rate ) => $rate instanceof WC_Shipping_Rate ? array( $rate->get_id(), $rate->get_label(), $rate->get_cost(), $rate->get_taxes(), $rate->get_meta_data() ) : $rate,
+			(array) WC()->cart->get_shipping_methods()
+		);
+
+		return array(
+			OrderItemType::LINE_ITEM => md5( (string) wp_json_encode( WC()->cart->get_cart_for_session() ) ),
+			OrderItemType::FEE       => $hashes['fees'],
+			OrderItemType::SHIPPING  => md5( (string) wp_json_encode( array( $shipping_rates, WC()->session->get( 'chosen_shipping_methods' ) ) ) ),
+			OrderItemType::TAX       => $hashes['taxes'],
+			OrderItemType::COUPON    => $hashes['coupons'],
+		);
 	}
+
+	/**
+	 * Builds the snapshot create_order() stores: the cart section hashes, and a fingerprint of each item the cart created,
+	 * read back from the saved order. An item that wasn't saved, or that an extension changed in
+	 * woocommerce_checkout_create_order, gets an empty fingerprint, so its type is rebuilt on resume.
+	 *
+	 * @param WC_Abstract_Order     $persisted_order  The order, read again after it was saved.
+	 * @param array[]               $cart_order_items Each item set_data_from_cart() added or kept, with its fingerprint before the hook.
+	 * @param array<string, string> $cart_hashes      Hashes from get_cart_section_hashes().
+	 * @return array{hashes: array<string, string>, items: array<string, array<int, string>>}
+	 */
+	private function get_cart_order_items_snapshot( WC_Abstract_Order $persisted_order, array $cart_order_items, array $cart_hashes ): array {
+		$persisted_items = $persisted_order->get_items( self::CART_ORDER_ITEM_TYPES );
+		$items           = array();
+		foreach ( $cart_order_items as list( $item, $fingerprint ) ) {
+			$persisted_item = $persisted_items[ $item->get_id() ] ?? null;
+			$is_unchanged   = $persisted_item && $fingerprint === $this->get_order_item_fingerprint( $item );
+
+			$items[ $item->get_type() ][ $item->get_id() ] = $is_unchanged ? $this->get_order_item_fingerprint( $persisted_item ) : '';
+		}
+
+		return array(
+			'hashes' => $cart_hashes,
+			'items'  => $items,
+		);
+	}
+
+	/**
+	 * Hashes an item's stored amounts and properties. Names and meta are left out, so renaming an item or adding meta
+	 * to it, as gateways do, doesn't count as a change.
+	 *
+	 * @param WC_Order_Item $item The order item.
+	 * @return string
+	 */
+	private function get_order_item_fingerprint( WC_Order_Item $item ): string {
+		// Include unsaved changes, so an item can be compared before and after it's saved.
+		$data = array_replace_recursive( $item->get_data(), $item->get_changes() );
+		unset( $data['id'], $data['order_id'], $data['name'], $data['meta_data'] );
+
+		return md5( (string) wp_json_encode( $this->sort_keys_recursively( $data ) ) );
+	}
+
+	/**
+	 * Sorts an array's keys at every level, since saving an item can reorder nested keys such as its taxes.
+	 *
+	 * @param array $data The array to sort.
+	 * @return array
+	 */
+	private function sort_keys_recursively( array $data ): array {
+		ksort( $data );
+		foreach ( $data as $key => $value ) {
+			if ( is_array( $value ) ) {
+				$data[ $key ] = $this->sort_keys_recursively( $value );
+			}
+		}
+
+		return $data;
+	}
+
+	/**
+	 * Removes the items of a resumed order that changed, and returns the item types it kept.
+	 *
+	 * A type is kept when its cart section hash matches the snapshot and every item the cart created is still there with
+	 * the same fingerprint. Other items of a kept type, such as ones extensions add at checkout, are removed so they
+	 * aren't duplicated.
+	 *
+	 * @param WC_Order              $order       The resumed order.
+	 * @param array<string, string> $cart_hashes Hashes from get_cart_section_hashes().
+	 * @return string[] The kept item types.
+	 */
+	private function remove_changed_order_items( WC_Order $order, array $cart_hashes ): array {
+		global $wpdb;
+
+		$snapshot = $order->get_meta( self::CART_ORDER_ITEMS_SNAPSHOT_META_KEY );
+		if ( ! is_array( $snapshot ) || ! is_array( $snapshot['hashes'] ?? null ) || ! is_array( $snapshot['items'] ?? null ) ) {
+			$order->remove_order_items();
+			return array();
+		}
+
+		$kept_types = array();
+		foreach ( self::CART_ORDER_ITEM_TYPES as $item_type ) {
+			$cart_section_unchanged = ( $snapshot['hashes'][ $item_type ] ?? '' ) === $cart_hashes[ $item_type ];
+			$order_items            = $cart_section_unchanged ? $order->get_items( $item_type ) : array();
+			$snapshot_items         = (array) ( $snapshot['items'][ $item_type ] ?? array() );
+
+			if ( ! $cart_section_unchanged || ! $this->order_items_match_snapshot( $order_items, $snapshot_items ) ) {
+				$order->remove_order_items( $item_type );
+				continue;
+			}
+
+			$kept_types[] = $item_type;
+			foreach ( array_keys( array_diff_key( $order_items, $snapshot_items ) ) as $item_id ) {
+				$order->remove_item( $item_id );
+			}
+		}
+
+		// Read the types directly: get_items() only finds types registered with woocommerce_order_type_to_group, so it would miss some extension items.
+		$persisted_types = $wpdb->get_col(
+			$wpdb->prepare(
+				"SELECT DISTINCT order_item_type FROM {$wpdb->prefix}woocommerce_order_items WHERE order_id = %d",
+				$order->get_id()
+			)
+		);
+		foreach ( array_diff( $persisted_types, self::CART_ORDER_ITEM_TYPES ) as $item_type ) {
+			$order->remove_order_items( $item_type );
+		}
+
+		return $kept_types;
+	}
+
+	/**
+	 * Whether every item in the snapshot is still on the order with the same fingerprint.
+	 *
+	 * @param WC_Order_Item[]    $order_items    The order's items of one type, keyed by ID.
+	 * @param array<int, string> $snapshot_items Fingerprints of the items the cart created, keyed by ID.
+	 * @return bool
+	 */
+	private function order_items_match_snapshot( array $order_items, array $snapshot_items ): bool {
+		foreach ( $snapshot_items as $item_id => $fingerprint ) {
+			if ( ! isset( $order_items[ $item_id ] ) || (string) $fingerprint !== $this->get_order_item_fingerprint( $order_items[ $item_id ] ) ) {
+				return false;
+			}
+		}
+
+		return true;
+	}
+
 	/**
 	 * Add line items to the 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 2d0f0880d94..c28903e582e 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
@@ -23,6 +23,11 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
 	 */
 	private $extra_field_filters = array();

+	/**
+	 * @var array The customer's location and VAT exemption before each test, restored on tear down.
+	 */
+	private $original_customer = array();
+
 	/**
 	 * Runs before each test.
 	 */
@@ -55,6 +60,9 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {

 		WC()->cart->empty_cart();

+		$customer                = WC()->customer;
+		$this->original_customer = array( $customer->get_billing_country(), $customer->get_billing_state(), $customer->get_shipping_country(), $customer->get_shipping_state(), $customer->get_is_vat_exempt() );
+
 		add_filter( 'woocommerce_checkout_registration_enabled', '__return_true' );
 	}

@@ -72,6 +80,12 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {

 		$this->extra_field_filters = array();

+		$customer = WC()->customer;
+		$customer->set_billing_location( $this->original_customer[0], $this->original_customer[1] );
+		$customer->set_shipping_location( $this->original_customer[2], $this->original_customer[3] );
+		$customer->set_is_vat_exempt( $this->original_customer[4] );
+		WC_Helper_Shipping::delete_simple_flat_rate();
+
 		parent::tearDown();
 	}

@@ -927,6 +941,817 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
 		$this->assertSame( 19.0, $tax_item->get_rate_percent() );
 	}

+	/**
+	 * @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 {
+		$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' );
+		$fee_item_calls  = did_action( 'woocommerce_checkout_create_order_fee_item' );
+
+		$resumed_id = $this->resume_order( $order_id );
+
+		$this->assertSame( $order_id, $resumed_id );
+		$this->assertSame( $before, $this->get_order_item_ids_by_type( $order_id ) );
+		$this->assertSame( $line_item_calls, did_action( 'woocommerce_checkout_create_order_line_item' ), 'Kept line items should not be created again.' );
+		$this->assertSame( $fee_item_calls, did_action( 'woocommerce_checkout_create_order_fee_item' ), 'Kept fees 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.' );
+	}
+
+	/**
+	 * @testdox Resuming a pending order whose $item_type cart section changed replaces only those items, and fires woocommerce_remove_order_items once for them.
+	 * @testWith ["fee"]
+	 *           ["shipping"]
+	 *           ["tax"]
+	 *           ["coupon"]
+	 *
+	 * @param string $item_type The item type whose cart section changed.
+	 */
+	public function test_create_order_resume_replaces_items_whose_cart_section_changed( string $item_type ): void {
+		$order_id = $this->create_order_with_shipping_taxes_and_coupon();
+		$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
+		);
+
+		$this->resume_order( $order_id );
+
+		$this->assertSame( array( $item_type ), $removed_types, '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.' );
+		$fresh_id = $this->sut->create_order( array( 'payment_method' => WC_Gateway_BACS::ID ) );
+		$this->assertIsInt( $fresh_id );
+		$this->assertSame( $this->get_order_item_amounts_by_type( $fresh_id ), $this->get_order_item_amounts_by_type( $order_id ), 'The resumed order should have the same items as a fresh order from the changed cart.' );
+	}
+
+	/**
+	 * @testdox Resuming a pending order keeps every original item when checkout fails before the order is saved.
+	 */
+	public function test_create_order_resume_keeps_original_items_when_checkout_fails(): void {
+		$order_id = $this->create_order_with_line_item_and_fee();
+		$this->change_cart_section( $order_id, 'fee' );
+		wc_add_order_item(
+			$order_id,
+			array(
+				'order_item_name' => 'Extension gift',
+				'order_item_type' => 'line_item',
+			)
+		);
+		$before = $this->get_order_item_ids_by_type( $order_id );
+		add_action(
+			'woocommerce_checkout_create_order',
+			function () {
+				throw new Exception( 'Simulated checkout failure' );
+			}
+		);
+
+		$result = $this->resume_order( $order_id );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Simulated checkout failure', $result->get_error_message() );
+		$this->assertCount( 2, $before['line_item'], 'The extension line item should be on the order before resuming.' );
+		$this->assertSame( $before, $this->get_order_item_ids_by_type( $order_id ), 'The changed fee and the extension line item should not be deleted.' );
+	}
+
+	/**
+	 * @testdox Resuming a pending order removes item types the cart doesn't track, so extensions that add them at checkout don't duplicate them.
+	 */
+	public function test_create_order_resume_removes_untracked_item_types(): void {
+		$order_id = $this->create_order_with_line_item_and_fee();
+		$before   = $this->get_order_item_ids_by_type( $order_id );
+		wc_add_order_item(
+			$order_id,
+			array(
+				'order_item_name' => 'Extension item',
+				'order_item_type' => 'test_extension_item',
+			)
+		);
+
+		$this->resume_order( $order_id );
+		$after = $this->get_order_item_ids_by_type( $order_id );
+
+		$this->assertArrayNotHasKey( 'test_extension_item', $after );
+		$this->assertSame( $before['line_item'], $after['line_item'] );
+	}
+
+	/**
+	 * @testdox Resuming a pending order doesn't duplicate items that an extension adds in woocommerce_checkout_create_order.
+	 */
+	public function test_create_order_resume_does_not_duplicate_items_added_in_create_order_action(): void {
+		add_action(
+			'woocommerce_checkout_create_order',
+			function ( WC_Order $order ) {
+				$fee = new WC_Order_Item_Fee();
+				$fee->set_name( 'Extension surcharge' );
+				$fee->set_total( '2' );
+				$order->add_item( $fee );
+
+				$line_item = new WC_Order_Item_Product();
+				$line_item->set_name( 'Extension gift' );
+				$line_item->set_quantity( 1 );
+				$order->add_item( $line_item );
+			}
+		);
+		$order_id = $this->create_order_with_line_item_and_fee();
+		$before   = $this->get_order_item_names_by_id( $order_id );
+
+		$this->resume_order( $order_id );
+		$this->resume_order( $order_id );
+		$after = $this->get_order_item_names_by_id( $order_id );
+
+		$this->assertSame( array( 'Dummy Product', 'Extension gift' ), array_values( $after['line_item'] ), 'Each line item should exist once.' );
+		$this->assertSame( array( 'Handling', 'Extension surcharge' ), array_values( $after['fee'] ), 'Each fee should exist once.' );
+		$this->assertSame( array_search( 'Dummy Product', $before['line_item'], true ), array_search( 'Dummy Product', $after['line_item'], true ), 'The cart line item should keep its ID.' );
+		$this->assertSame( array_search( 'Handling', $before['fee'], true ), array_search( 'Handling', $after['fee'], true ), 'The cart fee should keep its ID.' );
+	}
+
+	/**
+	 * @testdox Resuming a pending order without a snapshot of its cart items, such as one created before it was stored, replaces every item.
+	 */
+	public function test_create_order_resume_without_snapshot_replaces_all_items(): void {
+		$order_id = $this->create_order_with_line_item_and_fee();
+		$before   = $this->get_order_item_ids_by_type( $order_id );
+		$order    = wc_get_order( $order_id );
+		$order->delete_meta_data( '_cart_order_items_snapshot' );
+		$order->save();
+
+		$this->resume_order( $order_id );
+
+		$this->assert_items_replaced( $before, $this->get_order_item_ids_by_type( $order_id ), array_keys( $before ) );
+	}
+
+	/**
+	 * Tax setups for the resume test: an ordinary one, and one with a compound rate, prices including tax, rounding at
+	 * subtotal and a coupon, where cart and order rounding are most likely to differ.
+	 *
+	 * @return array<string, array{bool, bool, string[], string, int, array{string, string}|null}>
+	 */
+	public function provider_resume_tax_setups(): array {
+		return array(
+			'one rate, prices excluding tax, rounded per line'                       => array( false, false, array( '7.2500' ), '12.34', 1, null ),
+			'compound rates, prices including tax, rounded at subtotal, with coupon' => array( true, true, array( '10.0000', '5.0000c' ), '19.99', 3, array( 'percent', '15' ) ),
+		);
+	}
+
+	/**
+	 * @testdox Resuming an unchanged order keeps every item ID and the same totals as a fresh order, with prices including tax: $prices_include_tax, rounding at subtotal: $round_at_subtotal, price $price, quantity $quantity.
+	 * @dataProvider provider_resume_tax_setups
+	 *
+	 * @param bool                       $prices_include_tax Whether prices include tax.
+	 * @param bool                       $round_at_subtotal  Whether tax is rounded at subtotal.
+	 * @param string[]                   $rates              Tax rate percentages, applied in order. A "c" suffix makes the rate compound.
+	 * @param string                     $price              The product price.
+	 * @param int                        $quantity           How many of the product the cart has.
+	 * @param array{string, string}|null $coupon             The coupon's discount type and amount, added with a second cart line and a negative fee, or null for none of them.
+	 */
+	public function test_resume_keeps_items_and_matches_fresh_order_across_tax_setups( bool $prices_include_tax, bool $round_at_subtotal, array $rates, string $price, int $quantity, ?array $coupon ): void {
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		update_option( 'woocommerce_prices_include_tax', $prices_include_tax ? 'yes' : 'no' );
+		update_option( 'woocommerce_tax_round_at_subtotal', $round_at_subtotal ? 'yes' : 'no' );
+		foreach ( $rates as $index => $rate ) {
+			$is_compound = 'c' === substr( $rate, -1 );
+			$this->insert_tax_rate( '', rtrim( $rate, 'c' ), "Tax {$index}", $index + 1, $is_compound );
+		}
+		WC()->customer->set_shipping_location( 'US', 'CA' );
+		WC()->customer->set_billing_location( 'US', 'CA' );
+		WC_Helper_Shipping::create_simple_flat_rate( 10 );
+		add_action(
+			'woocommerce_cart_calculate_fees',
+			function ( WC_Cart $cart ) use ( $coupon ) {
+				$cart->add_fee( 'Handling', 5, true );
+				if ( $coupon ) {
+					// A negative taxable fee splits its tax by each item's share of the cart, so with a zero-rate item each rate's tax has more decimals to round.
+					$cart->add_fee( 'Discount', -1.37, true );
+				}
+			}
+		);
+
+		$product = WC_Helper_Product::create_simple_product( true, array( 'regular_price' => $price ) );
+		WC()->cart->add_to_cart( $product->get_id(), $quantity );
+		if ( $coupon ) {
+			$second_product = WC_Helper_Product::create_simple_product(
+				true,
+				array(
+					'regular_price' => '3.33',
+					'tax_class'     => 'zero-rate',
+				)
+			);
+			WC()->cart->add_to_cart( $second_product->get_id(), 2 );
+			$sweep_coupon = WC_Helper_Coupon::create_coupon(
+				'resume-sweep',
+				array(
+					'discount_type' => $coupon[0],
+					'coupon_amount' => $coupon[1],
+				)
+			);
+			WC()->cart->apply_coupon( $sweep_coupon->get_code() );
+		}
+		WC()->cart->calculate_totals();
+		$this->assertTrue( WC()->cart->has_calculated_shipping(), 'The cart should have calculated shipping.' );
+
+		$order_id = $this->sut->create_order( array( 'payment_method' => WC_Gateway_BACS::ID ) );
+		$this->assertIsInt( $order_id );
+		$before = $this->get_order_item_ids_by_type( $order_id );
+		$this->resume_order( $order_id );
+		$fresh_id = $this->sut->create_order( array( 'payment_method' => WC_Gateway_BACS::ID ) );
+		$this->assertIsInt( $fresh_id );
+		$after = $this->get_order_item_ids_by_type( $order_id );
+
+		$expected_types = $coupon ? array( 'coupon', 'fee', 'line_item', 'shipping', 'tax' ) : array( 'fee', 'line_item', 'shipping', 'tax' );
+		$this->assertSame( $expected_types, array_keys( $before ), 'The order should have every cart item type.' );
+		$this->assertSame( $before, $after, 'Every item should keep its ID.' );
+		$this->assertNotSame( $order_id, $fresh_id, 'The fresh order should be a new order.' );
+
+		$resumed = wc_get_order( $order_id );
+		$fresh   = wc_get_order( $fresh_id );
+		foreach ( array( 'total', 'total_tax', 'cart_tax', 'shipping_total', 'shipping_tax', 'discount_total', 'discount_tax' ) as $prop ) {
+			$this->assertSame( $fresh->{"get_{$prop}"}(), $resumed->{"get_{$prop}"}(), "The resumed order's {$prop} should match a fresh order's." );
+		}
+		$this->assertSame( $this->get_order_item_amounts_by_type( $fresh_id ), $this->get_order_item_amounts_by_type( $order_id ), "The resumed order's items should have the same amounts as a fresh order's." );
+	}
+
+	/**
+	 * @testdox Resuming a pending order replaces the items whose amounts a merchant edited, and keeps items edited without changing amounts.
+	 * @testWith ["none", ""]
+	 *           ["line_item_name", ""]
+	 *           ["line_item_tax", "line_item"]
+	 *           ["line_item_tax_split", "line_item"]
+	 *           ["shipping_total", "shipping"]
+	 *           ["shipping_tax", "shipping"]
+	 *           ["shipping_tax_split", "shipping"]
+	 *           ["tax_line", "tax"]
+	 *           ["fee_total", "fee"]
+	 *           ["fee_tax", "fee"]
+	 *           ["fee_tax_split", "fee"]
+	 *           ["coupon_discount", "coupon"]
+	 *
+	 * @param string $edit          The merchant edit to make to the order.
+	 * @param string $replaced_type The item type that should be replaced, or an empty string when none should be.
+	 */
+	public function test_create_order_resume_replaces_items_with_edited_amounts( string $edit, string $replaced_type ): void {
+		$order_id         = $this->create_order_with_shipping_taxes_and_coupon();
+		$before           = $this->get_order_item_ids_by_type( $order_id );
+		$expected_amounts = $this->get_order_item_amounts_by_type( $order_id );
+		$this->edit_order_item_amounts( wc_get_order( $order_id ), $edit );
+
+		$this->resume_order( $order_id );
+		$order = wc_get_order( $order_id );
+
+		$this->assertSame( array( 'coupon', 'fee', 'line_item', 'shipping', 'tax' ), array_keys( $before ), 'The order should have every cart item type.' );
+		$this->assert_items_replaced( $before, $this->get_order_item_ids_by_type( $order_id ), array_filter( array( $replaced_type ) ) );
+		$this->assertSame( (float) WC()->cart->get_total( 'edit' ), (float) $order->get_total(), 'The order total should match the cart.' );
+		$this->assertEqualsWithDelta( (float) WC()->cart->get_total_tax(), (float) $order->get_total_tax(), 0.001, 'The order tax total should match the cart.' );
+		$this->assertEqualsWithDelta( (float) WC()->cart->get_shipping_total(), (float) $order->get_shipping_total(), 0.001, 'The order shipping total should match the cart.' );
+		$this->assertSame( $expected_amounts, $this->get_order_item_amounts_by_type( $order_id ), 'The resumed items should have their original amounts and tax allocations.' );
+	}
+
+	/**
+	 * @testdox Resuming a pending order replaces shipping lines when a shipping rate filter changes their values without changing the cart total.
+	 */
+	public function test_create_order_resume_replaces_shipping_changed_by_rate_filter(): void {
+		$order_id = $this->create_order_with_shipping_taxes_and_coupon();
+		$before   = $this->get_order_item_ids_by_type( $order_id );
+		// Moves shipping tax from one rate to the other, so the cart total stays the same.
+		add_filter(
+			'woocommerce_shipping_rate_taxes',
+			function ( $taxes ) {
+				$rate_ids               = array_keys( $taxes );
+				$taxes[ $rate_ids[0] ] += 0.1;
+				$taxes[ $rate_ids[1] ] -= 0.1;
+				return $taxes;
+			}
+		);
+		WC()->cart->calculate_totals();
+		$expected_taxes = array_map( 'wc_format_decimal', current( WC()->cart->get_shipping_methods() )->get_taxes() );
+
+		$this->resume_order( $order_id );
+		$shipping = array_values( wc_get_order( $order_id )->get_shipping_methods() )[0];
+
+		$this->assertNotSame( $before['shipping'], $this->get_order_item_ids_by_type( $order_id )['shipping'], 'The shipping line should be replaced.' );
+		$this->assertSame( $expected_taxes, $shipping->get_taxes()['total'], 'The shipping line should have the filtered taxes.' );
+	}
+
+	/**
+	 * @testdox Resuming a pending order adds back a cart item that failed to save on the previous attempt.
+	 */
+	public function test_create_order_resume_rebuilds_type_with_item_that_failed_to_save(): void {
+		add_action(
+			'woocommerce_cart_calculate_fees',
+			function ( WC_Cart $cart ) {
+				$cart->add_fee( 'Gift wrap', 3 );
+			}
+		);
+		$fail_gift_wrap = function ( $item ) {
+			if ( $item instanceof WC_Order_Item_Fee && 'Gift wrap' === $item->get_name() ) {
+				throw new Exception( 'Simulated item save failure' );
+			}
+		};
+		add_action( 'woocommerce_before_order_item_object_save', $fail_gift_wrap );
+		$order_id = $this->create_order_with_line_item_and_fee();
+		remove_action( 'woocommerce_before_order_item_object_save', $fail_gift_wrap );
+		$this->assertCount( 1, wc_get_order( $order_id )->get_fees(), 'The gift wrap fee should fail to save on the first attempt.' );
+
+		$this->resume_order( $order_id );
+		$order = wc_get_order( $order_id );
+
+		$this->assertCount( 2, $order->get_fees(), 'Both cart fees should be on the resumed order.' );
+		$this->assertSame( (float) WC()->cart->get_total( 'edit' ), (float) $order->get_total(), 'The order total should match the cart.' );
+	}
+
+	/**
+	 * @testdox Resuming a pending order keeps items that a gateway added meta to after checkout.
+	 */
+	public function test_create_order_resume_keeps_items_with_meta_added_after_checkout(): void {
+		$order_id = $this->create_order_with_line_item_and_fee();
+		$before   = $this->get_order_item_ids_by_type( $order_id );
+		foreach ( wc_get_order( $order_id )->get_items( array( 'line_item', 'fee' ) ) as $item ) {
+			$item->update_meta_data( '_gateway_line_reference', 'ref-' . $item->get_id() );
+			$item->save();
+		}
+
+		$this->resume_order( $order_id );
+
+		$this->assertSame( $before, $this->get_order_item_ids_by_type( $order_id ) );
+	}
+
+	/**
+	 * @testdox Resuming a pending order rebuilds items an extension changed in woocommerce_checkout_create_order, so the change isn't applied twice.
+	 */
+	public function test_create_order_resume_does_not_reapply_changes_made_in_create_order_action(): void {
+		add_action(
+			'woocommerce_checkout_create_order',
+			function ( WC_Order $order ) {
+				foreach ( $order->get_fees() as $fee ) {
+					$fee->set_total( (float) $fee->get_total() * 0.9 );
+				}
+				$order->calculate_totals( false );
+			}
+		);
+		$order_id = $this->create_order_with_line_item_and_fee();
+		$before   = $this->get_order_item_ids_by_type( $order_id );
+
+		$this->resume_order( $order_id );
+		$this->resume_order( $order_id );
+		$after = $this->get_order_item_ids_by_type( $order_id );
+		$order = wc_get_order( $order_id );
+
+		$this->assertSame( $before['line_item'], $after['line_item'], 'Line items should keep their IDs.' );
+		$this->assertNotSame( $before['fee'], $after['fee'], 'The changed fee should be rebuilt.' );
+		$this->assertSame( 4.5, (float) array_values( $order->get_fees() )[0]->get_total(), 'The extension should take 10% off the cart fee once.' );
+	}
+
+	/**
+	 * @testdox Resuming a pending order adds a cart item back when a merchant deleted it after checkout.
+	 */
+	public function test_create_order_resume_replaces_type_with_deleted_cart_item(): void {
+		$order_id = $this->create_order_with_line_item_and_fee();
+		$before   = $this->get_order_item_ids_by_type( $order_id );
+		$order    = wc_get_order( $order_id );
+		$order->remove_item( $before['fee'][0] );
+		$order->save();
+
+		$this->resume_order( $order_id );
+		$after = $this->get_order_item_ids_by_type( $order_id );
+
+		$this->assertSame( $before['line_item'], $after['line_item'], 'Line items should keep their IDs.' );
+		$this->assertCount( 1, $after['fee'], 'The fee should be added again.' );
+		$this->assertSame( (float) WC()->cart->get_total( 'edit' ), (float) wc_get_order( $order_id )->get_total(), 'The order total should match the cart.' );
+	}
+
+	/**
+	 * @testdox Resuming a pending order replaces every item when woocommerce_checkout_keep_order_items_on_resume returns false.
+	 */
+	public function test_create_order_resume_replaces_all_items_when_keeping_is_filtered_off(): void {
+		$order_id = $this->create_order_with_line_item_and_fee();
+		$before   = $this->get_order_item_ids_by_type( $order_id );
+		add_filter( 'woocommerce_checkout_keep_order_items_on_resume', '__return_false' );
+		$line_item_hook_calls = did_action( 'woocommerce_checkout_create_order_line_item' );
+
+		$this->resume_order( $order_id );
+		$after = $this->get_order_item_ids_by_type( $order_id );
+
+		$this->assertSame( $line_item_hook_calls + 1, did_action( 'woocommerce_checkout_create_order_line_item' ), 'The line item hook should fire again.' );
+		$this->assert_items_replaced( $before, $after, array_keys( $before ) );
+	}
+
+	/**
+	 * @testdox Resuming a pending order replaces line items that no longer match the cart, even when the cart hash still matches.
+	 */
+	public function test_create_order_resume_replaces_line_items_when_cart_hash_misses_a_quantity_change(): void {
+		// A cart hash that only covers product IDs, so a quantity change doesn't change it.
+		add_filter(
+			'woocommerce_cart_hash',
+			function ( $hash, $cart_session ) {
+				unset( $hash );
+				return md5( wp_json_encode( wp_list_pluck( (array) $cart_session, 'product_id' ) ) );
+			},
+			10,
+			2
+		);
+		$order_id = $this->create_order_with_line_item_and_fee();
+		$before   = $this->get_order_item_ids_by_type( $order_id );
+		WC()->cart->set_quantity( array_key_first( WC()->cart->get_cart() ), 3 );
+		WC()->cart->calculate_totals();
+
+		$resumed_id = $this->resume_order( $order_id );
+
+		$this->assertSame( $order_id, $resumed_id, 'The order should still be resumed.' );
+		$this->assert_line_items_match_cart( $order_id );
+		$this->assertNotSame( $before['line_item'], $this->get_order_item_ids_by_type( $order_id )['line_item'], 'The line item should be replaced.' );
+	}
+
+	/**
+	 * @testdox Resuming a pending order replaces line items a merchant edited, so they match the cart again.
+	 */
+	public function test_create_order_resume_replaces_line_items_edited_after_checkout(): void {
+		$order_id = $this->create_order_with_line_item_and_fee();
+		$before   = $this->get_order_item_ids_by_type( $order_id );
+		$order    = wc_get_order( $order_id );
+		$item     = array_values( $order->get_items() )[0];
+		$item->set_quantity( 2 );
+		$item->set_subtotal( (float) $item->get_subtotal() * 2 );
+		$item->set_total( (float) $item->get_total() * 2 );
+		$item->save();
+		$order->calculate_totals();
+
+		$this->resume_order( $order_id );
+
+		$this->assert_line_items_match_cart( $order_id );
+		$this->assertNotSame( $before['line_item'], $this->get_order_item_ids_by_type( $order_id )['line_item'], 'The edited line item should be replaced.' );
+	}
+
+	/**
+	 * @testdox An Error thrown while resuming an order doesn't make a later set_data_from_cart() call skip item types.
+	 */
+	public function test_create_order_resume_error_does_not_leave_item_types_skipped(): void {
+		$order_id = $this->create_order_with_line_item_and_fee();
+		// This filter runs after the resumed order's kept item types are set and before set_data_from_cart().
+		add_filter(
+			'woocommerce_checkout_customer_id',
+			function () {
+				throw new Error( 'Extension error' );
+			}
+		);
+
+		try {
+			$this->resume_order( $order_id );
+			$this->fail( 'The Error should reach the caller.' );
+		} catch ( Error $e ) {
+			$this->assertSame( 'Extension error', $e->getMessage() );
+		}
+
+		$order = new WC_Order();
+		$this->sut->set_data_from_cart( $order );
+
+		$this->assertCount( 1, $order->get_items(), 'set_data_from_cart() should add the line item.' );
+	}
+
+	/**
+	 * Creates an order from a cart with one product and one fee.
+	 *
+	 * @return int The order ID.
+	 */
+	private function create_order_with_line_item_and_fee(): int {
+		$product = WC_Helper_Product::create_simple_product();
+		add_action(
+			'woocommerce_cart_calculate_fees',
+			function ( WC_Cart $cart ) {
+				$cart->add_fee( 'Handling', 5 );
+			}
+		);
+		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 ) );
+		$this->assertIsInt( $order_id );
+
+		return $order_id;
+	}
+
+	/**
+	 * Creates an order from a cart taxed at two rates, with one product, one fee, a coupon and flat rate shipping, shipped to Germany.
+	 *
+	 * @return int The order ID.
+	 */
+	private function create_order_with_shipping_taxes_and_coupon(): int {
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		$this->insert_tax_rate( 'DE', '19.0000', 'VAT', 1 );
+		$this->insert_tax_rate( 'DE', '5.0000', 'Local tax', 2 );
+		WC_Helper_Shipping::create_simple_flat_rate( 10 );
+		WC()->customer->set_billing_country( 'DE' );
+		WC()->customer->set_shipping_country( 'DE' );
+		WC()->customer->set_is_vat_exempt( false );
+		$coupon = WC_Helper_Coupon::create_coupon( 'resume-coupon' );
+
+		$product = WC_Helper_Product::create_simple_product();
+		add_action(
+			'woocommerce_cart_calculate_fees',
+			function ( WC_Cart $cart ) {
+				$cart->add_fee( 'Handling', 5, true );
+			}
+		);
+		WC()->cart->add_to_cart( $product->get_id(), 1 );
+		WC()->cart->apply_coupon( $coupon->get_code() );
+		WC()->cart->calculate_totals();
+		$this->assertTrue( WC()->cart->has_calculated_shipping(), 'The cart should have calculated shipping.' );
+
+		$order_id = $this->sut->create_order( array( 'payment_method' => WC_Gateway_BACS::ID ) );
+		$this->assertIsInt( $order_id );
+
+		return $order_id;
+	}
+
+	/**
+	 * Edits an order's items the way a merchant can in the order editor, without recalculating the order.
+	 *
+	 * @param WC_Order $order The order.
+	 * @param string   $edit  The edit to make.
+	 */
+	private function edit_order_item_amounts( WC_Order $order, string $edit ): void {
+		$line_item = array_values( $order->get_items() )[0];
+		$shipping  = array_values( $order->get_shipping_methods() )[0];
+		$tax       = array_values( $order->get_taxes() )[0];
+		$fee       = array_values( $order->get_fees() )[0];
+		$coupon    = array_values( $order->get_coupons() )[0];
+		$rate_id   = $tax->get_rate_id();
+
+		switch ( $edit ) {
+			case 'line_item_name':
+				$line_item->set_name( 'Renamed product' );
+				$line_item->save();
+				break;
+			case 'line_item_tax':
+				$line_item->set_taxes(
+					array(
+						'total'    => array( $rate_id => (float) $line_item->get_total_tax() + 1 ),
+						'subtotal' => array( $rate_id => (float) $line_item->get_subtotal_tax() + 1 ),
+					)
+				);
+				$line_item->save();
+				break;
+			case 'line_item_tax_split':
+			case 'shipping_tax_split':
+			case 'fee_tax_split':
+				// Moves tax from one rate to the other, so the item's tax sums don't change.
+				$item        = array(
+					'line_item_tax_split' => $line_item,
+					'shipping_tax_split'  => $shipping,
+					'fee_tax_split'       => $fee,
+				)[ $edit ];
+				$taxes       = $item->get_taxes();
+				$first_rate  = array_key_first( $taxes['total'] );
+				$second_rate = array_key_last( $taxes['total'] );
+				foreach ( array_keys( $taxes ) as $tax_type ) {
+					$taxes[ $tax_type ][ $first_rate ]  = (float) $taxes[ $tax_type ][ $first_rate ] + 0.1;
+					$taxes[ $tax_type ][ $second_rate ] = (float) $taxes[ $tax_type ][ $second_rate ] - 0.1;
+				}
+				$item->set_taxes( $taxes );
+				$item->save();
+				break;
+			case 'shipping_total':
+				$shipping->set_total( (float) $shipping->get_total() + 5 );
+				$shipping->save();
+				break;
+			case 'shipping_tax':
+				$shipping->set_taxes( array( 'total' => array( $rate_id => (float) $shipping->get_total_tax() + 1 ) ) );
+				$shipping->save();
+				break;
+			case 'tax_line':
+				$tax->set_tax_total( (float) $tax->get_tax_total() + 1 );
+				$tax->save();
+				break;
+			case 'fee_total':
+				$fee->set_total( (float) $fee->get_total() + 1 );
+				$fee->save();
+				break;
+			case 'fee_tax':
+				$fee->set_taxes( array( 'total' => array( $rate_id => (float) $fee->get_total_tax() + 1 ) ) );
+				$fee->save();
+				break;
+			case 'coupon_discount':
+				$coupon->set_discount( (float) $coupon->get_discount() + 1 );
+				$coupon->save();
+				break;
+		}
+	}
+
+	/**
+	 * Asserts that an order's line items have the cart's quantities and totals.
+	 *
+	 * @param int $order_id The order ID.
+	 */
+	private function assert_line_items_match_cart( int $order_id ): void {
+		$order      = wc_get_order( $order_id );
+		$line_items = array_values( $order->get_items() );
+		$cart_items = array_values( WC()->cart->get_cart() );
+
+		$this->assertCount( count( $cart_items ), $line_items, 'The order should have one line item per cart item.' );
+		$this->assertSame( (int) $cart_items[0]['quantity'], (int) $line_items[0]->get_quantity(), 'The line item quantity should match the cart.' );
+		$this->assertSame( (float) $cart_items[0]['line_total'], (float) $line_items[0]->get_total(), 'The line item total should match the cart.' );
+	}
+
+	/**
+	 * Asserts that each item type in $replaced_types was replaced once, and every other type kept its item IDs.
+	 *
+	 * @param array<string, int[]> $before         Item IDs by type before resuming.
+	 * @param array<string, int[]> $after          Item IDs by type after resuming.
+	 * @param string[]             $replaced_types The item types that should be replaced.
+	 */
+	private function assert_items_replaced( array $before, array $after, array $replaced_types ): void {
+		$this->assertSame( array_keys( $before ), array_keys( $after ), 'The same item types should exist.' );
+		foreach ( $before as $type => $ids ) {
+			if ( in_array( $type, $replaced_types, true ) ) {
+				$this->assertCount( count( $ids ), $after[ $type ], "The {$type} items should be added again once." );
+				$this->assertEmpty( array_intersect( $ids, $after[ $type ] ), "The {$type} items should be replaced." );
+			} else {
+				$this->assertSame( $ids, $after[ $type ], "The {$type} items should keep their IDs." );
+			}
+		}
+	}
+
+	/**
+	 * Changes one section of the cart without changing the cart total, so the order is still resumed. Taxes can't change
+	 * on their own, since their rate IDs are also in the other items' taxes, so for taxes this changes the stored hash instead.
+	 *
+	 * @param int    $order_id  The order ID.
+	 * @param string $item_type The order item type whose cart section changes.
+	 */
+	private function change_cart_section( int $order_id, string $item_type ): void {
+		switch ( $item_type ) {
+			case 'fee':
+				add_action(
+					'woocommerce_cart_calculate_fees',
+					function ( WC_Cart $cart ) {
+						foreach ( $cart->get_fees() as $fee ) {
+							$fee->name = 'Packaging';
+						}
+					},
+					20
+				);
+				break;
+			case 'shipping':
+				add_filter(
+					'woocommerce_package_rates',
+					function ( array $rates ) {
+						$express = clone current( $rates );
+						$express->set_id( 'express' );
+						$express->set_method_id( 'express' );
+						$rates['express'] = $express;
+						return $rates;
+					}
+				);
+				WC_Cache_Helper::get_transient_version( 'shipping', true );
+				// The chosen method resets to the default when the number of rates changes, so choose it once the new rate exists.
+				WC()->cart->calculate_totals();
+				WC()->session->set( 'chosen_shipping_methods', array( 'express' ) );
+				break;
+			case 'coupon':
+				WC()->cart->remove_coupon( 'resume-coupon' );
+				WC()->cart->apply_coupon( WC_Helper_Coupon::create_coupon( 'other-coupon' )->get_code() );
+				break;
+			case 'tax':
+				$order    = wc_get_order( $order_id );
+				$snapshot = $order->get_meta( '_cart_order_items_snapshot' );
+
+				$snapshot['hashes'][ $item_type ] = 'stale';
+				$order->update_meta_data( '_cart_order_items_snapshot', $snapshot );
+				$order->save();
+				return;
+		}
+
+		WC()->cart->calculate_totals();
+	}
+
+	/**
+	 * Adds a tax rate that also applies to shipping.
+	 *
+	 * @param string $country  The country code, or an empty string for every country.
+	 * @param string $rate     The rate percentage.
+	 * @param string $name     The rate name.
+	 * @param int    $priority The rate priority, also used as its order.
+	 * @param bool   $compound Whether the rate is compound.
+	 */
+	private function insert_tax_rate( string $country, string $rate, string $name, int $priority, bool $compound = false ): void {
+		WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => $country,
+				'tax_rate'          => $rate,
+				'tax_rate_name'     => $name,
+				'tax_rate_priority' => (string) $priority,
+				'tax_rate_compound' => $compound ? '1' : '0',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => (string) $priority,
+				'tax_rate_class'    => '',
+			)
+		);
+	}
+
+	/**
+	 * 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.
+	 * @return int|WP_Error The order ID returned by create_order().
+	 */
+	private function resume_order( int $order_id ) {
+		$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 ) );
+		} finally {
+			WC()->session->set( 'order_awaiting_payment', $original );
+		}
+	}
+
+	/**
+	 * Reads an order's persisted item IDs, grouped by item type.
+	 *
+	 * @param int $order_id The order ID.
+	 * @return array<string, int[]>
+	 */
+	private function get_order_item_ids_by_type( int $order_id ): array {
+		$ids_by_type = array_map( 'array_keys', $this->get_order_item_names_by_id( $order_id ) );
+		ksort( $ids_by_type );
+
+		return $ids_by_type;
+	}
+
+	/**
+	 * Reads the amounts of an order's items, grouped by item type and sorted, so two orders built from the same cart compare equal.
+	 *
+	 * @param int $order_id The order ID.
+	 * @return array<string, string[]>
+	 */
+	private function get_order_item_amounts_by_type( int $order_id ): array {
+		$order           = wc_get_order( $order_id );
+		$amounts_by_type = array();
+		foreach ( $order instanceof WC_Order ? $order->get_items( array( 'line_item', 'shipping', 'fee', 'coupon', 'tax' ) ) : array() as $item ) {
+			if ( $item instanceof WC_Order_Item_Product ) {
+				$values = array( $item->get_product_id(), $item->get_quantity(), $item->get_subtotal(), $item->get_total(), $item->get_subtotal_tax(), $item->get_total_tax(), $item->get_taxes() );
+			} elseif ( $item instanceof WC_Order_Item_Shipping ) {
+				$values = array( $item->get_method_id(), $item->get_total(), $item->get_total_tax(), $item->get_taxes() );
+			} elseif ( $item instanceof WC_Order_Item_Fee ) {
+				$values = array( $item->get_name(), $item->get_total(), $item->get_total_tax(), $item->get_taxes() );
+			} elseif ( $item instanceof WC_Order_Item_Coupon ) {
+				$values = array( $item->get_code(), $item->get_discount(), $item->get_discount_tax() );
+			} elseif ( $item instanceof WC_Order_Item_Tax ) {
+				$values = array( $item->get_rate_id(), $item->get_tax_total(), $item->get_shipping_tax_total() );
+			} else {
+				continue;
+			}
+			array_walk_recursive(
+				$values,
+				function ( &$value ) {
+					$value = is_numeric( $value ) ? (string) wc_format_decimal( $value ) : (string) $value;
+				}
+			);
+			$amounts_by_type[ $item->get_type() ][] = (string) wp_json_encode( $values );
+		}
+		ksort( $amounts_by_type );
+
+		return array_map(
+			function ( array $amounts ): array {
+				sort( $amounts );
+				return $amounts;
+			},
+			$amounts_by_type
+		);
+	}
+
+	/**
+	 * Reads an order's persisted item names, keyed by item ID and grouped by item type.
+	 *
+	 * @param int $order_id The order ID.
+	 * @return array<string, array<int, string>>
+	 */
+	private function get_order_item_names_by_id( int $order_id ): array {
+		global $wpdb;
+
+		$rows = $wpdb->get_results(
+			$wpdb->prepare(
+				"SELECT order_item_id, order_item_name, order_item_type FROM {$wpdb->prefix}woocommerce_order_items WHERE order_id = %d ORDER BY order_item_id",
+				$order_id
+			)
+		);
+
+		$names_by_type = array();
+		foreach ( $rows as $row ) {
+			$names_by_type[ $row->order_item_type ][ (int) $row->order_item_id ] = $row->order_item_name;
+		}
+
+		return $names_by_type;
+	}
+
 	/**
 	 * @testdox create_order_fee_lines sets tax status to 'none' for non-taxable cart fees and 'taxable' for taxable ones.
 	 *