Commit 68759081972 for woocommerce

commit 687590819729c169efe0607ec95b943c3a3c1105
Author: Valery Sukhomlinov <683297+dmvrtx@users.noreply.github.com>
Date:   Mon Oct 19 23:41:58 2026 +0200

    `order.updated` webhook is not triggered when refund is deleted (#68698)

    * Align behavour of `wc_get_orders` when querying unknown statuses between CPT and HPOS stores - return empty list.

    * Fix the changelog entry

    * Safeguards for the action calls with legacy signature

    * Type casting for order ID

    * Improve order ID validation

    * Cast order ID as int for further steps

    * Clarify description about backwards-compatibility

    * Fix tabs in the middle of the line

    * Update @since tags

    Co-authored-by: Francesco <frosso@users.noreply.github.com>

    * Update missed `@since` tag

    * Add a regression test with HPOS enabled

    * Ensure correct action registration

    * In tests, verify that deleted refund is not in the payload.

    * Harden `$order_id` check

    Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

    * Prevent `order.updated` webhook firing when order is deleted

    * Re-implement with a new added hook

    * Remove the `OrderModifiedDateUpdater` class as barely needed.

    * Implement `suppress_filters` support

    * Implement the shared set of orders being deleted

    * Drop unused suppress_filters check and trim refund deletion tests

    No core caller passes suppress_filters to a refund store, and the
    existing woocommerce_delete_order_refund action ignores it. The
    order-deletion webhook test duplicated the data store coverage, and the
    two remaining webhook tests are merged into one run on HPOS and CPT.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    ---------

    Co-authored-by: Francesco <frosso@users.noreply.github.com>
    Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/wooplug-164-deleting-a-refund-doesnt-fire-orderupdated-webhook b/plugins/woocommerce/changelog/wooplug-164-deleting-a-refund-doesnt-fire-orderupdated-webhook
new file mode 100644
index 00000000000..c670c805ca8
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-164-deleting-a-refund-doesnt-fire-orderupdated-webhook
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Send the order.updated webhook when a refund is deleted from an order.
diff --git a/plugins/woocommerce/includes/class-wc-webhook.php b/plugins/woocommerce/includes/class-wc-webhook.php
index f7ffb1f5509..d30848d5e5c 100644
--- a/plugins/woocommerce/includes/class-wc-webhook.php
+++ b/plugins/woocommerce/includes/class-wc-webhook.php
@@ -1055,6 +1055,7 @@ class WC_Webhook extends WC_Legacy_Webhook {
 			'order.updated'     => array(
 				'woocommerce_update_order',
 				'woocommerce_order_refunded',
+				'woocommerce_order_refund_deleted',
 			),
 			'order.deleted'     => array(
 				'wp_trash_post',
diff --git a/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php
index 03593403e73..f5cc3f37cd2 100644
--- a/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php
@@ -30,6 +30,13 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme
 	 */
 	protected $meta_type = 'post';

+	/**
+	 * IDs of orders currently being permanently deleted, keyed by order ID.
+	 *
+	 * @var array<int, true>
+	 */
+	protected static $orders_being_deleted = array();
+
 	/**
 	 * Data stored in meta keys, but not considered "meta" for an order.
 	 *
@@ -284,30 +291,35 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme
 		$do_filters = ! $args['suppress_filters'];

 		if ( $args['force_delete'] ) {
-			if ( $do_filters ) {
-				/**
-				 * Fires immediately before an order is deleted from the database.
-				 *
-				 * @since 8.0.0
-				 *
-				 * @param int      $order_id ID of the order about to be deleted.
-				 * @param WC_Order $order    Instance of the order that is about to be deleted.
-				 */
-				do_action( 'woocommerce_before_delete_order', $id, $order );
-			}
-
-			wp_delete_post( $id );
-			$order->set_id( 0 );
+			self::$orders_being_deleted[ $id ] = true;
+			try {
+				if ( $do_filters ) {
+					/**
+					 * Fires immediately before an order is deleted from the database.
+					 *
+					 * @since 8.0.0
+					 *
+					 * @param int      $order_id ID of the order about to be deleted.
+					 * @param WC_Order $order    Instance of the order that is about to be deleted.
+					 */
+					do_action( 'woocommerce_before_delete_order', $id, $order );
+				}

-			if ( $do_filters ) {
-				/**
-				 * Fires immediately after an order is deleted.
-				 *
-				 * @since 2.7.0
-				 *
-				 * @param int $order_id ID of the order that has been deleted.
-				 */
-				do_action( 'woocommerce_delete_order', $id );
+				wp_delete_post( $id );
+				$order->set_id( 0 );
+
+				if ( $do_filters ) {
+					/**
+					 * Fires immediately after an order is deleted.
+					 *
+					 * @since 2.7.0
+					 *
+					 * @param int $order_id ID of the order that has been deleted.
+					 */
+					do_action( 'woocommerce_delete_order', $id );
+				}
+			} finally {
+				unset( self::$orders_being_deleted[ $id ] );
 			}
 		} else {
 			if ( $do_filters ) {
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-order-refund-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-order-refund-data-store-cpt.php
index 1f59eec9cf5..14c502ff6b9 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-order-refund-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-order-refund-data-store-cpt.php
@@ -65,6 +65,20 @@ class WC_Order_Refund_Data_Store_CPT extends Abstract_WC_Order_Data_Store_CPT im
 		 * @since 3.0.0
 		 */
 		do_action( 'woocommerce_delete_order_refund', $id );
+
+		if ( ! $parent_order_id || isset( self::$orders_being_deleted[ $parent_order_id ] ) ) {
+			return;
+		}
+
+		/**
+		 * Fires after a refund is deleted from an order, unless the order itself is being deleted.
+		 *
+		 * @since 11.3.0
+		 *
+		 * @param int $order_id  The ID of the order the refund belonged to.
+		 * @param int $refund_id The ID of the deleted refund.
+		 */
+		do_action( 'woocommerce_order_refund_deleted', $parent_order_id, $id );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
index 3cbb412d0fb..043e1403df0 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
@@ -2626,53 +2626,57 @@ FROM $order_meta_table
 		$do_filters = ! $args['suppress_filters'];

 		if ( $args['force_delete'] ) {
+			self::$orders_being_deleted[ $order_id ] = true;
+			try {
+				if ( $do_filters ) {
+					/**
+					 * Fires immediately before an order is deleted from the database.
+					 *
+					 * @since 7.1.0
+					 *
+					 * @param int      $order_id ID of the order about to be deleted.
+					 * @param WC_Order $order    Instance of the order that is about to be deleted.
+					 */
+					do_action( 'woocommerce_before_delete_order', $order_id, $order );
+				}

-			if ( $do_filters ) {
-				/**
-				 * Fires immediately before an order is deleted from the database.
-				 *
-				 * @since 7.1.0
-				 *
-				 * @param int      $order_id ID of the order about to be deleted.
-				 * @param WC_Order $order    Instance of the order that is about to be deleted.
-				 */
-				do_action( 'woocommerce_before_delete_order', $order_id, $order );
-			}
-
-			$this->upshift_or_delete_child_orders( $order );
-			$this->delete_order_data_from_custom_order_tables( $order_id );
-			$this->delete_items( $order );
-
-			$order->set_id( 0 );
+				$this->upshift_or_delete_child_orders( $order );
+				$this->delete_order_data_from_custom_order_tables( $order_id );
+				$this->delete_items( $order );

-			/** We can delete the post data if:
-			 * 1. The HPOS table is authoritative and synchronization is enabled.
-			 * 2. The post record is of type `shop_order_placehold`, since this is created by the HPOS in the first place.
-			 *
-			 * In other words, we do not delete the post record when HPOS table is authoritative and synchronization is disabled but post record is a full record and not just a placeholder, because it implies that the order was created before HPOS was enabled.
-			 */
-			$orders_table_is_authoritative = $order->get_data_store()->get_current_class_name() === self::class;
-
-			if ( $orders_table_is_authoritative ) {
-				$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
-				if ( $data_synchronizer->data_sync_is_enabled() ) {
-					// Delete the associated post, which in turn deletes order items, etc. through {@see WC_Post_Data}.
-					// Once we stop creating posts for orders, we should do the cleanup here instead.
-					wp_delete_post( $order_id );
-				} else {
-					$this->handle_order_deletion_with_sync_disabled( $order_id );
-				}
-			}
+				$order->set_id( 0 );

-			if ( $do_filters ) {
-				/**
-				 * Fires immediately after an order is deleted.
-				 *
-				 * @since 2.7.0
+				/** We can delete the post data if:
+				 * 1. The HPOS table is authoritative and synchronization is enabled.
+				 * 2. The post record is of type `shop_order_placehold`, since this is created by the HPOS in the first place.
 				 *
-				 * @param int $order_id ID of the order that has been deleted.
+				 * In other words, we do not delete the post record when HPOS table is authoritative and synchronization is disabled but post record is a full record and not just a placeholder, because it implies that the order was created before HPOS was enabled.
 				 */
-				do_action( 'woocommerce_delete_order', $order_id ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+				$orders_table_is_authoritative = $order->get_data_store()->get_current_class_name() === self::class;
+
+				if ( $orders_table_is_authoritative ) {
+					$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
+					if ( $data_synchronizer->data_sync_is_enabled() ) {
+						// Delete the associated post, which in turn deletes order items, etc. through {@see WC_Post_Data}.
+						// Once we stop creating posts for orders, we should do the cleanup here instead.
+						wp_delete_post( $order_id );
+					} else {
+						$this->handle_order_deletion_with_sync_disabled( $order_id );
+					}
+				}
+
+				if ( $do_filters ) {
+					/**
+					 * Fires immediately after an order is deleted.
+					 *
+					 * @since 2.7.0
+					 *
+					 * @param int $order_id ID of the order that has been deleted.
+					 */
+					do_action( 'woocommerce_delete_order', $order_id ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+				}
+			} finally {
+				unset( self::$orders_being_deleted[ $order_id ] );
 			}
 		} else {
 			if ( $do_filters ) {
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableRefundDataStore.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableRefundDataStore.php
index 23f9fdfda7d..992d5250463 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableRefundDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableRefundDataStore.php
@@ -78,7 +78,8 @@ class OrdersTableRefundDataStore extends OrdersTableDataStore {
 			return;
 		}

-		$refund_cache_key = WC_Cache_Helper::get_cache_prefix( 'orders' ) . 'refund_ids' . $refund->get_parent_id();
+		$parent_order_id  = $refund->get_parent_id();
+		$refund_cache_key = WC_Cache_Helper::get_cache_prefix( 'orders' ) . 'refund_ids' . $parent_order_id;
 		wp_cache_delete( $refund_cache_key, 'orders' );

 		$this->delete_order_data_from_custom_order_tables( $refund_id );
@@ -104,6 +105,20 @@ class OrdersTableRefundDataStore extends OrdersTableDataStore {
 		 * @since 3.0.0
 		 */
 		do_action( 'woocommerce_delete_order_refund', $refund_id );
+
+		if ( ! $parent_order_id || isset( self::$orders_being_deleted[ $parent_order_id ] ) ) {
+			return;
+		}
+
+		/**
+		 * Fires after a refund is deleted from an order, unless the order itself is being deleted.
+		 *
+		 * @since 11.3.0
+		 *
+		 * @param int $order_id  The ID of the order the refund belonged to.
+		 * @param int $refund_id The ID of the deleted refund.
+		 */
+		do_action( 'woocommerce_order_refund_deleted', $parent_order_id, $refund_id );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php b/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php
index 9dbf66ddd95..128e8cbee92 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php
@@ -4,11 +4,20 @@
  */

 use Automattic\WooCommerce\Enums\ProductStatus;
+use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;

 /**
  * Tests for WC_Webhook class.
  */
 class WC_Webhook_Test extends WC_Unit_Test_Case {
+	use HPOSToggleTrait;
+
+	/**
+	 * Ensure permanent HPOS tables exist before per-test transactions start.
+	 */
+	public static function wpSetUpBeforeClass(): void {
+		self::setup_cot_tables();
+	}

 	/**
 	 * @testdox Check that post-action validation uses the affected post ID.
@@ -426,6 +435,58 @@ class WC_Webhook_Test extends WC_Unit_Test_Case {
 		$webhook->enqueue();
 	}

+	/**
+	 * @testdox Deleting a refund delivers an order.updated webhook with the parent order payload.
+	 * @testWith [true]
+	 *           [false]
+	 *
+	 * @param bool $hpos_on_or_off True to test with HPOS on, false to test with HPOS off.
+	 */
+	public function test_refund_deletion_delivers_order_updated_webhook( bool $hpos_on_or_off ): void {
+		// Remove the Test Suite’s use of temporary tables https://wordpress.stackexchange.com/a/220308.
+		remove_filter( 'query', array( $this, '_create_temporary_tables' ) );
+		remove_filter( 'query', array( $this, '_drop_temporary_tables' ) );
+		$this->toggle_cot_authoritative( $hpos_on_or_off );
+
+		$order  = WC_Helper_Order::create_order();
+		$refund = wc_create_refund(
+			array(
+				'order_id'       => $order->get_id(),
+				'amount'         => 1,
+				'refund_payment' => false,
+			)
+		);
+
+		$this->assertInstanceOf( WC_Order_Refund::class, $refund, 'The refund fixture should be created.' );
+		$refund_id = $refund->get_id();
+
+		$payloads = array();
+		$webhook  = $this->create_active_webhook( 'order.updated' );
+		$webhook->set_user_id( 1 );
+
+		remove_action( 'woocommerce_webhook_process_delivery', 'wc_webhook_process_delivery', 10 );
+		add_action(
+			'woocommerce_webhook_process_delivery',
+			function ( $delivering_webhook, $arg ) use ( $webhook, &$payloads ) {
+				if ( $webhook === $delivering_webhook ) {
+					$payloads[] = $delivering_webhook->build_payload( $arg );
+				}
+			},
+			10,
+			2
+		);
+		$webhook->enqueue();
+
+		$refund->delete( true );
+
+		$this->assertCount( 1, $payloads, 'The refund deletion should queue one webhook payload.' );
+		$this->assertArrayNotHasKey( 'code', $payloads[0], 'The payload should not contain a REST API error.' );
+		$this->assertArrayHasKey( 'id', $payloads[0] );
+		$this->assertSame( $order->get_id(), $payloads[0]['id'], 'The payload should represent the parent order.' );
+		$this->assertArrayHasKey( 'refunds', $payloads[0] );
+		$this->assertNotContains( $refund_id, wp_list_pluck( $payloads[0]['refunds'], 'id' ), 'The payload should not contain the deleted refund.' );
+	}
+
 	/**
 	 * Create an active webhook for integration tests.
 	 *
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-webhooks-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-webhooks-controller-tests.php
index 1a742e74a54..18b612ee29b 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-webhooks-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-webhooks-controller-tests.php
@@ -51,6 +51,7 @@ class WC_REST_Webhooks_Controller_Tests extends WC_REST_Unit_Test_Case {
 			'hooks'        => array(
 				'woocommerce_update_order',
 				'woocommerce_order_refunded',
+				'woocommerce_order_refund_deleted',
 			),
 			'delivery_url' => $delivery_url,
 		);
diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableRefundDataStoreTests.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableRefundDataStoreTests.php
index 989b3af5467..bf27371759c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableRefundDataStoreTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableRefundDataStoreTests.php
@@ -311,4 +311,90 @@ class OrdersTableRefundDataStoreTests extends \WC_Unit_Test_Case {
 		$this->assertEquals( 2, $hook_called_count['woocommerce_update_order_refund'] );
 	}

+	/**
+	 * @testdox Deleting a refund fires woocommerce_order_refund_deleted with the parent order and refund IDs.
+	 * @testWith [true]
+	 *           [false]
+	 *
+	 * @param bool $hpos_on_or_off True to test with HPOS on, false to test with HPOS off.
+	 */
+	public function test_refund_deletion_fires_order_refund_deleted( bool $hpos_on_or_off ): void {
+		$this->toggle_cot_authoritative( $hpos_on_or_off );
+		$order  = OrderHelper::create_order();
+		$refund = wc_create_refund(
+			array(
+				'order_id' => $order->get_id(),
+				'amount'   => 1,
+			)
+		);
+
+		$hook_args = array();
+		$callback  = function ( ...$args ) use ( &$hook_args ) {
+			$hook_args[] = $args;
+		};
+		add_action( 'woocommerce_order_refund_deleted', $callback, 10, 2 );
+
+		$refund_id = $refund->get_id();
+		$refund->delete( true );
+
+		$this->assertSame( array( array( $order->get_id(), $refund_id ) ), $hook_args, 'The hook should fire once with the order and refund IDs.' );
+	}
+
+	/**
+	 * @testdox Deleting an order does not fire woocommerce_order_refund_deleted for its refunds.
+	 * @testWith [true]
+	 *           [false]
+	 *
+	 * @param bool $hpos_on_or_off True to test with HPOS on, false to test with HPOS off.
+	 */
+	public function test_order_deletion_does_not_fire_order_refund_deleted( bool $hpos_on_or_off ): void {
+		$this->toggle_cot_authoritative( $hpos_on_or_off );
+		$order  = OrderHelper::create_order();
+		$refund = wc_create_refund(
+			array(
+				'order_id' => $order->get_id(),
+				'amount'   => 1,
+			)
+		);
+
+		$order_refund_deleted = did_action( 'woocommerce_order_refund_deleted' );
+
+		$order->delete( true );
+
+		$this->assertFalse( wc_get_order( $refund->get_id() ), 'The refund should be deleted along with the order.' );
+		$this->assertSame( $order_refund_deleted, did_action( 'woocommerce_order_refund_deleted' ), 'The hook should not fire for an order that is being deleted.' );
+	}
+
+	/**
+	 * @testdox Deleting a refund from an order deletion callback does not fire woocommerce_order_refund_deleted.
+	 * @testWith [true]
+	 *           [false]
+	 *
+	 * @param bool $hpos_on_or_off True to test with HPOS on, false to test with HPOS off.
+	 */
+	public function test_refund_deletion_during_order_deletion_does_not_fire_order_refund_deleted( bool $hpos_on_or_off ): void {
+		$this->toggle_cot_authoritative( $hpos_on_or_off );
+		$order  = OrderHelper::create_order();
+		$refund = wc_create_refund(
+			array(
+				'order_id' => $order->get_id(),
+				'amount'   => 1,
+			)
+		);
+
+		$refund_deleted_count = did_action( 'woocommerce_delete_order_refund' );
+		$order_refund_deleted = did_action( 'woocommerce_order_refund_deleted' );
+
+		add_action(
+			'woocommerce_before_delete_order',
+			function () use ( $refund ) {
+				$refund->delete( true );
+			}
+		);
+
+		$order->delete( true );
+
+		$this->assertSame( $refund_deleted_count + 1, did_action( 'woocommerce_delete_order_refund' ), 'The refund should be deleted through its data store.' );
+		$this->assertSame( $order_refund_deleted, did_action( 'woocommerce_order_refund_deleted' ), 'The hook should not fire for an order that is being deleted.' );
+	}
 }