Commit 7b3ecbff6ff for woocommerce

commit 7b3ecbff6ff7c23bc86667f7a03465263894885c
Author: Raluca Stan <ralucastn@gmail.com>
Date:   Wed Oct 21 15:41:24 2026 +0200

    Fix order.updated webhooks firing for checkout draft orders (#69863)

    * Fix order.updated webhooks firing for checkout draft orders

    The order webhook delivery check skipped draft, auto-draft and new, but not checkout-draft. The block checkout saves the order as checkout-draft before moving it to pending, so order.updated went out before order.created, and failed checkouts sent order.updated for orders hidden from the admin.

    * Add changelog entry for checkout draft webhook fix

diff --git a/plugins/woocommerce/changelog/66995-skip-checkout-draft-order-updated-webhook b/plugins/woocommerce/changelog/66995-skip-checkout-draft-order-updated-webhook
new file mode 100644
index 00000000000..5f7c11dd794
--- /dev/null
+++ b/plugins/woocommerce/changelog/66995-skip-checkout-draft-order-updated-webhook
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Stop sending "Order updated" webhooks for draft orders created during checkout, so they no longer arrive before "Order created".
diff --git a/plugins/woocommerce/includes/class-wc-webhook.php b/plugins/woocommerce/includes/class-wc-webhook.php
index d30848d5e5c..99045664d95 100644
--- a/plugins/woocommerce/includes/class-wc-webhook.php
+++ b/plugins/woocommerce/includes/class-wc-webhook.php
@@ -332,8 +332,8 @@ class WC_Webhook extends WC_Legacy_Webhook {

 			$order = wc_get_order( absint( $arg ) );

-			// Ignore standard drafts for orders.
-			if ( in_array( $order->get_status(), array( OrderStatus::DRAFT, OrderStatus::AUTO_DRAFT, 'new' ), true ) ) {
+			// Ignore drafts for orders, matching the statuses for which the data stores skip woocommerce_new_order.
+			if ( in_array( $order->get_status(), array( OrderStatus::DRAFT, OrderStatus::AUTO_DRAFT, OrderStatus::CHECKOUT_DRAFT, OrderStatus::NEW ), true ) ) {
 				return false;
 			}
 		}
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 128e8cbee92..18a7ba831fe 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php
@@ -3,8 +3,10 @@
  * Tests for WC_Webhook class.
  */

+use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
+use Automattic\WooCommerce\Utilities\OrderUtil;

 /**
  * Tests for WC_Webhook class.
@@ -19,6 +21,27 @@ class WC_Webhook_Test extends WC_Unit_Test_Case {
 		self::setup_cot_tables();
 	}

+	/**
+	 * Whether HPOS was in use before a test changed the order storage, or null if it did not.
+	 *
+	 * @var bool|null
+	 */
+	private $prev_hpos_enabled = null;
+
+	/**
+	 * Restore the order storage a test switched with use_order_storage().
+	 */
+	public function tearDown(): void {
+		try {
+			if ( null !== $this->prev_hpos_enabled ) {
+				$this->toggle_cot_authoritative( $this->prev_hpos_enabled );
+				$this->prev_hpos_enabled = null;
+			}
+		} finally {
+			parent::tearDown();
+		}
+	}
+
 	/**
 	 * @testdox Check that post-action validation uses the affected post ID.
 	 *
@@ -414,6 +437,70 @@ class WC_Webhook_Test extends WC_Unit_Test_Case {
 		$this->assertSame( array( $coupon_id ), $delivered_ids, 'The variation-only check should not apply to coupon webhooks' );
 	}

+	/**
+	 * @testdox Order updated webhooks are not delivered while the order is a checkout draft.
+	 *
+	 * @testWith [true]
+	 *           [false]
+	 *
+	 * @param bool $hpos_enabled Whether orders are stored in the HPOS tables.
+	 */
+	public function test_order_updated_webhook_skips_checkout_draft_orders( bool $hpos_enabled ): void {
+		$this->use_order_storage( $hpos_enabled );
+		$order = new WC_Order();
+		$order->set_status( OrderStatus::CHECKOUT_DRAFT );
+		$order->save();
+		$updated_ids = array();
+		$created_ids = array();
+		$this->record_deliveries( $this->create_active_webhook( 'order.updated' ), $updated_ids );
+		$this->record_deliveries( $this->create_active_webhook( 'order.created' ), $created_ids );
+
+		$order->set_billing_email( 'customer@example.com' );
+		$order->save();
+		$order->update_status( OrderStatus::PENDING );
+
+		$this->assertSame( array(), $updated_ids, 'Saving a checkout draft should not deliver order.updated' );
+		$this->assertSame( array( $order->get_id() ), $created_ids, 'Leaving checkout draft should deliver order.created' );
+	}
+
+	/**
+	 * @testdox Order updated webhooks skipped for a checkout draft are still delivered once the order leaves draft.
+	 *
+	 * @testWith [true]
+	 *           [false]
+	 *
+	 * @param bool $hpos_enabled Whether orders are stored in the HPOS tables.
+	 */
+	public function test_order_updated_webhook_delivers_after_checkout_draft( bool $hpos_enabled ): void {
+		$this->use_order_storage( $hpos_enabled );
+		$order = new WC_Order();
+		$order->set_status( OrderStatus::CHECKOUT_DRAFT );
+		$order->save();
+		$updated_ids = array();
+		$this->record_deliveries( $this->create_active_webhook( 'order.updated' ), $updated_ids );
+
+		$order->set_billing_email( 'draft@example.com' );
+		$order->save();
+		$order->update_status( OrderStatus::PENDING );
+		$order->set_billing_email( 'customer@example.com' );
+		$order->save();
+
+		$this->assertSame( array( $order->get_id() ), $updated_ids, 'The first save after leaving draft should deliver order.updated once' );
+	}
+
+	/**
+	 * Store orders in the HPOS tables or in posts for the current test.
+	 *
+	 * @param bool $hpos_enabled Whether orders are stored in the HPOS tables.
+	 */
+	private function use_order_storage( bool $hpos_enabled ): void {
+		if ( null === $this->prev_hpos_enabled ) {
+			$this->prev_hpos_enabled = OrderUtil::custom_orders_table_usage_is_enabled();
+		}
+		add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+		$this->toggle_cot_authoritative( $hpos_enabled );
+	}
+
 	/**
 	 * Enqueue a webhook and record the resource IDs it would deliver, without sending them.
 	 *