Commit 6e28918b935 for woocommerce

commit 6e28918b9359d71e431f7632aa4165dd7deb02da
Author: Miguel Gasca <miguel.gasca@automattic.com>
Date:   Thu Oct 15 12:30:36 2026 +0200

    Fix product.deleted webhook not firing for deleted variations (#69004)

    Removing a variation in the Variations panel, the "Delete all" action,
    a REST or WP-CLI delete with force, and switching a variable product to
    simple all delete variations without the trash. product.deleted only
    listened to wp_trash_post, so none of these sent a webhook.
    The topic now also listens to before_delete_post, which runs after the
    pre_delete_post veto and while the trash metadata still exists. A new
    check lets through variations only, and skips those wp_trash_post()
    already reported (trash status plus _wp_trash_meta_status), so each
    variation is reported once. Permanently deleted simple and variable
    products still send nothing; #69017 tracks that.
    Bulk deletes now queue one delivery per variation. Sites that added
    before_delete_post to product.deleted by filter now get only
    variations from that hook.
    Closes #31620.

diff --git a/plugins/woocommerce/changelog/31620-variation-product-deleted-webhook b/plugins/woocommerce/changelog/31620-variation-product-deleted-webhook
new file mode 100644
index 00000000000..ca2b739baaa
--- /dev/null
+++ b/plugins/woocommerce/changelog/31620-variation-product-deleted-webhook
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Send the product.deleted webhook when a product variation is permanently deleted.
diff --git a/plugins/woocommerce/includes/class-wc-webhook.php b/plugins/woocommerce/includes/class-wc-webhook.php
index 3015fc868a9..f7ffb1f5509 100644
--- a/plugins/woocommerce/includes/class-wc-webhook.php
+++ b/plugins/woocommerce/includes/class-wc-webhook.php
@@ -13,6 +13,7 @@

 use Automattic\Jetpack\Constants;
 use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\Utilities\NumberUtil;
 use Automattic\WooCommerce\Utilities\OrderUtil;
 use Automattic\WooCommerce\Utilities\RestApiUtil;
@@ -193,6 +194,9 @@ class WC_Webhook extends WC_Legacy_Webhook {
 			case 'delete_user':
 				$return = $this->is_valid_user_action( $arg );
 				break;
+			case 'before_delete_post':
+				$return = $this->is_valid_variation_delete_action( $arg );
+				break;
 		}

 		if ( 0 === strpos( $current_action, 'woocommerce_process_shop' ) || 0 === strpos( $current_action, 'woocommerce_process_product' ) ) {
@@ -228,6 +232,34 @@ class WC_Webhook extends WC_Legacy_Webhook {
 		return isset( $post_type_to_resource[ $post_type ] ) && $post_type_to_resource[ $post_type ] === $this->get_resource();
 	}

+	/**
+	 * Validates permanent deletions for product webhooks: only variations, skipping those already reported.
+	 *
+	 * Trashing through wp_trash_post() fires `product.deleted` and records `_wp_trash_meta_status`,
+	 * so a variation trashed that way is not reported again when it is permanently deleted.
+	 *
+	 * @since  11.3.0
+	 * @param  mixed $arg First hook argument.
+	 * @return bool       True if validation passes.
+	 */
+	private function is_valid_variation_delete_action( $arg ) {
+		if ( 'product' !== $this->get_resource() ) {
+			return true;
+		}
+
+		if ( ! $this->is_valid_post_action( $arg ) ) {
+			return false;
+		}
+
+		$variation_id = absint( $arg );
+
+		if ( 'product_variation' !== get_post_type( $variation_id ) ) {
+			return false;
+		}
+
+		return ProductStatus::TRASH !== get_post_status( $variation_id ) || ! metadata_exists( 'post', $variation_id, '_wp_trash_meta_status' );
+	}
+
 	/**
 	 * Validates user actions.
 	 *
@@ -1044,6 +1076,7 @@ class WC_Webhook extends WC_Legacy_Webhook {
 			),
 			'product.deleted'   => array(
 				'wp_trash_post',
+				'before_delete_post',
 			),
 			'product.restored'  => array(
 				'untrashed_post',
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 6808ef3c49d..9dbf66ddd95 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php
@@ -3,6 +3,8 @@
  * Tests for WC_Webhook class.
  */

+use Automattic\WooCommerce\Enums\ProductStatus;
+
 /**
  * Tests for WC_Webhook class.
  */
@@ -111,6 +113,38 @@ class WC_Webhook_Test extends WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * @testdox Variation-delete validation rejects a non-ID argument even when the global post is a variation.
+	 *
+	 * @testWith [0]
+	 *           ["abc"]
+	 *
+	 * @param mixed $arg Hook argument that is not a valid post ID.
+	 */
+	public function test_is_valid_variation_delete_action_rejects_non_id_with_global_variation( $arg ): void {
+		$had_global_post = array_key_exists( 'post', $GLOBALS );
+		$original_post   = $GLOBALS['post'] ?? null;
+		$product         = WC_Helper_Product::create_variation_product();
+		$webhook         = new WC_Webhook();
+		$webhook->set_topic( 'product.deleted' );
+		$call_is_valid_variation_delete_action = function ( $arg ) {
+			return $this->is_valid_variation_delete_action( $arg );
+		};
+
+		try {
+			// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Test isolates arbitrary global request state.
+			$GLOBALS['post'] = get_post( $product->get_children()[0] );
+			$this->assertFalse( $call_is_valid_variation_delete_action->call( $webhook, $arg ) );
+		} finally {
+			if ( $had_global_post ) {
+				// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the global request state changed for this test.
+				$GLOBALS['post'] = $original_post;
+			} else {
+				unset( $GLOBALS['post'] );
+			}
+		}
+	}
+
 	/**
 	 * @testdox Post-action validation rejects non-ID values before coercion.
 	 */
@@ -212,20 +246,7 @@ class WC_Webhook_Test extends WC_Unit_Test_Case {
 		$product       = WC_Helper_Product::create_variation_product();
 		$expected_ids  = array_merge( array( $product->get_id() ), $product->get_children() );
 		$delivered_ids = array();
-		$webhook       = $this->create_active_webhook( 'product.deleted' );
-
-		remove_action( 'woocommerce_webhook_process_delivery', 'wc_webhook_process_delivery', 10 );
-		add_action(
-			'woocommerce_webhook_process_delivery',
-			function ( $delivering_webhook, $arg ) use ( $webhook, &$delivered_ids ) {
-				if ( $webhook === $delivering_webhook ) {
-					$delivered_ids[] = $arg;
-				}
-			},
-			10,
-			2
-		);
-		$webhook->enqueue();
+		$this->record_deliveries( $this->create_active_webhook( 'product.deleted' ), $delivered_ids );

 		wp_trash_post( $product->get_id() );

@@ -242,8 +263,155 @@ class WC_Webhook_Test extends WC_Unit_Test_Case {
 		$expected_ids  = array_merge( array( $product->get_id() ), $product->get_children() );
 		$delivered_ids = array();
 		wp_trash_post( $product->get_id() );
+		$this->record_deliveries( $this->create_active_webhook( 'product.restored' ), $delivered_ids );
+
+		wp_untrash_post( $product->get_id() );
+
+		sort( $expected_ids );
+		sort( $delivered_ids );
+		$this->assertSame( $expected_ids, $delivered_ids );
+	}
+
+	/**
+	 * @testdox Product deletion webhooks are delivered when a variation is permanently deleted.
+	 */
+	public function test_product_deletion_webhook_delivers_for_permanently_deleted_variation(): void {
+		$product       = WC_Helper_Product::create_variation_product();
+		$variation_id  = $product->get_children()[0];
+		$delivered_ids = array();
+		$this->record_deliveries( $this->create_active_webhook( 'product.deleted' ), $delivered_ids );
+
+		wc_get_product( $variation_id )->delete( true );
+
+		$this->assertSame( array( $variation_id ), $delivered_ids );
+	}
+
+	/**
+	 * @testdox Product deletion webhooks are delivered for the variations removed when a variable product changes type.
+	 */
+	public function test_product_deletion_webhook_delivers_for_variations_removed_by_type_change(): void {
+		$product       = WC_Helper_Product::create_variation_product();
+		$expected_ids  = $product->get_children();
+		$delivered_ids = array();
+		$this->record_deliveries( $this->create_active_webhook( 'product.deleted' ), $delivered_ids );
+
+		( new WC_Product_Simple( $product->get_id() ) )->save();
+
+		sort( $expected_ids );
+		sort( $delivered_ids );
+		$this->assertSame( $expected_ids, $delivered_ids );
+	}
+
+	/**
+	 * @testdox Product deletion webhooks are not delivered again when a trashed variable product is permanently deleted.
+	 */
+	public function test_product_deletion_webhook_skips_already_trashed_variations_of_deleted_product(): void {
+		$product       = WC_Helper_Product::create_variation_product();
+		$delivered_ids = array();
+		wp_trash_post( $product->get_id() );
+		$this->record_deliveries( $this->create_active_webhook( 'product.deleted' ), $delivered_ids );
+
+		wp_delete_post( $product->get_id(), true );
+
+		$this->assertSame( array(), $delivered_ids );
+	}
+
+	/**
+	 * @testdox Product deletion webhooks are not delivered again when a trashed variation is permanently deleted.
+	 */
+	public function test_product_deletion_webhook_skips_already_trashed_variation(): void {
+		$product       = WC_Helper_Product::create_variation_product();
+		$variation_id  = $product->get_children()[0];
+		$delivered_ids = array();
+		wc_get_product( $variation_id )->delete();
+		$this->record_deliveries( $this->create_active_webhook( 'product.deleted' ), $delivered_ids );
+
+		wc_get_product( $variation_id )->delete( true );
+
+		$this->assertSame( array(), $delivered_ids );
+	}
+
+	/**
+	 * @testdox Product deletion webhooks are delivered for a variation moved to trash without wp_trash_post().
+	 */
+	public function test_product_deletion_webhook_delivers_for_variation_trashed_without_wp_trash_post(): void {
+		$product       = WC_Helper_Product::create_variation_product();
+		$variation_id  = $product->get_children()[0];
+		$variation     = wc_get_product( $variation_id );
+		$delivered_ids = array();
+		$variation->set_status( ProductStatus::TRASH );
+		$variation->save();
+		$this->record_deliveries( $this->create_active_webhook( 'product.deleted' ), $delivered_ids );
+
+		$variation->delete( true );
+
+		$this->assertSame( array( $variation_id ), $delivered_ids );
+	}
+
+	/**
+	 * @testdox Product deletion webhooks are not delivered when a pre_delete_post filter cancels the variation deletion.
+	 */
+	public function test_product_deletion_webhook_skips_variation_deletion_cancelled_by_pre_delete_post(): void {
+		$product       = WC_Helper_Product::create_variation_product();
+		$variation_id  = $product->get_children()[0];
+		$delivered_ids = array();
+		add_filter(
+			'pre_delete_post',
+			function ( $check, $post ) use ( $variation_id ) {
+				return $variation_id === $post->ID ? false : $check;
+			},
+			10,
+			2
+		);
+		$this->record_deliveries( $this->create_active_webhook( 'product.deleted' ), $delivered_ids );
+
+		wc_get_product( $variation_id )->delete( true );
+
+		$this->assertNotNull( get_post( $variation_id ), 'The filter should have kept the variation' );
+		$this->assertSame( array(), $delivered_ids );
+	}
+
+	/**
+	 * @testdox Product deletion webhooks are not delivered when a post that is not a variation is permanently deleted.
+	 */
+	public function test_product_deletion_webhook_skips_permanently_deleted_non_variation_post(): void {
+		$post_id       = $this->factory->post->create();
+		$delivered_ids = array();
+		$this->record_deliveries( $this->create_active_webhook( 'product.deleted' ), $delivered_ids );
+
+		wp_delete_post( $post_id, true );
+
+		$this->assertSame( array(), $delivered_ids );
+	}

-		$webhook = $this->create_active_webhook( 'product.restored' );
+	/**
+	 * @testdox Non-product webhooks that add before_delete_post by filter still deliver for trashed posts.
+	 */
+	public function test_non_product_webhook_on_before_delete_post_delivers_for_trashed_post(): void {
+		$coupon_id     = WC_Helper_Coupon::create_coupon()->get_id();
+		$delivered_ids = array();
+		wp_trash_post( $coupon_id );
+		add_filter(
+			'woocommerce_webhook_topic_hooks',
+			function ( $topic_hooks ) {
+				$topic_hooks['coupon.deleted'][] = 'before_delete_post';
+				return $topic_hooks;
+			}
+		);
+		$this->record_deliveries( $this->create_active_webhook( 'coupon.deleted' ), $delivered_ids );
+
+		wp_delete_post( $coupon_id, true );
+
+		$this->assertSame( array( $coupon_id ), $delivered_ids, 'The variation-only check should not apply to coupon webhooks' );
+	}
+
+	/**
+	 * Enqueue a webhook and record the resource IDs it would deliver, without sending them.
+	 *
+	 * @param WC_Webhook   $webhook       Webhook to enqueue.
+	 * @param array<mixed> $delivered_ids Receives each delivered resource ID.
+	 */
+	private function record_deliveries( WC_Webhook $webhook, array &$delivered_ids ): void {
 		remove_action( 'woocommerce_webhook_process_delivery', 'wc_webhook_process_delivery', 10 );
 		add_action(
 			'woocommerce_webhook_process_delivery',
@@ -256,12 +424,6 @@ class WC_Webhook_Test extends WC_Unit_Test_Case {
 			2
 		);
 		$webhook->enqueue();
-
-		wp_untrash_post( $product->get_id() );
-
-		sort( $expected_ids );
-		sort( $delivered_ids );
-		$this->assertSame( $expected_ids, $delivered_ids );
 	}

 	/**