Commit ea8ddc1e2e4 for woocommerce

commit ea8ddc1e2e43ab66561558d6aba32b0c9fdaada6
Author: Néstor Soriano <konamiman@konamiman.com>
Date:   Tue Apr 21 17:59:31 2026 +0200

    Fix HPOS data cache cross-bleed between order data store subclasses (#64067)

    * Fix HPOS data cache cross-bleed between order data store subclasses

    When a subclass data store (e.g. refunds, subscriptions) with a reduced
    column mapping populates the HPOS data cache, the cached stdClass object
    only contains properties for the subclass' column subset. If another
    data store later reads that cache entry, missing properties cause the
    order to silently retain default values from set_defaults(), which could
    lead to sales being re-recorded, stock being re-reduced, or loss of
    transaction tracking data.

    Fix this by extracting each base column mapping into a private constant
    (BASE_ORDER_COLUMN_MAPPING, BASE_BILLING_ADDRESS_COLUMN_MAPPING,
    BASE_SHIPPING_ADDRESS_COLUMN_MAPPING, BASE_OPERATIONAL_DATA_COLUMN_MAPPING)
    and using them in a new get_all_order_column_mappings_for_cache() method
    that always builds cache entries with the full set of columns. Subclasses
    can still override the protected properties for their own read logic, but
    the cached objects are always complete.

    Also adds debug-level logging (source: hpos-data-cache) when
    set_order_props_from_data() encounters a missing property, to aid
    diagnosis in environments where cross-bleed may still occur via
    other code paths.

    ---------

    Co-authored-by: botwoo <102544806+botwoo@users.noreply.github.com>
    Co-authored-by: Brandon Kraft <public@brandonkraft.com>

diff --git a/plugins/woocommerce/changelog/pr-64067 b/plugins/woocommerce/changelog/pr-64067
new file mode 100644
index 00000000000..be265334018
--- /dev/null
+++ b/plugins/woocommerce/changelog/pr-64067
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix HPOS data cache cross-bleed between order data store subclasses
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index d9f7e94536d..4bfb19a359b 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -64242,12 +64242,6 @@ parameters:
 			count: 1
 			path: src/Internal/DataStores/Orders/OrdersTableDataStore.php

-		-
-			message: '#^Parameter \#2 \$property_name of function property_exists expects string, array\|string given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/Internal/DataStores/Orders/OrdersTableDataStore.php
-
 		-
 			message: '#^Parameter \#3 \$default_value of static method Automattic\\WooCommerce\\Utilities\\ArrayUtil\:\:get_value_or_default\(\) expects null, string given\.$#'
 			identifier: argument.type
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
index 4ae8e0d70c5..ee7ef61dd38 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
@@ -265,7 +265,14 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
 	 *
 	 * @var \string[][]
 	 */
-	protected $order_column_mapping = array(
+	/**
+	 * Full set of order columns for the base order data store. Used via self:: to ensure cached
+	 * order data objects always contain the complete column set, even when a subclass overrides
+	 * $order_column_mapping with a subset.
+	 *
+	 * @since 10.8.0
+	 */
+	private const BASE_ORDER_COLUMN_MAPPING = array(
 		'id'                   => array(
 			'type' => 'int',
 			'name' => 'id',
@@ -337,11 +344,20 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
 	);

 	/**
-	 * Table column to WC_Order mapping for billing addresses in wc_address table.
+	 * Table column to WC_Order mapping for wc_orders table.
 	 *
 	 * @var \string[][]
 	 */
-	protected $billing_address_column_mapping = array(
+	protected $order_column_mapping = self::BASE_ORDER_COLUMN_MAPPING;
+
+	/**
+	 * Full set of billing address columns for the base order data store. Used via self:: to
+	 * ensure cached order data objects always contain the complete column set, even when a
+	 * subclass overrides $billing_address_column_mapping with a subset.
+	 *
+	 * @since 10.8.0
+	 */
+	private const BASE_BILLING_ADDRESS_COLUMN_MAPPING = array(
 		'id'           => array( 'type' => 'int' ),
 		'order_id'     => array( 'type' => 'int' ),
 		'address_type' => array( 'type' => 'string' ),
@@ -392,11 +408,20 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
 	);

 	/**
-	 * Table column to WC_Order mapping for shipping addresses in wc_address table.
+	 * Table column to WC_Order mapping for billing addresses in wc_addresses table.
 	 *
 	 * @var \string[][]
 	 */
-	protected $shipping_address_column_mapping = array(
+	protected $billing_address_column_mapping = self::BASE_BILLING_ADDRESS_COLUMN_MAPPING;
+
+	/**
+	 * Full set of shipping address columns for the base order data store. Used via self:: to
+	 * ensure cached order data objects always contain the complete column set, even when a
+	 * subclass overrides $shipping_address_column_mapping with a subset.
+	 *
+	 * @since 10.8.0
+	 */
+	private const BASE_SHIPPING_ADDRESS_COLUMN_MAPPING = array(
 		'id'           => array( 'type' => 'int' ),
 		'order_id'     => array( 'type' => 'int' ),
 		'address_type' => array( 'type' => 'string' ),
@@ -444,11 +469,20 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
 	);

 	/**
-	 * Table column to WC_Order mapping for wc_operational_data table.
+	 * Table column to WC_Order mapping for shipping addresses in wc_addresses table.
 	 *
 	 * @var \string[][]
 	 */
-	protected $operational_data_column_mapping = array(
+	protected $shipping_address_column_mapping = self::BASE_SHIPPING_ADDRESS_COLUMN_MAPPING;
+
+	/**
+	 * Full set of operational data columns for the base order data store. Used via self:: to ensure
+	 * cached order data objects always contain the complete column set, even when a subclass
+	 * overrides $operational_data_column_mapping with a subset.
+	 *
+	 * @since 10.8.0
+	 */
+	private const BASE_OPERATIONAL_DATA_COLUMN_MAPPING = array(
 		'id'                          => array( 'type' => 'int' ),
 		'order_id'                    => array( 'type' => 'int' ),
 		'created_via'                 => array(
@@ -517,6 +551,13 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
 		),
 	);

+	/**
+	 * Table column to WC_Order mapping for wc_operational_data table.
+	 *
+	 * @var \string[][]
+	 */
+	protected $operational_data_column_mapping = self::BASE_OPERATIONAL_DATA_COLUMN_MAPPING;
+
 	/**
 	 * Cache variable to store combined mapping.
 	 *
@@ -524,6 +565,13 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
 	 */
 	private $all_order_column_mapping;

+	/**
+	 * Cache variable to store combined mapping with full operational data columns.
+	 *
+	 * @var array<string, array<string, array<string, string>>>|null
+	 */
+	private $all_order_column_mapping_for_cache;
+
 	/**
 	 * Return combined mappings for all order tables.
 	 *
@@ -542,6 +590,30 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
 		return $this->all_order_column_mapping;
 	}

+	/**
+	 * Return combined mappings for all order tables, always using the full set of operational
+	 * data columns defined in the base OrdersTableDataStore class. This ensures that cached
+	 * order data objects are complete regardless of which data store subclass populates the
+	 * cache, preventing cross-bleed when different data stores (orders, refunds, subscriptions)
+	 * share the same cache group.
+	 *
+	 * @since 10.8.0
+	 *
+	 * @return array<string, array<string, array<string, string>>> Return combined mapping with full operational data columns.
+	 */
+	private function get_all_order_column_mappings_for_cache() {
+		if ( ! isset( $this->all_order_column_mapping_for_cache ) ) {
+			$this->all_order_column_mapping_for_cache = array(
+				'orders'           => self::BASE_ORDER_COLUMN_MAPPING,
+				'billing_address'  => self::BASE_BILLING_ADDRESS_COLUMN_MAPPING,
+				'shipping_address' => self::BASE_SHIPPING_ADDRESS_COLUMN_MAPPING,
+				'operational_data' => self::BASE_OPERATIONAL_DATA_COLUMN_MAPPING,
+			);
+		}
+
+		return $this->all_order_column_mapping_for_cache;
+	}
+
 	/**
 	 * The group name to use when caching order object data.
 	 *
@@ -1689,12 +1761,26 @@ WHERE
 	 * @param object             $order_data A row of order data from the database.
 	 */
 	protected function set_order_props_from_data( &$order, $order_data ) {
+		// Uses $this->get_all_order_column_mappings() (not the cache variant) intentionally:
+		// each data store subclass should only attempt to set properties it actually maps.
 		foreach ( $this->get_all_order_column_mappings() as $table_name => $column_mapping ) {
 			foreach ( $column_mapping as $column_name => $prop_details ) {
 				if ( ! isset( $prop_details['name'] ) || ! is_string( $prop_details['name'] ) ) {
 					continue;
 				}
 				if ( ! property_exists( $order_data, $prop_details['name'] ) ) {
+					$this->error_logger->debug(
+						sprintf(
+							'Property \'%1$s\' (column \'%2$s\' from table group \'%3$s\') missing from data for order %4$d. Order will use default value for this property.',
+							$prop_details['name'],
+							$column_name,
+							$table_name,
+							$order->get_id()
+						),
+						array(
+							'source' => 'hpos-data-cache',
+						)
+					);
 					continue;
 				}
 				$prop_value = $order_data->{$prop_details['name']};
@@ -1826,7 +1912,7 @@ WHERE
 		foreach ( $table_data as $table_datum ) {
 			$id                = $table_datum->{"{$order_table_alias}_id"};
 			$order_data[ $id ] = new \stdClass();
-			foreach ( $this->get_all_order_column_mappings() as $table_name => $column_mappings ) {
+			foreach ( $this->get_all_order_column_mappings_for_cache() as $table_name => $column_mappings ) {
 				$table_alias = $table_aliases[ $table_name ];
 				// This remapping is required to keep the query length small enough to be supported by implementations such as HyperDB (i.e. fetching some tables in join via alias.*, while others via full name). We can revert this commit if HyperDB starts supporting SRTM for query length more than 3076 characters.
 				foreach ( $column_mappings as $field => $map ) {
diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreCacheCrossBleedTest.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreCacheCrossBleedTest.php
new file mode 100644
index 00000000000..0980a5235c6
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreCacheCrossBleedTest.php
@@ -0,0 +1,372 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\DataStores\Orders;
+
+use Automattic\WooCommerce\Caching\WPCacheEngine;
+use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
+use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
+use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableRefundDataStore;
+use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
+use Automattic\WooCommerce\Utilities\OrderUtil;
+use WC_Order;
+
+/**
+ * Tests for HPOS data cache cross-bleed prevention between data store subclasses.
+ */
+class OrdersTableDataStoreCacheCrossBleedTest extends \HposTestCase {
+	use HPOSToggleTrait;
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var OrdersTableDataStore
+	 */
+	private $sut;
+
+	/**
+	 * The refund data store.
+	 *
+	 * @var OrdersTableRefundDataStore
+	 */
+	private $refund_sut;
+
+	/**
+	 * Whether COT was enabled before the test.
+	 *
+	 * @var bool
+	 */
+	private $cot_state;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+
+		$this->setup_cot();
+		$this->cot_state = OrderUtil::custom_orders_table_usage_is_enabled();
+		$this->toggle_cot_feature_and_usage( true );
+		update_option( CustomOrdersTableController::HPOS_DATASTORE_CACHING_ENABLED_OPTION, 'yes' );
+
+		$container = wc_get_container();
+		$container->reset_all_resolved();
+		$this->sut        = $container->get( OrdersTableDataStore::class );
+		$this->refund_sut = $container->get( OrdersTableRefundDataStore::class );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		$this->toggle_cot_feature_and_usage( $this->cot_state );
+		$this->clean_up_cot_setup();
+		delete_option( CustomOrdersTableController::HPOS_DATASTORE_CACHING_ENABLED_OPTION );
+
+		remove_all_filters( 'wc_allow_changing_orders_storage_while_sync_is_pending' );
+		remove_all_filters( 'woocommerce_logging_class' );
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Refund data store caches all base column properties from all table mappings.
+	 */
+	public function test_refund_data_store_caches_all_base_column_properties(): void {
+		$order = \WC_Helper_Order::create_order();
+		$order->set_status( 'completed' );
+		$order->set_total( '50.00' );
+		$order->save();
+
+		$refund = wc_create_refund(
+			array(
+				'order_id' => $order->get_id(),
+				'amount'   => '10.00',
+				'reason'   => 'Test refund',
+			)
+		);
+		$this->assertNotWPError( $refund, 'Refund creation should not return a WP_Error' );
+		$refund_id = $refund->get_id();
+
+		$this->refund_sut->clear_cached_data( array( $refund_id ) );
+		wp_cache_flush();
+
+		$call_protected = function ( $ids ) {
+			return $this->get_order_data_for_ids( $ids );
+		};
+
+		$refund_data = $call_protected->call( $this->refund_sut, array( $refund_id ) );
+
+		$this->assertArrayHasKey( $refund_id, $refund_data, 'Refund data should be returned' );
+
+		$cached_object = $refund_data[ $refund_id ];
+
+		foreach ( $this->get_all_base_named_properties() as $group => $properties ) {
+			foreach ( $properties as $prop ) {
+				$this->assertTrue(
+					property_exists( $cached_object, $prop ),
+					"Cached object should have '$group' property '$prop' even when loaded by refund data store"
+				);
+			}
+		}
+	}
+
+	/**
+	 * @testdox Order data store caches all base column properties from all table mappings.
+	 */
+	public function test_order_data_store_caches_all_base_column_properties(): void {
+		$order = new WC_Order();
+		$order->set_status( 'completed' );
+		$order->set_recorded_sales( true );
+		$order->set_transaction_id( 'txn_67890' );
+		$order->set_cart_hash( 'hash456' );
+		$order->save();
+		$order_id = $order->get_id();
+
+		$this->sut->clear_cached_data( array( $order_id ) );
+		wp_cache_flush();
+
+		$call_protected = function ( $ids ) {
+			return $this->get_order_data_for_ids( $ids );
+		};
+
+		$order_data    = $call_protected->call( $this->sut, array( $order_id ) );
+		$cached_object = $order_data[ $order_id ];
+
+		foreach ( $this->get_all_base_named_properties() as $group => $properties ) {
+			foreach ( $properties as $prop ) {
+				$this->assertTrue(
+					property_exists( $cached_object, $prop ),
+					"Cached object should have '$group' property '$prop' when loaded by order data store"
+				);
+			}
+		}
+	}
+
+	/**
+	 * Return all named properties from the base class column mappings, grouped by table.
+	 *
+	 * @return array<string, string[]>
+	 */
+	private function get_all_base_named_properties(): array {
+		return array(
+			'orders'           => array(
+				'id',
+				'status',
+				'type',
+				'currency',
+				'cart_tax',
+				'total',
+				'customer_id',
+				'billing_email',
+				'date_created',
+				'date_modified',
+				'parent_id',
+				'payment_method',
+				'payment_method_title',
+				'customer_ip_address',
+				'transaction_id',
+				'customer_user_agent',
+				'customer_note',
+			),
+			'billing_address'  => array(
+				'billing_first_name',
+				'billing_last_name',
+				'billing_company',
+				'billing_address_1',
+				'billing_address_2',
+				'billing_city',
+				'billing_state',
+				'billing_postcode',
+				'billing_country',
+				'billing_email',
+				'billing_phone',
+			),
+			'shipping_address' => array(
+				'shipping_first_name',
+				'shipping_last_name',
+				'shipping_company',
+				'shipping_address_1',
+				'shipping_address_2',
+				'shipping_city',
+				'shipping_state',
+				'shipping_postcode',
+				'shipping_country',
+				'shipping_phone',
+			),
+			'operational_data' => array(
+				'created_via',
+				'version',
+				'prices_include_tax',
+				'recorded_coupon_usage_counts',
+				'download_permissions_granted',
+				'cart_hash',
+				'new_order_email_sent',
+				'order_key',
+				'order_stock_reduced',
+				'date_paid',
+				'date_completed',
+				'shipping_tax',
+				'shipping_total',
+				'discount_tax',
+				'discount_total',
+				'recorded_sales',
+			),
+		);
+	}
+
+	/**
+	 * @testdox Order loaded via order data store retains correct values when cache was populated by refund data store.
+	 */
+	public function test_order_retains_values_when_cache_populated_by_refund_store(): void {
+		$order = new WC_Order();
+		$order->set_status( 'completed' );
+		$order->set_total( '100.00' );
+		$order->set_recorded_sales( true );
+		$order->set_order_stock_reduced( true );
+		$order->set_transaction_id( 'txn_cross_bleed_test' );
+		$order->set_cart_hash( 'cross_bleed_hash' );
+		$order->save();
+		$order_id = $order->get_id();
+
+		$refund = wc_create_refund(
+			array(
+				'order_id' => $order_id,
+				'amount'   => '25.00',
+				'reason'   => 'Cross-bleed regression test',
+			)
+		);
+		$this->assertNotWPError( $refund, 'Refund creation should not return a WP_Error' );
+
+		// Flush cache and reload the parent order via the refund data store to populate cache.
+		$this->sut->clear_cached_data( array( $order_id ) );
+		$this->refund_sut->clear_cached_data( array( $order_id ) );
+		wp_cache_flush();
+
+		$call_get_data = function ( $ids ) {
+			return $this->get_order_data_for_ids( $ids );
+		};
+		$call_get_data->call( $this->refund_sut, array( $order_id ) );
+
+		// Now load the order through the normal order data store, which should hit cache.
+		$reloaded_order = wc_get_order( $order_id );
+
+		$this->assertTrue( $reloaded_order->get_recorded_sales(), 'recorded_sales should be true, not reset to default' );
+		$this->assertTrue( $reloaded_order->get_order_stock_reduced(), 'order_stock_reduced should be true, not reset to default' );
+		$this->assertSame( 'txn_cross_bleed_test', $reloaded_order->get_transaction_id(), 'transaction_id should be preserved' );
+		$this->assertSame( 'cross_bleed_hash', $reloaded_order->get_cart_hash(), 'cart_hash should be preserved' );
+	}
+
+	/**
+	 * @testdox Debug logging is triggered when a property is missing from order data.
+	 */
+	public function test_debug_logging_on_missing_property(): void {
+		$fake_logger = $this->create_fake_logger();
+		add_filter(
+			'woocommerce_logging_class',
+			function () use ( $fake_logger ) {
+				return $fake_logger;
+			}
+		);
+
+		$container = wc_get_container();
+		$container->reset_all_resolved();
+		$sut = $container->get( OrdersTableDataStore::class );
+
+		$order = new WC_Order();
+		$order->save();
+		$order_id = $order->get_id();
+
+		$order_data     = new \stdClass();
+		$order_data->id = $order_id;
+
+		$call_protected = function ( $order, $order_data ) {
+			$this->set_order_props_from_data( $order, $order_data );
+		};
+
+		$call_protected->call( $sut, $order, $order_data );
+
+		$this->assertNotEmpty( $fake_logger->debug_calls, 'Debug log should fire when properties are missing from order data' );
+
+		$found_hpos_source = false;
+		foreach ( $fake_logger->debug_calls as $call ) {
+			if ( isset( $call['context']['source'] ) && 'hpos-data-cache' === $call['context']['source'] ) {
+				$found_hpos_source = true;
+				break;
+			}
+		}
+		$this->assertTrue( $found_hpos_source, 'Debug log entries should have source "hpos-data-cache"' );
+
+		remove_all_filters( 'woocommerce_logging_class' );
+	}
+
+	/**
+	 * Create a fake logger for testing.
+	 *
+	 * @return object Fake logger implementing WC_Logger_Interface.
+	 */
+	// phpcs:disable Squiz.Commenting
+	private function create_fake_logger(): object {
+		return new class() implements \WC_Logger_Interface {
+			public array $debug_calls   = array();
+			public array $info_calls    = array();
+			public array $warning_calls = array();
+			public array $error_calls   = array();
+
+			public function add( $handle, $message, $level = \WC_Log_Levels::NOTICE ) {
+				unset( $handle, $message, $level ); // Avoid parameter not used PHPCS errors.
+				return true;
+			}
+
+			public function log( $level, $message, $context = array() ) {
+				unset( $level, $message, $context ); // Avoid parameter not used PHPCS errors.
+			}
+
+			public function emergency( $message, $context = array() ) {
+				unset( $message, $context ); // Avoid parameter not used PHPCS errors.
+			}
+
+			public function alert( $message, $context = array() ) {
+				unset( $message, $context ); // Avoid parameter not used PHPCS errors.
+			}
+
+			public function critical( $message, $context = array() ) {
+				unset( $message, $context ); // Avoid parameter not used PHPCS errors.
+			}
+
+			public function notice( $message, $context = array() ) {
+				unset( $message, $context ); // Avoid parameter not used PHPCS errors.
+			}
+
+			public function debug( $message, $context = array() ) {
+				$this->debug_calls[] = array(
+					'message' => $message,
+					'context' => $context,
+				);
+			}
+
+			public function info( $message, $context = array() ) {
+				$this->info_calls[] = array(
+					'message' => $message,
+					'context' => $context,
+				);
+			}
+
+			public function warning( $message, $context = array() ) {
+				$this->warning_calls[] = array(
+					'message' => $message,
+					'context' => $context,
+				);
+			}
+
+			public function error( $message, $context = array() ) {
+				$this->error_calls[] = array(
+					'message' => $message,
+					'context' => $context,
+				);
+			}
+		};
+	}
+	// phpcs:enable Squiz.Commenting
+}