Commit fecbc7a8df0 for woocommerce

commit fecbc7a8df0b8b2559082468462428cad7d5bb5f
Author: Raluca Stan <ralucastn@gmail.com>
Date:   Mon Oct 19 12:27:53 2026 +0200

    Stop checkout step logging from firing order update hooks and webhooks (#69870)

    * Save checkout step log meta without a full order save

    wc_log_order_step() called $order->save() at every logged step to store
    _debug_log_source, which fired woocommerce_update_order and queued the
    order.updated webhook for a brand-new, unchanged order. Persist the meta
    with save_meta_data() instead, skip unsaved orders, and mark both keys as
    ephemeral so the HPOS data store does not do the full save itself.

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

    * Write the checkout log source meta only when it changes

    The source stays the same for the whole request, so rewriting it at every
    logged step only added meta deletes and inserts. Also narrow the changelog:
    the Store API still sends one order.updated when the order becomes pending.

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

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/43019-checkout-logger-order-updated-webhook b/plugins/woocommerce/changelog/43019-checkout-logger-order-updated-webhook
new file mode 100644
index 00000000000..958ebe2f675
--- /dev/null
+++ b/plugins/woocommerce/changelog/43019-checkout-logger-order-updated-webhook
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Stop checkout logging from sending extra "Order updated" webhooks.
diff --git a/plugins/woocommerce/includes/wc-order-step-logger-functions.php b/plugins/woocommerce/includes/wc-order-step-logger-functions.php
index c405249ff88..d149adb0893 100644
--- a/plugins/woocommerce/includes/wc-order-step-logger-functions.php
+++ b/plugins/woocommerce/includes/wc-order-step-logger-functions.php
@@ -72,8 +72,11 @@ function wc_log_order_step( string $message, ?array $context = null, bool $final
 			$order   = $context['order_object'];
 			$context = array_merge( extract_order_safe_data( $order ), $context );
 			unset( $context['order_object'] ); // This is super-important to avoid logging sensitive data.
-			$order->add_meta_data( '_debug_log_source', $context['source'], true );
-			$order->save();
+			// Save only the meta: a full save fires woocommerce_update_order, and with it the order.updated webhook.
+			if ( $order->get_id() > 0 && $order->get_meta( '_debug_log_source' ) !== $context['source'] ) {
+				$order->add_meta_data( '_debug_log_source', $context['source'], true );
+				$order->save_meta_data();
+			}
 		}

 		// Use the global logger instance which respects site's logging configuration.
@@ -93,16 +96,16 @@ function wc_log_order_step( string $message, ?array $context = null, bool $final
 			// We'll schedule the deletion for later in order to speed up the checkout process
 			// unless a custom (non-Woo core) orders data store is in use, because in that case there's
 			// no reliable way to query orders by meta key.
-			if ( $order && ( count( array_unique( $steps ) ) === count( $steps ) ) ) {
+			if ( $order && $order->get_id() > 0 && ( count( array_unique( $steps ) ) === count( $steps ) ) ) {
 				$order->delete_meta_data( '_debug_log_source' );
 				if ( OrderUtil::unknown_orders_data_store_in_use() ) {
 					if ( $logger instanceof WC_Logger ) {
 						$logger->clear( $context['source'] );
 					}
-					$order->save();
+					$order->save_meta_data();
 				} else {
 					$order->add_meta_data( '_debug_log_source_pending_deletion', $context['source'], true );
-					$order->save();
+					$order->save_meta_data();
 					wc_get_container()->get( BatchProcessingController::class )->enqueue_processor( OrderLogsDeletionProcessor::class );
 				}
 			}
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
index f53382e86c8..3cbb412d0fb 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
@@ -123,6 +123,8 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
 		'_wp_trash_meta_status',
 		'_wp_trash_meta_time',
 		'_wp_trash_meta_comments_status',
+		'_debug_log_source',
+		'_debug_log_source_pending_deletion',
 	);

 	/**
diff --git a/plugins/woocommerce/tests/php/includes/wc-order-step-logger-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-order-step-logger-functions-test.php
index b12457a823f..c29ea565dcf 100644
--- a/plugins/woocommerce/tests/php/includes/wc-order-step-logger-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-order-step-logger-functions-test.php
@@ -7,6 +7,10 @@

 declare(strict_types=1);
 use Automattic\Jetpack\Constants;
+use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
+use Automattic\WooCommerce\Utilities\OrderUtil;
+
 /**
  * Class WC_Order_Step_Logger_Functions_Test
  */
@@ -19,6 +23,13 @@ class WC_Order_Step_Logger_Functions_Test extends \WC_Unit_Test_Case {
 	 */
 	private $original_request_method;

+	/**
+	 * 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;
+
 	/**
 	 * Runs before each test.
 	 */
@@ -38,6 +49,11 @@ class WC_Order_Step_Logger_Functions_Test extends \WC_Unit_Test_Case {
 	 * Runs after each test.
 	 */
 	public function tearDown(): void {
+		if ( null !== $this->prev_hpos_enabled ) {
+			OrderHelper::toggle_cot_feature_and_usage( $this->prev_hpos_enabled );
+			$this->prev_hpos_enabled = null;
+		}
+
 		// Clean up log files.
 		$this->clean_up_log_files();

@@ -347,4 +363,82 @@ class WC_Order_Step_Logger_Functions_Test extends \WC_Unit_Test_Case {

 		$order->delete( true );
 	}
+
+	/**
+	 * @testdox Order step logging stores its meta without a full order save, so woocommerce_update_order does not fire.
+	 *
+	 * @testWith [true, false]
+	 *           [true, true]
+	 *           [false, false]
+	 *           [false, true]
+	 *
+	 * @param bool $hpos_enabled Whether orders are stored in the HPOS tables.
+	 * @param bool $sync_enabled Whether HPOS and posts data sync is on.
+	 */
+	public function test_wc_log_order_step_does_not_fire_update_order_hook( bool $hpos_enabled, bool $sync_enabled ): void {
+		Constants::set_constant( 'WC_LOG_THRESHOLD', WC_Log_Levels::DEBUG );
+		$this->use_order_storage( $hpos_enabled, $sync_enabled );
+		$order = WC_Helper_Order::create_order();
+		// HPOS saves the whole order after a meta change unless the order was modified in the current second.
+		$order->set_date_modified( time() - HOUR_IN_SECONDS );
+		$order->save();
+		$update_order_count = 0;
+		add_action(
+			'woocommerce_update_order',
+			function () use ( &$update_order_count ) {
+				++$update_order_count;
+			}
+		);
+
+		wc_log_order_step( 'Step 1', array( 'order_object' => $order ), false, true );
+		wc_log_order_step( 'Step 2', array( 'order_object' => $order ) );
+
+		$log_source = wc_get_order( $order->get_id() )->get_meta( '_debug_log_source' );
+		$this->assertStringStartsWith( 'place-order-debug-', $log_source, 'Each logged step should store the log source on the order' );
+
+		wc_log_order_step( 'Step 3 - Final', array( 'order_object' => $order ), true );
+
+		$this->assertSame( 0, $update_order_count, 'Logging checkout steps should not fire woocommerce_update_order' );
+		$saved_order = wc_get_order( $order->get_id() );
+		$this->assertSame( '', $saved_order->get_meta( '_debug_log_source' ), 'The final step should remove the log source meta' );
+		$this->assertSame( $log_source, $saved_order->get_meta( '_debug_log_source_pending_deletion' ), 'The final step should mark the log for deletion' );
+		if ( $hpos_enabled && $sync_enabled ) {
+			$this->assertSame( $log_source, get_post_meta( $order->get_id(), '_debug_log_source_pending_deletion', true ), 'The pending-deletion meta should be backfilled to the posts table when sync is on' );
+			$this->assertSame( '', get_post_meta( $order->get_id(), '_debug_log_source', true ), 'The log source meta should be removed from the posts table when sync is on' );
+		}
+	}
+
+	/**
+	 * @testdox Order step logging does not store meta for an order that has not been saved yet.
+	 */
+	public function test_wc_log_order_step_skips_meta_for_unsaved_order(): void {
+		Constants::set_constant( 'WC_LOG_THRESHOLD', WC_Log_Levels::DEBUG );
+		$order = new WC_Order();
+
+		wc_log_order_step( 'Step 1', array( 'order_object' => $order ), false, true );
+		wc_log_order_step( 'Step 2 - Final', array( 'order_object' => $order ), true );
+
+		$this->assertSame( 0, $order->get_id(), 'Logging should not save the order' );
+		$this->assertSame( '', $order->get_meta( '_debug_log_source' ), 'Logging should not add meta to an unsaved order' );
+	}
+
+	/**
+	 * 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.
+	 * @param bool $sync_enabled Whether HPOS and posts data sync is on.
+	 */
+	private function use_order_storage( bool $hpos_enabled, bool $sync_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' );
+		add_filter(
+			'pre_option_' . DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION,
+			function () use ( $sync_enabled ) {
+				return wc_bool_to_string( $sync_enabled );
+			}
+		);
+		OrderHelper::toggle_cot_feature_and_usage( $hpos_enabled );
+	}
 }