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.' );
+ }
}