Commit 62be524ca5 for woocommerce

commit 62be524ca57fa37c98e5c0d328ab021de5eda85c
Author: Néstor Soriano <konamiman@konamiman.com>
Date:   Thu Feb 5 10:30:36 2026 +0100

    Add the OrdersVersionStringInvalidator class (#63079)

diff --git a/plugins/woocommerce/changelog/pr-63079 b/plugins/woocommerce/changelog/pr-63079
new file mode 100644
index 0000000000..3573138e0a
--- /dev/null
+++ b/plugins/woocommerce/changelog/pr-63079
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Add the OrdersVersionStringInvalidator class
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 8efb7e9b3e..c81a67655e 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -35,6 +35,7 @@ use Automattic\WooCommerce\Utilities\{LoggingUtil, TimeUtil};
 use Automattic\WooCommerce\Internal\Logging\RemoteLogger;
 use Automattic\WooCommerce\Caches\OrderCountCacheService;
 use Automattic\WooCommerce\Internal\Caches\ProductVersionStringInvalidator;
+use Automattic\WooCommerce\Internal\Caches\OrdersVersionStringInvalidator;
 use Automattic\WooCommerce\Internal\Caches\TaxRateVersionStringInvalidator;
 use Automattic\WooCommerce\Internal\StockNotifications\StockNotifications;
 use Automattic\Jetpack\Constants;
@@ -361,6 +362,7 @@ final class WooCommerce {
 		$container->get( AbilitiesRegistry::class );
 		$container->get( MCPAdapterProvider::class );
 		$container->get( ProductVersionStringInvalidator::class );
+		$container->get( OrdersVersionStringInvalidator::class );
 		$container->get( TaxRateVersionStringInvalidator::class );

 		// Feature flags.
diff --git a/plugins/woocommerce/src/Internal/Caches/OrdersVersionStringInvalidator.php b/plugins/woocommerce/src/Internal/Caches/OrdersVersionStringInvalidator.php
new file mode 100644
index 0000000000..f269f8337a
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Caches/OrdersVersionStringInvalidator.php
@@ -0,0 +1,346 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Caches;
+
+/**
+ * Order version string invalidation handler.
+ *
+ * This class provides an 'invalidate' method that will invalidate
+ * the version string for a given order, which in turn invalidates
+ * any cached REST API responses containing that order.
+ */
+class OrdersVersionStringInvalidator {
+
+	/**
+	 * Stores the customer ID of orders before they are saved.
+	 * Used to detect customer changes that require list invalidation.
+	 *
+	 * @var array<int, int> Order ID => Customer ID
+	 */
+	private array $pre_save_customer_ids = array();
+
+	/**
+	 * Initialize the invalidator and register hooks.
+	 *
+	 * Hooks are only registered when both conditions are met:
+	 * - The REST API caching feature is enabled
+	 * - The backend caching setting is active
+	 *
+	 * @return void
+	 *
+	 * @since 10.6.0
+	 *
+	 * @internal
+	 */
+	final public function init(): void {
+		// We can't use FeaturesController::feature_is_enabled at this point
+		// (before the 'init' action is triggered) because that would cause
+		// "Translation loading for the woocommerce domain was triggered too early" warnings.
+		if ( 'yes' !== get_option( 'woocommerce_feature_rest_api_caching_enabled' ) ) {
+			return;
+		}
+
+		if ( 'yes' === get_option( 'woocommerce_rest_api_enable_backend_caching', 'no' ) ) {
+			$this->register_hooks();
+		}
+	}
+
+	/**
+	 * Register all order-related hooks.
+	 *
+	 * Only WooCommerce hooks are registered (not WordPress post hooks) since these always fire
+	 * when an order is created/updated/deleted via the WooCommerce APIs, regardless of HPOS
+	 * being active or not.
+	 *
+	 * @return void
+	 */
+	private function register_hooks(): void {
+		// Hook to capture customer ID before save for change detection.
+		add_action( 'woocommerce_before_order_object_save', array( $this, 'handle_before_order_save' ), 10, 1 );
+
+		// WooCommerce CRUD hooks for orders.
+		add_action( 'woocommerce_new_order', array( $this, 'handle_woocommerce_new_order' ), 10, 2 );
+		add_action( 'woocommerce_update_order', array( $this, 'handle_woocommerce_update_order' ), 10, 2 );
+		add_action( 'woocommerce_before_delete_order', array( $this, 'handle_woocommerce_before_delete_order' ), 10, 2 );
+		add_action( 'woocommerce_trash_order', array( $this, 'handle_woocommerce_trash_order' ), 10, 1 );
+		add_action( 'woocommerce_untrash_order', array( $this, 'handle_woocommerce_untrash_order' ), 10, 2 );
+
+		// Status change hook.
+		add_action( 'woocommerce_order_status_changed', array( $this, 'handle_woocommerce_order_status_changed' ), 10, 4 );
+
+		// Refund hooks.
+		add_action( 'woocommerce_order_refunded', array( $this, 'handle_woocommerce_order_refunded' ), 10, 2 );
+		add_action( 'woocommerce_refund_deleted', array( $this, 'handle_woocommerce_refund_deleted' ), 10, 2 );
+	}
+
+	// phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+
+	/**
+	 * Handle the woocommerce_before_order_object_save hook.
+	 *
+	 * Captures the customer ID before save to detect changes.
+	 *
+	 * @param \WC_Order $order The order being saved.
+	 *
+	 * @return void
+	 *
+	 * @since 10.6.0
+	 *
+	 * @internal
+	 */
+	public function handle_before_order_save( $order ): void {
+		if ( ! $order instanceof \WC_Order || 'shop_order' !== $order->get_type() ) {
+			return;
+		}
+
+		$order_id = $order->get_id();
+		if ( $order_id > 0 ) {
+			$this->pre_save_customer_ids[ $order_id ] = (int) $order->get_data()['customer_id'];
+		}
+	}
+
+	/**
+	 * Handle the woocommerce_new_order hook.
+	 *
+	 * @param int       $order_id The order ID.
+	 * @param \WC_Order $order    The order object.
+	 *
+	 * @return void
+	 *
+	 * @since 10.6.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_new_order( $order_id, $order ): void {
+		$this->invalidate( (int) $order_id );
+		$this->invalidate_orders_list();
+	}
+
+	/**
+	 * Handle the woocommerce_update_order hook.
+	 *
+	 * @param int       $order_id The order ID.
+	 * @param \WC_Order $order    The order object.
+	 *
+	 * @return void
+	 *
+	 * @since 10.6.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_update_order( $order_id, $order ): void {
+		$order_id = (int) $order_id;
+		$this->invalidate( $order_id );
+
+		if ( $this->did_customer_change( $order_id, $order ) ) {
+			$this->invalidate_orders_list();
+		}
+
+		unset( $this->pre_save_customer_ids[ $order_id ] );
+	}
+
+	/**
+	 * Check if the customer ID changed during the update.
+	 *
+	 * @param int       $order_id The order ID.
+	 * @param \WC_Order $order    The order object (after save).
+	 *
+	 * @return bool True if customer changed.
+	 */
+	private function did_customer_change( int $order_id, $order ): bool {
+		if ( ! isset( $this->pre_save_customer_ids[ $order_id ] ) ) {
+			return false;
+		}
+
+		$old_customer_id = $this->pre_save_customer_ids[ $order_id ];
+		$new_customer_id = $order instanceof \WC_Order ? (int) $order->get_customer_id() : 0;
+
+		return $old_customer_id !== $new_customer_id;
+	}
+
+	/**
+	 * Handle the woocommerce_before_delete_order hook.
+	 *
+	 * @param int       $order_id The order ID.
+	 * @param \WC_Order $order    The order object.
+	 *
+	 * @return void
+	 *
+	 * @since 10.6.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_before_delete_order( $order_id, $order ): void {
+		$this->invalidate( (int) $order_id );
+		$this->invalidate_orders_list();
+	}
+
+	/**
+	 * Handle the woocommerce_trash_order hook.
+	 *
+	 * @param int $order_id The order ID.
+	 *
+	 * @return void
+	 *
+	 * @since 10.6.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_trash_order( $order_id ): void {
+		$this->invalidate( (int) $order_id );
+		$this->invalidate_orders_list();
+	}
+
+	/**
+	 * Handle the woocommerce_untrash_order hook.
+	 *
+	 * @param int    $order_id        The order ID.
+	 * @param string $previous_status The previous order status before trashing.
+	 *
+	 * @return void
+	 *
+	 * @since 10.6.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_untrash_order( $order_id, $previous_status ): void {
+		$this->invalidate( (int) $order_id );
+		$this->invalidate_orders_list();
+	}
+
+	/**
+	 * Handle the woocommerce_order_status_changed hook.
+	 *
+	 * Status changes affect which orders appear in status-filtered collection endpoints,
+	 * so we always invalidate the orders list.
+	 *
+	 * @param int       $order_id    The order ID.
+	 * @param string    $from_status The old status.
+	 * @param string    $to_status   The new status.
+	 * @param \WC_Order $order       The order object.
+	 *
+	 * @return void
+	 *
+	 * @since 10.6.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_order_status_changed( $order_id, $from_status, $to_status, $order ): void {
+		$this->invalidate( (int) $order_id );
+		$this->invalidate_orders_list();
+	}
+
+	/**
+	 * Handle the woocommerce_order_refunded hook.
+	 *
+	 * @param int $order_id  The parent order ID.
+	 * @param int $refund_id The refund ID.
+	 *
+	 * @return void
+	 *
+	 * @since 10.6.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_order_refunded( $order_id, $refund_id ): void {
+		$order_id  = (int) $order_id;
+		$refund_id = (int) $refund_id;
+
+		$this->invalidate( $order_id );
+		$this->invalidate_refund( $refund_id );
+		$this->invalidate_order_refunds_list( $order_id );
+		$this->invalidate_refunds_list();
+	}
+
+	/**
+	 * Handle the woocommerce_refund_deleted hook.
+	 *
+	 * @param int $refund_id The refund ID.
+	 * @param int $order_id  The parent order ID.
+	 *
+	 * @return void
+	 *
+	 * @since 10.6.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_refund_deleted( $refund_id, $order_id ): void {
+		$order_id  = (int) $order_id;
+		$refund_id = (int) $refund_id;
+
+		$this->invalidate( $order_id );
+		$this->invalidate_refund( $refund_id );
+		$this->invalidate_order_refunds_list( $order_id );
+		$this->invalidate_refunds_list();
+	}
+
+	// phpcs:enable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+
+	/**
+	 * Invalidate an order version string.
+	 *
+	 * @param int $order_id The order ID.
+	 *
+	 * @return void
+	 *
+	 * @since 10.6.0
+	 */
+	public function invalidate( int $order_id ): void {
+		wc_get_container()->get( VersionStringGenerator::class )->delete_version( "order_{$order_id}" );
+	}
+
+	/**
+	 * Invalidate a refund version string.
+	 *
+	 * @param int $refund_id The refund ID.
+	 *
+	 * @return void
+	 *
+	 * @since 10.6.0
+	 */
+	public function invalidate_refund( int $refund_id ): void {
+		wc_get_container()->get( VersionStringGenerator::class )->delete_version( "refund_{$refund_id}" );
+	}
+
+	/**
+	 * Invalidate the orders list version string.
+	 *
+	 * This should be called when orders are created, deleted, change status,
+	 * or change customer, as these operations affect collection/list endpoints.
+	 *
+	 * @return void
+	 */
+	private function invalidate_orders_list(): void {
+		wc_get_container()->get( VersionStringGenerator::class )->delete_version( 'list_orders' );
+	}
+
+	/**
+	 * Invalidate the refunds list version string.
+	 *
+	 * This should be called when refunds are created or deleted,
+	 * as these operations affect the /refunds collection endpoint.
+	 *
+	 * @return void
+	 */
+	private function invalidate_refunds_list(): void {
+		wc_get_container()->get( VersionStringGenerator::class )->delete_version( 'list_refunds' );
+	}
+
+	/**
+	 * Invalidate the refunds list version string for a specific order.
+	 *
+	 * This should be called when refunds are created or deleted for an order,
+	 * as these operations affect the /orders/{id}/refunds collection endpoint.
+	 *
+	 * @param int $order_id The parent order ID.
+	 *
+	 * @return void
+	 */
+	private function invalidate_order_refunds_list( int $order_id ): void {
+		if ( $order_id > 0 ) {
+			wc_get_container()->get( VersionStringGenerator::class )->delete_version( "list_order_refunds_{$order_id}" );
+		}
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Caches/OrdersVersionStringInvalidatorTest.php b/plugins/woocommerce/tests/php/src/Internal/Caches/OrdersVersionStringInvalidatorTest.php
new file mode 100644
index 0000000000..7244ba90fd
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Caches/OrdersVersionStringInvalidatorTest.php
@@ -0,0 +1,417 @@
+<?php
+/**
+ * OrdersVersionStringInvalidatorTest class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Caches;
+
+use Automattic\WooCommerce\Internal\Caches\OrdersVersionStringInvalidator;
+use Automattic\WooCommerce\Internal\Caches\VersionStringGenerator;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the OrdersVersionStringInvalidator class.
+ */
+class OrdersVersionStringInvalidatorTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var OrdersVersionStringInvalidator
+	 */
+	private $sut;
+
+	/**
+	 * Version string generator.
+	 *
+	 * @var VersionStringGenerator
+	 */
+	private $version_generator;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->sut               = new OrdersVersionStringInvalidator();
+		$this->version_generator = wc_get_container()->get( VersionStringGenerator::class );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		delete_option( 'woocommerce_feature_rest_api_caching_enabled' );
+		delete_option( 'woocommerce_rest_api_enable_backend_caching' );
+		parent::tearDown();
+	}
+
+	/**
+	 * Enable the feature and backend caching, and initialize a new invalidator with hooks registered.
+	 *
+	 * @return OrdersVersionStringInvalidator The initialized invalidator.
+	 */
+	private function get_invalidator_with_hooks_enabled(): OrdersVersionStringInvalidator {
+		update_option( 'woocommerce_feature_rest_api_caching_enabled', 'yes' );
+		update_option( 'woocommerce_rest_api_enable_backend_caching', 'yes' );
+
+		$invalidator = new OrdersVersionStringInvalidator();
+		$invalidator->init();
+
+		return $invalidator;
+	}
+
+	/**
+	 * @testdox Invalidate method deletes the order version string from cache.
+	 */
+	public function test_invalidate_deletes_version_string(): void {
+		$order_id = 123;
+
+		$this->version_generator->generate_version( "order_{$order_id}" );
+
+		$version_before = $this->version_generator->get_version( "order_{$order_id}", false );
+		$this->assertNotNull( $version_before, 'Version string should exist before invalidation' );
+
+		$this->sut->invalidate( $order_id );
+
+		$version_after = $this->version_generator->get_version( "order_{$order_id}", false );
+		$this->assertNull( $version_after, 'Version string should be deleted after invalidation' );
+	}
+
+	/**
+	 * @testdox Invalidate_refund method deletes the refund version string from cache.
+	 */
+	public function test_invalidate_refund_deletes_version_string(): void {
+		$refund_id = 456;
+
+		$this->version_generator->generate_version( "refund_{$refund_id}" );
+
+		$version_before = $this->version_generator->get_version( "refund_{$refund_id}", false );
+		$this->assertNotNull( $version_before, 'Version string should exist before invalidation' );
+
+		$this->sut->invalidate_refund( $refund_id );
+
+		$version_after = $this->version_generator->get_version( "refund_{$refund_id}", false );
+		$this->assertNull( $version_after, 'Version string should be deleted after invalidation' );
+	}
+
+	/**
+	 * @testdox Hooks are registered when feature is enabled and backend caching is active.
+	 */
+	public function test_hooks_registered_when_feature_and_setting_enabled(): void {
+		$invalidator = $this->get_invalidator_with_hooks_enabled();
+
+		$this->assertNotFalse( has_action( 'woocommerce_new_order', array( $invalidator, 'handle_woocommerce_new_order' ) ) );
+		$this->assertNotFalse( has_action( 'woocommerce_update_order', array( $invalidator, 'handle_woocommerce_update_order' ) ) );
+		$this->assertNotFalse( has_action( 'woocommerce_order_status_changed', array( $invalidator, 'handle_woocommerce_order_status_changed' ) ) );
+		$this->assertNotFalse( has_action( 'woocommerce_order_refunded', array( $invalidator, 'handle_woocommerce_order_refunded' ) ) );
+	}
+
+	/**
+	 * @testdox Hooks are not registered when feature is disabled.
+	 */
+	public function test_hooks_not_registered_when_feature_disabled(): void {
+		update_option( 'woocommerce_feature_rest_api_caching_enabled', 'no' );
+		update_option( 'woocommerce_rest_api_enable_backend_caching', 'yes' );
+
+		$invalidator = new OrdersVersionStringInvalidator();
+		$invalidator->init();
+
+		$this->assertFalse( has_action( 'woocommerce_new_order', array( $invalidator, 'handle_woocommerce_new_order' ) ) );
+		$this->assertFalse( has_action( 'woocommerce_update_order', array( $invalidator, 'handle_woocommerce_update_order' ) ) );
+		$this->assertFalse( has_action( 'woocommerce_order_status_changed', array( $invalidator, 'handle_woocommerce_order_status_changed' ) ) );
+	}
+
+	/**
+	 * @testdox Hooks are not registered when backend caching setting is disabled.
+	 */
+	public function test_hooks_not_registered_when_backend_caching_disabled(): void {
+		update_option( 'woocommerce_feature_rest_api_caching_enabled', 'yes' );
+		update_option( 'woocommerce_rest_api_enable_backend_caching', 'no' );
+
+		$invalidator = new OrdersVersionStringInvalidator();
+		$invalidator->init();
+
+		$this->assertFalse( has_action( 'woocommerce_new_order', array( $invalidator, 'handle_woocommerce_new_order' ) ) );
+		$this->assertFalse( has_action( 'woocommerce_update_order', array( $invalidator, 'handle_woocommerce_update_order' ) ) );
+		$this->assertFalse( has_action( 'woocommerce_order_status_changed', array( $invalidator, 'handle_woocommerce_order_status_changed' ) ) );
+	}
+
+	/**
+	 * @testdox Creating a new order invalidates the order version string and list.
+	 */
+	public function test_order_creation_invalidates_version_strings(): void {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$this->version_generator->generate_version( 'list_orders' );
+		$list_version_before = $this->version_generator->get_version( 'list_orders', false );
+		$this->assertNotNull( $list_version_before, 'List version string should exist before order creation' );
+
+		$order    = \WC_Helper_Order::create_order();
+		$order_id = $order->get_id();
+
+		$order_version = $this->version_generator->get_version( "order_{$order_id}", false );
+		$this->assertNull( $order_version, 'Order version string should be deleted after creation' );
+
+		$list_version_after = $this->version_generator->get_version( 'list_orders', false );
+		$this->assertNull( $list_version_after, 'List version string should be deleted after order creation' );
+	}
+
+	/**
+	 * @testdox Updating an existing order invalidates the order version string.
+	 */
+	public function test_order_update_invalidates_version_string(): void {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$order    = \WC_Helper_Order::create_order();
+		$order_id = $order->get_id();
+
+		$this->version_generator->generate_version( "order_{$order_id}" );
+		$version_before = $this->version_generator->get_version( "order_{$order_id}", false );
+		$this->assertNotNull( $version_before, 'Version string should exist before update' );
+
+		$order->set_billing_first_name( 'Updated Name' );
+		$order->save();
+
+		$version_after = $this->version_generator->get_version( "order_{$order_id}", false );
+		$this->assertNull( $version_after, 'Version string should be deleted after order update' );
+	}
+
+	/**
+	 * @testdox Changing order customer invalidates the orders list.
+	 */
+	public function test_order_customer_change_invalidates_list(): void {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$order = \WC_Helper_Order::create_order( 1 );
+
+		$this->version_generator->generate_version( 'list_orders' );
+		$list_version_before = $this->version_generator->get_version( 'list_orders', false );
+		$this->assertNotNull( $list_version_before, 'List version string should exist before customer change' );
+
+		$order->set_customer_id( 2 );
+		$order->save();
+
+		$list_version_after = $this->version_generator->get_version( 'list_orders', false );
+		$this->assertNull( $list_version_after, 'List version string should be deleted after customer change' );
+	}
+
+	/**
+	 * @testdox Updating order without customer change does not invalidate the list.
+	 */
+	public function test_order_update_without_customer_change_does_not_invalidate_list(): void {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$order = \WC_Helper_Order::create_order( 1 );
+
+		$this->version_generator->generate_version( 'list_orders' );
+		$list_version_before = $this->version_generator->get_version( 'list_orders', false );
+		$this->assertNotNull( $list_version_before, 'List version string should exist before update' );
+
+		$order->set_billing_first_name( 'Different Name' );
+		$order->save();
+
+		$list_version_after = $this->version_generator->get_version( 'list_orders', false );
+		$this->assertNotNull( $list_version_after, 'List version string should still exist after non-customer update' );
+	}
+
+	/**
+	 * @testdox Changing order status invalidates the orders list.
+	 */
+	public function test_order_status_change_invalidates_list(): void {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$order    = \WC_Helper_Order::create_order();
+		$order_id = $order->get_id();
+
+		$this->version_generator->generate_version( "order_{$order_id}" );
+		$this->version_generator->generate_version( 'list_orders' );
+
+		$order_version_before = $this->version_generator->get_version( "order_{$order_id}", false );
+		$list_version_before  = $this->version_generator->get_version( 'list_orders', false );
+		$this->assertNotNull( $order_version_before, 'Order version string should exist before status change' );
+		$this->assertNotNull( $list_version_before, 'List version string should exist before status change' );
+
+		$order->set_status( 'completed' );
+		$order->save();
+
+		$order_version_after = $this->version_generator->get_version( "order_{$order_id}", false );
+		$list_version_after  = $this->version_generator->get_version( 'list_orders', false );
+		$this->assertNull( $order_version_after, 'Order version string should be deleted after status change' );
+		$this->assertNull( $list_version_after, 'List version string should be deleted after status change' );
+	}
+
+	/**
+	 * @testdox Trashing an order invalidates the order version string and list.
+	 */
+	public function test_order_trash_invalidates_version_strings(): void {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$order    = \WC_Helper_Order::create_order();
+		$order_id = $order->get_id();
+
+		$this->version_generator->generate_version( "order_{$order_id}" );
+		$this->version_generator->generate_version( 'list_orders' );
+
+		$order_version_before = $this->version_generator->get_version( "order_{$order_id}", false );
+		$list_version_before  = $this->version_generator->get_version( 'list_orders', false );
+		$this->assertNotNull( $order_version_before, 'Order version string should exist before trashing' );
+		$this->assertNotNull( $list_version_before, 'List version string should exist before trashing' );
+
+		$order->delete( false );
+
+		$order_version_after = $this->version_generator->get_version( "order_{$order_id}", false );
+		$list_version_after  = $this->version_generator->get_version( 'list_orders', false );
+		$this->assertNull( $order_version_after, 'Order version string should be deleted after trashing' );
+		$this->assertNull( $list_version_after, 'List version string should be deleted after trashing' );
+	}
+
+	/**
+	 * @testdox Deleting an order invalidates the order version string and list.
+	 */
+	public function test_order_deletion_invalidates_version_strings(): void {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$order    = \WC_Helper_Order::create_order();
+		$order_id = $order->get_id();
+
+		$this->version_generator->generate_version( "order_{$order_id}" );
+		$this->version_generator->generate_version( 'list_orders' );
+
+		$order_version_before = $this->version_generator->get_version( "order_{$order_id}", false );
+		$list_version_before  = $this->version_generator->get_version( 'list_orders', false );
+		$this->assertNotNull( $order_version_before, 'Order version string should exist before deletion' );
+		$this->assertNotNull( $list_version_before, 'List version string should exist before deletion' );
+
+		$order->delete( true );
+
+		$order_version_after = $this->version_generator->get_version( "order_{$order_id}", false );
+		$list_version_after  = $this->version_generator->get_version( 'list_orders', false );
+		$this->assertNull( $order_version_after, 'Order version string should be deleted after deletion' );
+		$this->assertNull( $list_version_after, 'List version string should be deleted after deletion' );
+	}
+
+	/**
+	 * @testdox Creating a refund invalidates the parent order and refund lists.
+	 */
+	public function test_refund_creation_invalidates_version_strings(): void {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$order    = \WC_Helper_Order::create_order();
+		$order_id = $order->get_id();
+		$order->set_status( 'completed' );
+		$order->save();
+
+		$this->version_generator->generate_version( "order_{$order_id}" );
+		$this->version_generator->generate_version( 'list_refunds' );
+		$this->version_generator->generate_version( "list_order_refunds_{$order_id}" );
+
+		$order_version_before         = $this->version_generator->get_version( "order_{$order_id}", false );
+		$refunds_list_version_before  = $this->version_generator->get_version( 'list_refunds', false );
+		$order_refunds_version_before = $this->version_generator->get_version( "list_order_refunds_{$order_id}", false );
+		$this->assertNotNull( $order_version_before, 'Order version string should exist before refund' );
+		$this->assertNotNull( $refunds_list_version_before, 'Refunds list version string should exist before refund' );
+		$this->assertNotNull( $order_refunds_version_before, 'Order refunds list version string should exist before refund' );
+
+		$refund = wc_create_refund(
+			array(
+				'order_id' => $order_id,
+				'amount'   => 1,
+				'reason'   => 'Test refund',
+			)
+		);
+
+		$refund_id = $refund->get_id();
+
+		$order_version_after         = $this->version_generator->get_version( "order_{$order_id}", false );
+		$refund_version_after        = $this->version_generator->get_version( "refund_{$refund_id}", false );
+		$refunds_list_version_after  = $this->version_generator->get_version( 'list_refunds', false );
+		$order_refunds_version_after = $this->version_generator->get_version( "list_order_refunds_{$order_id}", false );
+
+		$this->assertNull( $order_version_after, 'Order version string should be deleted after refund' );
+		$this->assertNull( $refund_version_after, 'Refund version string should be deleted after creation' );
+		$this->assertNull( $refunds_list_version_after, 'Refunds list version string should be deleted after refund' );
+		$this->assertNull( $order_refunds_version_after, 'Order refunds list version string should be deleted after refund' );
+	}
+
+	/**
+	 * @testdox Deleting a refund invalidates the parent order and refund lists.
+	 */
+	public function test_refund_deletion_invalidates_version_strings(): void {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$order    = \WC_Helper_Order::create_order();
+		$order_id = $order->get_id();
+		$order->set_status( 'completed' );
+		$order->save();
+
+		$refund    = wc_create_refund(
+			array(
+				'order_id' => $order_id,
+				'amount'   => 1,
+				'reason'   => 'Test refund',
+			)
+		);
+		$refund_id = $refund->get_id();
+
+		$this->version_generator->generate_version( "order_{$order_id}" );
+		$this->version_generator->generate_version( "refund_{$refund_id}" );
+		$this->version_generator->generate_version( 'list_refunds' );
+		$this->version_generator->generate_version( "list_order_refunds_{$order_id}" );
+
+		$order_version_before         = $this->version_generator->get_version( "order_{$order_id}", false );
+		$refund_version_before        = $this->version_generator->get_version( "refund_{$refund_id}", false );
+		$refunds_list_version_before  = $this->version_generator->get_version( 'list_refunds', false );
+		$order_refunds_version_before = $this->version_generator->get_version( "list_order_refunds_{$order_id}", false );
+		$this->assertNotNull( $order_version_before, 'Order version string should exist before refund deletion' );
+		$this->assertNotNull( $refund_version_before, 'Refund version string should exist before deletion' );
+		$this->assertNotNull( $refunds_list_version_before, 'Refunds list version string should exist before deletion' );
+		$this->assertNotNull( $order_refunds_version_before, 'Order refunds list version string should exist before deletion' );
+
+		$refund->delete( true );
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Test code.
+		do_action( 'woocommerce_refund_deleted', $refund_id, $order_id );
+
+		$order_version_after         = $this->version_generator->get_version( "order_{$order_id}", false );
+		$refund_version_after        = $this->version_generator->get_version( "refund_{$refund_id}", false );
+		$refunds_list_version_after  = $this->version_generator->get_version( 'list_refunds', false );
+		$order_refunds_version_after = $this->version_generator->get_version( "list_order_refunds_{$order_id}", false );
+
+		$this->assertNull( $order_version_after, 'Order version string should be deleted after refund deletion' );
+		$this->assertNull( $refund_version_after, 'Refund version string should be deleted after deletion' );
+		$this->assertNull( $refunds_list_version_after, 'Refunds list version string should be deleted after refund deletion' );
+		$this->assertNull( $order_refunds_version_after, 'Order refunds list version string should be deleted after refund deletion' );
+	}
+
+	/**
+	 * @testdox Handle methods can be called directly for manual invalidation.
+	 */
+	public function test_handle_methods_can_be_called_directly(): void {
+		$order_id  = 100;
+		$refund_id = 200;
+
+		$this->version_generator->generate_version( "order_{$order_id}" );
+		$this->version_generator->generate_version( "refund_{$refund_id}" );
+		$this->version_generator->generate_version( 'list_orders' );
+		$this->version_generator->generate_version( 'list_refunds' );
+
+		$mock_order = $this->createMock( \WC_Order::class );
+		$mock_order->method( 'get_id' )->willReturn( $order_id );
+		$mock_order->method( 'get_type' )->willReturn( 'shop_order' );
+		$mock_order->method( 'get_customer_id' )->willReturn( 1 );
+
+		$this->sut->handle_woocommerce_new_order( $order_id, $mock_order );
+
+		$this->assertNull(
+			$this->version_generator->get_version( "order_{$order_id}", false ),
+			'Order version should be invalidated by handle_woocommerce_new_order'
+		);
+		$this->assertNull(
+			$this->version_generator->get_version( 'list_orders', false ),
+			'Orders list version should be invalidated by handle_woocommerce_new_order'
+		);
+	}
+}