Commit 9e7beb4ed2d for woocommerce

commit 9e7beb4ed2d8c2d7aa472690b17d1f30a1492720
Author: Tom Cafferkey <tjcafferkey@gmail.com>
Date:   Thu Nov 5 11:19:22 2026 +0000

    Fix product sales counts after order item edits (#70414)

    * Fix product sales counts after order item edits

    * Add changelog entry for order edit sales counts

    * Refactor product sales tracking into the existing item data store

    * Revert "Refactor product sales tracking into the existing item data store"

    This reverts commit ba593787269baf92a7124cfbe58416d9f6eadaaf.

    * Update var names

    * Fix sync-on-read test setup isolation

diff --git a/plugins/woocommerce/changelog/fix-52156-sales-after-order-edits b/plugins/woocommerce/changelog/fix-52156-sales-after-order-edits
new file mode 100644
index 00000000000..31bcadfdde5
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-52156-sales-after-order-edits
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Update product sales counts when products or quantities in an existing order are edited.
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 87fca571b79..75a1fe4f562 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -446,6 +446,7 @@ final class WooCommerce {
 		$container->get( Automattic\WooCommerce\Internal\PushNotifications\PushNotifications::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Orders\PointOfSaleEmailHandler::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Orders\UnpaidOrderTimeoutMarker::class )->register();
+		$container->get( Automattic\WooCommerce\Internal\Orders\ProductSales::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\POS\POSController::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\ShopperLists\ShopperListsController::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\StockNotifications\StockNotifications::class )->register();
diff --git a/plugins/woocommerce/src/Internal/Orders/ProductSales.php b/plugins/woocommerce/src/Internal/Orders/ProductSales.php
new file mode 100644
index 00000000000..c40c2a0116d
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Orders/ProductSales.php
@@ -0,0 +1,197 @@
+<?php
+/**
+ * ProductSales class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Orders;
+
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+use WC_Data_Store;
+use WC_Order;
+use WC_Order_Data_Store_Interface;
+use WC_Order_Factory;
+use WC_Order_Item_Product;
+use WC_Product_Data_Store_Interface;
+
+/**
+ * Keeps product sales counts in sync when recorded order items change.
+ *
+ * @internal
+ * @since 11.4.0
+ */
+class ProductSales implements RegisterHooksInterface {
+
+	/**
+	 * Persisted item values before saving, keyed by PHP object ID.
+	 *
+	 * @var array<int, array{product_id: int, quantity: int, order_id: int}>
+	 */
+	private array $item_snapshots_before_save = array();
+
+	/**
+	 * Recorded item sales captured before deletion, keyed by item ID.
+	 *
+	 * @var array<int, array{product_id: int, quantity: int}>
+	 */
+	private array $item_sales_before_delete = array();
+
+	/**
+	 * Register hooks.
+	 *
+	 * @since 11.4.0
+	 */
+	public function register(): void {
+		add_action( 'woocommerce_before_order_item_object_save', array( $this, 'handle_woocommerce_before_order_item_object_save' ) );
+		add_action( 'woocommerce_after_order_item_object_save', array( $this, 'handle_woocommerce_after_order_item_object_save' ) );
+		add_action( 'woocommerce_before_delete_order_item', array( $this, 'handle_woocommerce_before_delete_order_item' ) );
+		add_action( 'woocommerce_delete_order_item', array( $this, 'handle_woocommerce_delete_order_item' ) );
+	}
+
+	/**
+	 * Capture the persisted values before an item is saved.
+	 *
+	 * @since 11.4.0
+	 * @internal
+	 * @param mixed $item Order item.
+	 */
+	public function handle_woocommerce_before_order_item_object_save( $item ): void {
+		if ( ! $item instanceof WC_Order_Item_Product ) {
+			return;
+		}
+
+		$object_id = spl_object_id( $item );
+		unset( $this->item_snapshots_before_save[ $object_id ] );
+		if ( $item->get_id() && ! array_intersect( array( 'product_id', 'quantity', 'order_id' ), array_keys( $item->get_changes() ) ) ) {
+			return;
+		}
+
+		$persisted_item                                 = $item->get_id() ? WC_Order_Factory::get_order_item( $item->get_id() ) : false;
+		$this->item_snapshots_before_save[ $object_id ] = array(
+			'product_id' => $persisted_item instanceof WC_Order_Item_Product ? $persisted_item->get_product_id() : 0,
+			'quantity'   => $persisted_item instanceof WC_Order_Item_Product ? absint( $persisted_item->get_quantity() ) : 0,
+			'order_id'   => $persisted_item ? $persisted_item->get_order_id() : $item->get_order_id(),
+		);
+	}
+
+	/**
+	 * Apply only the sales difference after an item is saved.
+	 *
+	 * @since 11.4.0
+	 * @internal
+	 * @param mixed $item Order item.
+	 */
+	public function handle_woocommerce_after_order_item_object_save( $item ): void {
+		if ( ! $item instanceof WC_Order_Item_Product ) {
+			return;
+		}
+
+		$object_id        = spl_object_id( $item );
+		$item_before_save = $this->item_snapshots_before_save[ $object_id ] ?? null;
+		unset( $this->item_snapshots_before_save[ $object_id ] );
+		if ( null === $item_before_save ) {
+			return;
+		}
+
+		$previous_order_has_recorded_sales = $this->has_recorded_sales( $item_before_save['order_id'] );
+		$current_order_has_recorded_sales  = $item_before_save['order_id'] === $item->get_order_id() ? $previous_order_has_recorded_sales : $this->has_recorded_sales( $item->get_order_id() );
+		if ( ! $previous_order_has_recorded_sales && ! $current_order_has_recorded_sales ) {
+			return;
+		}
+
+		$current_product_id      = $item->get_product_id();
+		$current_sales_quantity  = $current_order_has_recorded_sales ? absint( $item->get_quantity() ) : 0;
+		$previous_sales_quantity = $previous_order_has_recorded_sales ? $item_before_save['quantity'] : 0;
+		if ( $item_before_save['product_id'] === $current_product_id ) {
+			$this->update_sales( $current_product_id, $current_sales_quantity - $previous_sales_quantity );
+		} else {
+			$this->update_sales( $item_before_save['product_id'], -$previous_sales_quantity );
+			$this->update_sales( $current_product_id, $current_sales_quantity );
+		}
+	}
+
+	/**
+	 * Capture recorded sales before the item data is deleted.
+	 *
+	 * @since 11.4.0
+	 * @internal
+	 * @param mixed $item_id Order item ID.
+	 */
+	public function handle_woocommerce_before_delete_order_item( $item_id ): void {
+		if ( ! is_numeric( $item_id ) ) {
+			return;
+		}
+
+		$item_id = absint( $item_id );
+		unset( $this->item_sales_before_delete[ $item_id ] );
+		$item = WC_Order_Factory::get_order_item( $item_id );
+		if ( ! $item instanceof WC_Order_Item_Product || ! $this->has_recorded_sales( $item->get_order_id() ) ) {
+			return;
+		}
+
+		$this->item_sales_before_delete[ $item_id ] = array(
+			'product_id' => $item->get_product_id(),
+			'quantity'   => absint( $item->get_quantity() ),
+		);
+	}
+
+	/**
+	 * Reverse the removed item's sales.
+	 *
+	 * @since 11.4.0
+	 * @internal
+	 * @param mixed $item_id Order item ID.
+	 */
+	public function handle_woocommerce_delete_order_item( $item_id ): void {
+		if ( ! is_numeric( $item_id ) ) {
+			return;
+		}
+
+		$item_id             = absint( $item_id );
+		$sales_before_delete = $this->item_sales_before_delete[ $item_id ] ?? null;
+		unset( $this->item_sales_before_delete[ $item_id ] );
+		if ( null !== $sales_before_delete ) {
+			$this->update_sales( $sales_before_delete['product_id'], -$sales_before_delete['quantity'] );
+		}
+	}
+
+	/**
+	 * Check the persisted order, rather than an item's potentially stale order instance.
+	 *
+	 * @param int $order_id Order ID.
+	 * @return bool
+	 */
+	private function has_recorded_sales( int $order_id ): bool {
+		$order = $order_id ? wc_get_order( $order_id ) : false;
+		if ( ! $order instanceof WC_Order ) {
+			return false;
+		}
+
+		/**
+		 * Order data store.
+		 *
+		 * @var WC_Order_Data_Store_Interface $data_store
+		 */
+		$data_store = $order->get_data_store();
+		return $data_store->get_recorded_sales( $order );
+	}
+
+	/**
+	 * Update sales through the product data store, including its lookup table and caches.
+	 *
+	 * @param int $product_id Product ID.
+	 * @param int $difference Quantity difference.
+	 */
+	private function update_sales( int $product_id, int $difference ): void {
+		if ( $product_id && $difference ) {
+			/**
+			 * Product data store.
+			 *
+			 * @var WC_Product_Data_Store_Interface $data_store
+			 */
+			$data_store = WC_Data_Store::load( 'product' );
+			$data_store->update_product_sales( $product_id, abs( $difference ), $difference > 0 ? 'increase' : 'decrease' );
+		}
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
index e283b020dff..a9e5c3888f5 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
@@ -1631,13 +1631,15 @@ class OrdersTableDataStoreTests extends \HposTestCase {
 	public function test_read_with_direct_meta_write() {
 		$this->toggle_cot_feature_and_usage( true );
 		$this->enable_cot_sync();
-		add_filter( 'woocommerce_hpos_enable_sync_on_read', '__return_true' );
 		$order = $this->create_complex_cot_order();

 		$post_object = get_post( $order->get_id() );
 		// Make sure that COT sync is enabled, by checking that this is not placeholder post.
 		$this->assertTrue( get_post_type( $post_object->ID ) === 'shop_order' );

+		// Fixture saves can read the order, so enable sync-on-read only after setup.
+		add_filter( 'woocommerce_hpos_enable_sync_on_read', '__return_true' );
+
 		// simulate direct write.
 		update_post_meta( $post_object->ID, 'my_custom_meta', array( 'key' => 'value' ) );

@@ -1656,7 +1658,6 @@ class OrdersTableDataStoreTests extends \HposTestCase {
 	public function test_read_multiple_with_direct_write() {
 		$this->enable_cot_sync();
 		$this->toggle_cot_feature_and_usage( true );
-		add_filter( 'woocommerce_hpos_enable_sync_on_read', '__return_true' );
 		$order       = $this->create_complex_cot_order();
 		$order_total = $order->get_total();
 		$order->add_meta_data( 'custom_meta_1', 'custom_value_1' );
@@ -1666,6 +1667,9 @@ class OrdersTableDataStoreTests extends \HposTestCase {
 		$post_object = get_post( $order->get_id() );
 		assert( get_post_type( $post_object->ID ) === 'shop_order' );

+		// Fixture saves can read the order, so enable sync-on-read only after setup.
+		add_filter( 'woocommerce_hpos_enable_sync_on_read', '__return_true' );
+
 		// simulate direct write.
 		update_post_meta( $post_object->ID, '_order_total', $order_total + 100 ); // core table.
 		update_post_meta( $post_object->ID, '_billing_first_name', 'John Doe Updated' ); // address table.
@@ -1695,7 +1699,6 @@ class OrdersTableDataStoreTests extends \HposTestCase {
 	public function test_is_post_different_from_order() {
 		$this->toggle_cot_feature_and_usage( true );
 		$this->enable_cot_sync();
-		add_filter( 'woocommerce_hpos_enable_sync_on_read', '__return_true' );
 		$order                         = $this->create_complex_cot_order();
 		$post_order_comparison_closure = function ( $order ) {
 			$post_order = $this->get_post_orders_for_ids( array( $order->get_id() => $order ) )[ $order->get_id() ];
@@ -1705,6 +1708,9 @@ class OrdersTableDataStoreTests extends \HposTestCase {
 		// No changes, post and order should be same.
 		$this->assertFalse( $post_order_comparison_closure->call( $this->sut, $order ) );

+		// Fixture saves can read the order, so enable sync-on-read only after setup.
+		add_filter( 'woocommerce_hpos_enable_sync_on_read', '__return_true' );
+
 		// Simulate direct write.
 		update_post_meta( $order->get_id(), 'my_custom_meta', array( 'key' => 'value' ) );

diff --git a/plugins/woocommerce/tests/php/src/Internal/Orders/ProductSalesTest.php b/plugins/woocommerce/tests/php/src/Internal/Orders/ProductSalesTest.php
new file mode 100644
index 00000000000..2c7671a4bec
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Orders/ProductSalesTest.php
@@ -0,0 +1,233 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Orders;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use WC_Helper_Product;
+use WC_Order;
+use WC_Order_Item_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for product sales counts after order item edits.
+ */
+class ProductSalesTest extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox Updates sales counts when a recorded order's items are edited, added or removed.
+	 */
+	public function test_item_edits_update_sales(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$other   = WC_Helper_Product::create_simple_product();
+		$order   = wc_create_order();
+		$item_id = $order->add_product( $product, 1 );
+		$order->update_status( OrderStatus::ON_HOLD );
+		$this->assertSame( 1, wc_get_product( $product->get_id() )->get_total_sales() );
+
+		$item = new WC_Order_Item_Product( $item_id );
+		$item->set_quantity( 5 );
+		$item->save();
+		$this->assertSame( 5, wc_get_product( $product->get_id() )->get_total_sales(), 'Quantity increases should increase sales.' );
+
+		$item->set_quantity( 2 );
+		$item->save();
+		$item->save();
+		$this->assertSame( 2, wc_get_product( $product->get_id() )->get_total_sales(), 'Repeated saves should not count sales twice.' );
+
+		$other_item_id = $order->add_product( $other, 3 );
+		$this->assertSame( 3, wc_get_product( $other->get_id() )->get_total_sales(), 'New items in recorded orders should count as sales.' );
+
+		wc_delete_order_item( $other_item_id );
+		$this->assertSame( 0, wc_get_product( $other->get_id() )->get_total_sales(), 'Removing an item should reverse its sales.' );
+
+		$item->delete();
+		$this->assertSame( 0, wc_get_product( $product->get_id() )->get_total_sales(), 'CRUD deletion should also reverse sales.' );
+	}
+
+	/**
+	 * @testdox Does not count edits to an order whose sales have not been recorded.
+	 */
+	public function test_unrecorded_order_edits_do_not_count_sales(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$order   = wc_create_order();
+		$item_id = $order->add_product( $product, 1 );
+		$item    = new WC_Order_Item_Product( $item_id );
+		$item->set_quantity( 5 );
+		$item->save();
+		$this->assertSame( 0, wc_get_product( $product->get_id() )->get_total_sales(), 'Pending orders must not record sales on item saves.' );
+
+		$order = new WC_Order( $order->get_id() );
+		$order->update_status( OrderStatus::ON_HOLD );
+		$this->assertSame( 5, wc_get_product( $product->get_id() )->get_total_sales(), 'The status transition should count the final quantity once.' );
+	}
+
+	/**
+	 * @testdox Reverses the edited quantities on cancellation, trash and deletion.
+	 */
+	public function test_edited_sales_follow_order_status_changes(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$order   = wc_create_order();
+		$item_id = $order->add_product( $product, 1 );
+		$order->update_status( OrderStatus::ON_HOLD );
+
+		$order = new WC_Order( $order->get_id() );
+		$items = $order->get_items();
+		$item  = $items[ $item_id ];
+		$item->set_quantity( 5 );
+		$order->set_status( OrderStatus::CANCELLED );
+		$order->save();
+		$this->assertSame( 0, wc_get_product( $product->get_id() )->get_total_sales(), 'Editing and cancelling together should reverse the correct quantity.' );
+
+		$order = new WC_Order( $order->get_id() );
+		$order->update_status( OrderStatus::ON_HOLD );
+		$this->assertSame( 5, wc_get_product( $product->get_id() )->get_total_sales() );
+
+		$order_id = $order->get_id();
+		$order->delete( false );
+		$this->assertSame( 0, wc_get_product( $product->get_id() )->get_total_sales(), 'Trashing should reverse the edited sales.' );
+		$order = wc_get_order( $order_id );
+		$order->untrash();
+		$this->assertSame( 5, wc_get_product( $product->get_id() )->get_total_sales() );
+		wc_get_order( $order->get_id() )->delete( true );
+		$this->assertSame( 0, wc_get_product( $product->get_id() )->get_total_sales(), 'Order deletion should not reverse item sales twice.' );
+	}
+
+	/**
+	 * @testdox Transfers sales to the replacement product when an item changes product.
+	 */
+	public function test_changing_item_product_updates_both_products(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$other   = WC_Helper_Product::create_simple_product();
+		$order   = wc_create_order();
+		$item_id = $order->add_product( $product, 2 );
+		$order->update_status( OrderStatus::ON_HOLD );
+
+		$item = new WC_Order_Item_Product( $item_id );
+		$item->set_product( $other );
+		$item->set_quantity( 3 );
+		$item->save();
+
+		$this->assertSame( 0, wc_get_product( $product->get_id() )->get_total_sales(), 'The original product should no longer count the item.' );
+		$this->assertSame( 3, wc_get_product( $other->get_id() )->get_total_sales(), 'The replacement product should count the new quantity.' );
+	}
+
+	/**
+	 * @testdox Updates sales through the admin item-save path without counting recalculations twice.
+	 */
+	public function test_admin_item_save_updates_sales(): void {
+		global $wpdb;
+		$product = WC_Helper_Product::create_simple_product();
+		$order   = wc_create_order();
+		$item_id = $order->add_product( $product, 1 );
+		$order->update_status( OrderStatus::ON_HOLD );
+		$items = array(
+			'order_item_id'  => array( $item_id ),
+			'order_item_qty' => array( $item_id => '5' ),
+		);
+
+		wc_save_order_items( $order->get_id(), $items );
+		wc_save_order_items( $order->get_id(), $items );
+		$this->assertSame( 5, wc_get_product( $product->get_id() )->get_total_sales(), 'Repeated admin saves should only count the quantity difference.' );
+		$this->assertSame(
+			5,
+			(int) $wpdb->get_var( $wpdb->prepare( "SELECT total_sales FROM {$wpdb->wc_product_meta_lookup} WHERE product_id = %d", $product->get_id() ) ),
+			'The product lookup table should also reflect the edited quantity.'
+		);
+
+		$items['order_item_qty'][ $item_id ] = '0';
+		wc_save_order_items( $order->get_id(), $items );
+		$this->assertSame( 0, wc_get_product( $product->get_id() )->get_total_sales(), 'Setting quantity to zero in admin should reverse the sales.' );
+	}
+
+	/**
+	 * @testdox Counts variation sales against the parent product, just like initial sales recording.
+	 */
+	public function test_variation_edits_update_parent_sales(): void {
+		$parent    = WC_Helper_Product::create_variation_product();
+		$variation = wc_get_product( $parent->get_children()[0] );
+		$order     = wc_create_order();
+		$item_id   = $order->add_product( $variation, 1 );
+		$order->update_status( OrderStatus::ON_HOLD );
+
+		$item = new WC_Order_Item_Product( $item_id );
+		$item->set_quantity( 4 );
+		$item->save();
+
+		$this->assertSame( 4, wc_get_product( $parent->get_id() )->get_total_sales(), 'Variation quantity changes should update parent sales.' );
+		$this->assertSame( 0, (int) wc_get_product( $variation->get_id() )->get_total_sales(), 'Variation sales should not be recorded separately.' );
+	}
+
+	/**
+	 * @testdox Leaves sales unchanged when refund line items are saved or deleted.
+	 */
+	public function test_refund_items_do_not_change_sales(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$order   = wc_create_order();
+		$item_id = $order->add_product( $product, 5 );
+		$order->calculate_totals();
+		$order->update_status( OrderStatus::ON_HOLD );
+		$refund = wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => 1,
+				'line_items' => array(
+					$item_id => array(
+						'qty'          => 1,
+						'refund_total' => 1,
+					),
+				),
+			)
+		);
+		$this->assertNotWPError( $refund );
+		$items = $refund->get_items();
+		$item  = reset( $items );
+		$item->set_quantity( -2 );
+		$item->save();
+		$item->delete();
+
+		$this->assertSame( 5, wc_get_product( $product->get_id() )->get_total_sales(), 'Refund item writes should preserve the existing sales-count behavior.' );
+	}
+
+	/**
+	 * @testdox Preserves sales from other orders when editing and removing an item.
+	 */
+	public function test_edits_preserve_other_sales(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_total_sales( 10 );
+		$product->save();
+		$order   = wc_create_order();
+		$item_id = $order->add_product( $product, 1 );
+		$order->update_status( OrderStatus::ON_HOLD );
+		$item = new WC_Order_Item_Product( $item_id );
+		$item->set_quantity( 5 );
+		$item->save();
+		$this->assertSame( 15, wc_get_product( $product->get_id() )->get_total_sales(), 'Only the edited order contribution should change.' );
+
+		$order = new WC_Order( $order->get_id() );
+		$order->remove_item( $item_id );
+		$order->save();
+		$this->assertSame( 10, wc_get_product( $product->get_id() )->get_total_sales(), 'Removing the edited item should preserve unrelated sales.' );
+	}
+
+	/**
+	 * @testdox Adjusts sales correctly when an item is moved between recorded and unrecorded orders.
+	 */
+	public function test_moving_item_between_orders(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$pending = wc_create_order();
+		$order   = wc_create_order();
+		$item_id = $order->add_product( $product, 2 );
+		$order->update_status( OrderStatus::ON_HOLD );
+
+		$item = new WC_Order_Item_Product( $item_id );
+		$item->set_order_id( $pending->get_id() );
+		$item->set_quantity( 3 );
+		$item->save();
+		$this->assertSame( 0, wc_get_product( $product->get_id() )->get_total_sales(), 'Moving to an unrecorded order should reverse the previously counted quantity.' );
+
+		$item->set_order_id( $order->get_id() );
+		$item->save();
+		$this->assertSame( 3, wc_get_product( $product->get_id() )->get_total_sales(), 'Moving back to a recorded order should count the current quantity.' );
+	}
+}