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.' );
+ }
+}