Commit 516877aa79d for woocommerce
commit 516877aa79de0c6ac7fbaf11ee74c75738440359
Author: Ján Mikláš <neosinner@gmail.com>
Date: Fri Sep 4 16:52:52 2026 +0200
Fix customer filters for partially refunded orders (#67713)
* Fix customer filters for partially refunded orders
Customer report aggregates, numeric filters, Stats results and oldest-order
lookups counted refund rows with stale returning_customer markers as orders and
skipped parented shop_order rows. Use the authoritative order type for parented
rows (short-circuiting on the NULL marker refunds always carry) and invalidate
Analytics report caches in the 11.2.0 update so existing stores get fixed results.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GU3W7KnYnsMo2dTVk65LPB
* Reset stale refund returning-customer markers in the 11.2.0 update
Refund rows are written with a NULL returning_customer, but earlier
first-order recalculations could overwrite that marker and never restore
it, since set_customer_first_order() only rewrites non-NULL rows. Reset
those markers in batches so the customer aggregates stay on the cheap
NULL branch and the Orders report falls back to the refunded order's
value again, then invalidate the Analytics cache once the last batch
completes so no report is cached against half-migrated data.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APxNvFVJ543DPf2ZYFhnpT
* Paginate the refund marker reset by order ID and stop on database errors
Walk the order stats table by order ID so each batch continues where
the previous one stopped instead of rescanning already-reset rows, and
treat a database error as a reason to stop and log rather than to
silently report completion or retry forever. The report queries stay
correct without the reset, so stopping is the safe failure mode.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APxNvFVJ543DPf2ZYFhnpT
* Re-run batched database update callbacks under WP-CLI
WC_Install::run_update_callback() re-queues a callback that returns
true, but wp wc update called each callback once and ignored the return
value, so a batched update only ever processed its first batch there.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APxNvFVJ543DPf2ZYFhnpT
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-wooplug-529-refunded-order-count b/plugins/woocommerce/changelog/fix-wooplug-529-refunded-order-count
new file mode 100644
index 00000000000..c64c8b102bf
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-529-refunded-order-count
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix Customers report aggregates and numeric filters for refunds and parented orders.
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index dc7eef11f6d..68f66bb20da 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -359,6 +359,9 @@ class WC_Install {
'wc_update_11201_migrate_tax_lookup_order_items',
'wc_update_11201_invalidate_analytics_reports_cache',
),
+ '11.2.0-2' => array(
+ 'wc_update_11202_reset_refund_returning_customer_markers',
+ ),
);
/**
diff --git a/plugins/woocommerce/includes/cli/class-wc-cli-update-command.php b/plugins/woocommerce/includes/cli/class-wc-cli-update-command.php
index 5cc19a275db..4964dceb37b 100644
--- a/plugins/woocommerce/includes/cli/class-wc-cli-update-command.php
+++ b/plugins/woocommerce/includes/cli/class-wc-cli-update-command.php
@@ -75,7 +75,10 @@ class WC_CLI_Update_Command {
);
foreach ( $callbacks_to_run as $update_callback ) {
- call_user_func( $update_callback );
+ // Batched callbacks return true while more work remains, as WC_Install::run_update_callback() expects.
+ do {
+ $needs_another_run = (bool) call_user_func( $update_callback );
+ } while ( $needs_another_run );
$update_count ++;
$progress->tick();
}
diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index d7a6ff8862e..37811b0b6de 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -43,6 +43,7 @@ use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;
use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
use Automattic\WooCommerce\Internal\VariationGallery\Package as VariationGalleryPackage;
+use Automattic\WooCommerce\Utilities\OrderUtil;
use Automattic\WooCommerce\Utilities\StringUtil;
use Automattic\WooCommerce\Blocks\InboxNotifications;
use Automattic\WooCommerce\Blocks\Options as BlockOptions;
@@ -3732,3 +3733,70 @@ function wc_update_11201_invalidate_analytics_reports_cache() {
\Automattic\WooCommerce\Admin\API\Reports\Cache::invalidate();
}
}
+
+/**
+ * Reset stale returning-customer markers on refund rows.
+ *
+ * Refund rows in the order stats table are written with a NULL returning_customer, but earlier
+ * first-order recalculations could overwrite that marker and never restore it. Customer aggregates
+ * now fall back to the order type for such rows; resetting the marker keeps them on the cheap path
+ * and restores the Orders report fallback to the refunded order's value.
+ *
+ * Batches walk the table by order ID. A database error stops the migration and is logged instead
+ * of retried, because the report queries stay correct without the reset.
+ *
+ * @since 11.2.0
+ *
+ * @return bool True to run again for the next batch, false when completed.
+ */
+function wc_update_11202_reset_refund_returning_customer_markers() {
+ global $wpdb;
+
+ $last_id_option = 'woocommerce_update_11202_last_refund_order_id';
+ $order_stats_table = $wpdb->prefix . 'wc_order_stats';
+ $orders_table = OrderUtil::get_table_for_orders();
+ $hpos_enabled = OrderUtil::custom_orders_table_usage_is_enabled();
+ $order_id_column = $hpos_enabled ? 'id' : 'ID';
+ $order_type_column = $hpos_enabled ? 'type' : 'post_type';
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table and column names cannot be prepared.
+ $refund_ids = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT stats.order_id FROM {$order_stats_table} AS stats
+ INNER JOIN {$orders_table} AS orders ON orders.{$order_id_column} = stats.order_id
+ WHERE stats.order_id > %d AND stats.returning_customer IS NOT NULL AND orders.{$order_type_column} = 'shop_order_refund'
+ ORDER BY stats.order_id ASC
+ LIMIT 250",
+ (int) get_option( $last_id_option, 0 )
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ if ( '' === $wpdb->last_error && ! empty( $refund_ids ) ) {
+ $refund_ids = array_map( 'intval', $refund_ids );
+ $id_placeholders = implode( ', ', array_fill( 0, count( $refund_ids ), '%d' ) );
+ $updated = $wpdb->query(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table name cannot be prepared; placeholders are generated per ID.
+ $wpdb->prepare( "UPDATE {$order_stats_table} SET returning_customer = NULL WHERE order_id IN ( {$id_placeholders} )", $refund_ids )
+ );
+
+ if ( false !== $updated ) {
+ update_option( $last_id_option, end( $refund_ids ), false );
+ return true;
+ }
+ }
+
+ if ( '' !== $wpdb->last_error ) {
+ wc_get_logger()->error(
+ sprintf( 'Stopped resetting refund returning-customer markers: %s', $wpdb->last_error ),
+ array( 'source' => 'wc_update_11202_reset_refund_returning_customer_markers' )
+ );
+ }
+
+ delete_option( $last_id_option );
+
+ // Reports cached against half-migrated data would otherwise keep being served.
+ wc_update_11201_invalidate_analytics_reports_cache();
+
+ return false;
+}
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
index c0ff64b5e8f..9002e69b9af 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
@@ -70,7 +70,9 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
protected function assign_report_columns() {
global $wpdb;
$table_name = self::get_db_table_name();
- $orders_count = 'SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END )';
+ $order_stats_table = $wpdb->prefix . 'wc_order_stats';
+ $countable_order = static::get_countable_customer_order_predicate( $order_stats_table );
+ $orders_count = "SUM( CASE WHEN {$countable_order} THEN 1 ELSE 0 END )";
$total_spend = 'SUM( total_sales )';
$this->report_columns = array(
'id' => "{$table_name}.customer_id as id",
@@ -96,6 +98,32 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
);
}
+ /**
+ * Get the SQL predicate that includes orders while excluding refund rows.
+ *
+ * Parented rows use the authoritative order type because legacy refund stats can have a non-null
+ * returning_customer value.
+ *
+ * @since 11.2.0
+ *
+ * @param string $order_stats_table Fully qualified order stats table name.
+ * @return string
+ */
+ protected static function get_countable_customer_order_predicate( $order_stats_table ) {
+ $orders_table = OrderUtil::get_table_for_orders();
+ $hpos_enabled = OrderUtil::custom_orders_table_usage_is_enabled();
+ $order_id_column = $hpos_enabled ? 'id' : 'ID';
+ $order_type_column = $hpos_enabled ? 'type' : 'post_type';
+
+ $source_order_exists = "EXISTS ( SELECT 1 FROM {$orders_table} AS countable_customer_order";
+ $source_order_exists .= " WHERE countable_customer_order.{$order_id_column} = {$order_stats_table}.order_id";
+ $source_order_exists .= " AND countable_customer_order.{$order_type_column} = 'shop_order' )";
+
+ // Refund rows are always written with a NULL returning_customer and first-order recalculation never touches
+ // NULL rows, so a NULL marker identifies a refund without the EXISTS lookup; only stale non-null markers need it.
+ return "( CASE WHEN {$order_stats_table}.parent_id = 0 THEN 1 WHEN {$order_stats_table}.parent_id IS NULL THEN 0 WHEN {$order_stats_table}.returning_customer IS NULL THEN 0 ELSE {$source_order_exists} END )";
+ }
+
/**
* Set up all the hooks for maintaining and populating table data.
*/
@@ -461,9 +489,12 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
$where_clauses[] = "{$customer_lookup_table}.user_id IS " . ( 'registered' === $user_type ? 'NOT NULL' : 'NULL' );
}
- $numeric_params = array(
+ $countable_order = static::get_countable_customer_order_predicate( $order_stats_table_name );
+ $orders_count = "SUM( CASE WHEN {$countable_order} THEN 1 ELSE 0 END )";
+ $avg_order_value = "CASE WHEN {$orders_count} = 0 THEN NULL ELSE SUM( total_sales ) / {$orders_count} END";
+ $numeric_params = array(
'orders_count' => array(
- 'column' => 'COUNT( order_id )',
+ 'column' => $orders_count,
'format' => '%d',
),
'total_spend' => array(
@@ -471,7 +502,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
'format' => '%f',
),
'avg_order_value' => array(
- 'column' => '( SUM( total_sales ) / COUNT( order_id ) )',
+ 'column' => $avg_order_value,
'format' => '%f',
),
);
@@ -874,10 +905,9 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
* Retrieve the oldest orders made by a customer.
*
* Refunds share the customer of the order they refund, but they are not orders of that
- * customer and must not be returned here. They are the only rows written with a NULL
- * returning_customer, and they always carry the ID of the refunded order in parent_id;
- * both are required so that an order given a parent through set_parent_id() keeps
- * counting as one of the customer's orders.
+ * customer and must not be returned here. Parented rows use the authoritative order type
+ * so that legacy refund stats remain excluded while an order given a parent through
+ * set_parent_id() keeps counting as one of the customer's orders.
*
* @param int $customer_id Customer ID.
* @return array Orders.
@@ -885,6 +915,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
public static function get_oldest_orders( $customer_id ) {
global $wpdb;
$orders_table = $wpdb->prefix . 'wc_order_stats';
+ $countable_order = static::get_countable_customer_order_predicate( $orders_table );
$excluded_statuses = array_map( array( __CLASS__, 'normalize_order_status' ), self::get_excluded_report_order_statuses() );
$excluded_statuses_condition = '';
if ( ! empty( $excluded_statuses ) ) {
@@ -895,7 +926,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
return $wpdb->get_results(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
- "SELECT order_id, date_created FROM {$orders_table} WHERE customer_id = %d AND ( parent_id = 0 OR returning_customer IS NOT NULL ) {$excluded_statuses_condition} ORDER BY date_created, order_id ASC LIMIT 2",
+ "SELECT order_id, date_created FROM {$orders_table} WHERE customer_id = %d AND {$countable_order} {$excluded_statuses_condition} ORDER BY date_created, order_id ASC LIMIT 2",
$customer_id
)
);
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/Stats/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/Stats/DataStore.php
index 5e6798e8e2d..a56bb3cce70 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Customers/Stats/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/Stats/DataStore.php
@@ -89,6 +89,9 @@ class DataStore extends CustomersDataStore implements DataStoreInterface {
public function get_noncached_data( $query_args ) {
global $wpdb;
$this->initialize_queries();
+ $order_stats_table = $wpdb->prefix . 'wc_order_stats';
+ $countable_order = static::get_countable_customer_order_predicate( $order_stats_table );
+ $orders_count = "SUM( CASE WHEN {$countable_order} THEN 1 ELSE 0 END )";
$data = (object) array(
'customers_count' => 0,
@@ -104,11 +107,11 @@ class DataStore extends CustomersDataStore implements DataStoreInterface {
$this->subquery->add_sql_clause( 'select', 'SUM( total_sales ) AS total_spend,' );
$this->subquery->add_sql_clause(
'select',
- 'SUM( CASE WHEN parent_id = 0 THEN 1 END ) as orders_count,'
+ "SUM( CASE WHEN {$countable_order} THEN 1 END ) as orders_count,"
);
$this->subquery->add_sql_clause(
'select',
- 'CASE WHEN SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END ) = 0 THEN NULL ELSE SUM( total_sales ) / SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END ) END AS avg_order_value'
+ "CASE WHEN {$orders_count} = 0 THEN NULL ELSE SUM( total_sales ) / {$orders_count} END AS avg_order_value"
);
$this->clear_sql_clause( array( 'order_by', 'limit' ) );
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/reports/class-wc-tests-reports-orders.php b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/reports/class-wc-tests-reports-orders.php
index 7a519461754..8c08b474abd 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/reports/class-wc-tests-reports-orders.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/reports/class-wc-tests-reports-orders.php
@@ -765,6 +765,8 @@ class WC_Admin_Tests_Reports_Orders extends WC_Unit_Test_Case {
* @testdox Should keep reporting a customer's only order as new after its date is moved past its refund.
*/
public function test_refund_is_not_treated_as_the_customers_first_order() {
+ global $wpdb;
+
WC_Helper_Reports::reset_stats_dbs();
$simple_product = new WC_Product_Simple();
@@ -774,14 +776,23 @@ class WC_Admin_Tests_Reports_Orders extends WC_Unit_Test_Case {
$order = $this->create_guest_order( $simple_product, 'guest-refund-first-order@example.org' );
- wc_create_refund(
+ $refund = wc_create_refund(
array(
'amount' => 25,
'order_id' => $order->get_id(),
)
);
+ $this->assertInstanceOf( WC_Order_Refund::class, $refund );
WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+ $updated = $wpdb->update(
+ $wpdb->prefix . 'wc_order_stats',
+ array( 'returning_customer' => 1 ),
+ array( 'order_id' => $refund->get_id() ),
+ array( '%d' ),
+ array( '%d' )
+ );
+ $this->assertSame( 1, $updated, 'The fixture should simulate a legacy non-null refund marker.' );
// Moving the order past its refund makes the refund the oldest row of the customer,
// which triggers the first order recalculation.
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-reports-customers-controller-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-reports-customers-controller-test.php
index abd49855030..6eb559d0448 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-reports-customers-controller-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-reports-customers-controller-test.php
@@ -3,6 +3,7 @@ declare( strict_types=1 );
use Automattic\WooCommerce\Admin\API\Reports\Customers\Controller as CustomersController;
use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore;
+use Automattic\WooCommerce\Admin\API\Reports\Customers\Stats\DataStore as CustomersStatsDataStore;
use Automattic\WooCommerce\Enums\OrderStatus;
/**
@@ -122,6 +123,17 @@ class WC_Admin_Reports_Customers_Controller_Test extends WC_Unit_Test_Case {
$order->set_billing_state( $customer->get_billing_state() );
$order->set_billing_country( $customer->get_billing_country() );
$order->save();
+
+ if ( 0 === $index ) {
+ $refund = wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => 20,
+ 'line_items' => array(),
+ )
+ );
+ self::assertInstanceOf( WC_Order_Refund::class, $refund );
+ }
}
// Create guest orders (no user_id) with different locations.
@@ -222,6 +234,43 @@ class WC_Admin_Reports_Customers_Controller_Test extends WC_Unit_Test_Case {
parent::tearDown();
}
+ /**
+ * Create a parented order while preserving a stale non-null refund marker.
+ *
+ * @return int Analytics customer ID.
+ */
+ private function create_parented_order_with_stale_refund_row(): int {
+ global $wpdb;
+
+ $customer = self::$registered_customers[0];
+ $parent_order = wc_get_customer_last_order( $customer->get_id() );
+ $this->assertInstanceOf( WC_Order::class, $parent_order );
+
+ $refunds = $parent_order->get_refunds();
+ $this->assertCount( 1, $refunds );
+ $refund = reset( $refunds );
+ $this->assertInstanceOf( WC_Order_Refund::class, $refund );
+
+ // Older first-order recalculations could overwrite the refund's NULL marker.
+ $updated = $wpdb->update(
+ $wpdb->prefix . 'wc_order_stats',
+ array( 'returning_customer' => 1 ),
+ array( 'order_id' => $refund->get_id() ),
+ array( '%d' ),
+ array( '%d' )
+ );
+ $this->assertSame( 1, $updated );
+
+ $child_order = WC_Helper_Order::create_order( $customer->get_id(), self::$product );
+ $child_order->set_parent_id( $parent_order->get_id() );
+ $child_order->set_status( OrderStatus::COMPLETED );
+ $child_order->set_total( 20 );
+ $child_order->save();
+ WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+ return (int) CustomersDataStore::get_customer_id_by_user_id( $customer->get_id() );
+ }
+
/**
* Test route registration.
*/
@@ -242,6 +291,108 @@ class WC_Admin_Reports_Customers_Controller_Test extends WC_Unit_Test_Case {
$this->assertEquals( 401, $response->get_status() );
}
+ /**
+ * @testdox Partially refunded orders count once in numeric filters.
+ */
+ public function test_partially_refunded_orders_count_once_in_numeric_filters(): void {
+ $customer_id = CustomersDataStore::get_customer_id_by_user_id( self::$registered_customers[0]->get_id() );
+ $request = new WP_REST_Request( 'GET', $this->endpoint );
+ $request->set_query_params(
+ array(
+ 'customers' => array( $customer_id ),
+ 'orders_count_min' => 1,
+ 'orders_count_max' => 1,
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertCount( 1, $reports, 'A refund row should not count as another order.' );
+ $this->assertSame( 1, $reports[0]['orders_count'] );
+ }
+
+ /**
+ * @testdox Partially refunded orders use the parent order count in average order value filters.
+ */
+ public function test_partially_refunded_orders_use_parent_count_in_average_order_value_filters(): void {
+ $customer_id = CustomersDataStore::get_customer_id_by_user_id( self::$registered_customers[0]->get_id() );
+ $request = new WP_REST_Request( 'GET', $this->endpoint );
+ $request->set_query_params(
+ array(
+ 'customers' => array( $customer_id ),
+ 'avg_order_value_min' => 79,
+ 'avg_order_value_max' => 81,
+ 'total_spend_min' => 79,
+ 'total_spend_max' => 81,
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertCount( 1, $reports, 'A refund row should not lower the average order value denominator.' );
+ $this->assertEqualsWithDelta( 80.0, $reports[0]['total_spend'], 0.001 );
+ $this->assertEqualsWithDelta( 80.0, $reports[0]['avg_order_value'], 0.001 );
+ }
+
+ /**
+ * @testdox Parented non-refund orders count while stale refund rows remain excluded from customer aggregates.
+ */
+ public function test_parented_orders_and_stale_refunds_use_authoritative_types(): void {
+ $customer_id = $this->create_parented_order_with_stale_refund_row();
+ $request = new WP_REST_Request( 'GET', $this->endpoint );
+ $request->set_query_params(
+ array(
+ 'customers' => array( $customer_id ),
+ 'orders_count_min' => 2,
+ 'orders_count_max' => 2,
+ 'total_spend_min' => 100,
+ 'total_spend_max' => 100,
+ 'avg_order_value_min' => 50,
+ 'avg_order_value_max' => 50,
+ 'force_cache_refresh' => true,
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertCount( 1, $reports );
+ $this->assertSame( 2, $reports[0]['orders_count'] );
+ $this->assertEqualsWithDelta( 100.0, $reports[0]['total_spend'], 0.001 );
+ $this->assertEqualsWithDelta( 50.0, $reports[0]['avg_order_value'], 0.001 );
+ }
+
+ /**
+ * @testdox Customer stats count parented non-refund orders while excluding stale refund rows.
+ */
+ public function test_customer_stats_use_authoritative_order_types(): void {
+ $customer_id = $this->create_parented_order_with_stale_refund_row();
+ $data_store = new CustomersStatsDataStore();
+ $data = $data_store->get_data(
+ array(
+ 'customers' => array( $customer_id ),
+ 'orders_count_min' => 2,
+ 'orders_count_max' => 2,
+ 'total_spend_min' => 100,
+ 'total_spend_max' => 100,
+ 'avg_order_value_min' => 50,
+ 'avg_order_value_max' => 50,
+ 'force_cache_refresh' => true,
+ )
+ );
+
+ $this->assertInstanceOf( stdClass::class, $data );
+ $this->assertSame( 1, $data->customers_count );
+ $this->assertEqualsWithDelta( 2.0, $data->avg_orders_count, 0.001 );
+ $this->assertEqualsWithDelta( 100.0, $data->avg_total_spend, 0.001 );
+ $this->assertEqualsWithDelta( 50.0, $data->avg_avg_order_value, 0.001 );
+ }
+
/**
* Test user_type parameter with 'all' value (default).
*/
diff --git a/plugins/woocommerce/tests/php/includes/cli/class-wc-cli-update-command-test.php b/plugins/woocommerce/tests/php/includes/cli/class-wc-cli-update-command-test.php
index 6337eed2d4f..cfe20b61af2 100644
--- a/plugins/woocommerce/tests/php/includes/cli/class-wc-cli-update-command-test.php
+++ b/plugins/woocommerce/tests/php/includes/cli/class-wc-cli-update-command-test.php
@@ -67,6 +67,44 @@ class WC_CLI_Update_Command_Test extends WC_Unit_Test_Case {
);
}
+ /**
+ * Number of times the batched test callback has run.
+ *
+ * @var int
+ */
+ public static $batched_callback_runs = 0;
+
+ /**
+ * Update callback that asks to be run again until its third run.
+ *
+ * @return bool
+ */
+ public static function batched_callback() {
+ ++self::$batched_callback_runs;
+
+ return self::$batched_callback_runs < 3;
+ }
+
+ /**
+ * @testdox Batched update callbacks are run again while they return true.
+ */
+ public function test_batched_callbacks_run_until_completed() {
+ $this->mock_wp_cli();
+
+ self::$batched_callback_runs = 0;
+ $this->db_updates_property->setValue(
+ array(
+ '5.0.0' => array( __CLASS__ . '::batched_callback' ),
+ )
+ );
+
+ update_option( 'woocommerce_db_version', '4.0.0' );
+ $sut = new WC_CLI_Update_Command();
+ $sut->update();
+
+ $this->assertSame( 3, self::$batched_callback_runs, 'A callback returning true should be run again until it returns false.' );
+ }
+
/**
* @testdox After `wp wc update` has run, the `woocommerce_db_option` should be left at the expected value (even if no update callbacks were executed)
*/
diff --git a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
index 227ae24ce0c..62766af6084 100644
--- a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
@@ -12,6 +12,7 @@ use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Blocks\InboxNotifications;
use Automattic\WooCommerce\Blocks\Options as BlockOptions;
use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils;
+use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Internal\Admin\OrderTaxLookupMigrator;
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
@@ -553,4 +554,69 @@ class WC_Update_Functions_Test extends \WC_Unit_Test_Case {
$this->assertFalse( ReportsCache::get( $key ), 'A response cached before the update should no longer be served' );
}
+
+ /**
+ * @testdox Migration resets stale refund markers in batches and invalidates cached Analytics reports.
+ */
+ public function test_wc_update_11202_reset_refund_returning_customer_markers(): void {
+ global $wpdb;
+
+ include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+ $db_updates = WC_Install::get_db_update_callbacks();
+ // Under its own key, so that a store already past the 11.2.0 batches still resets its markers.
+ $this->assertArrayHasKey( '11.2.0-2', $db_updates );
+ $this->assertContains( 'wc_update_11202_reset_refund_returning_customer_markers', $db_updates['11.2.0-2'] );
+
+ $order = WC_Helper_Order::create_order();
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $refund = wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => 5,
+ 'line_items' => array(),
+ )
+ );
+ $this->assertInstanceOf( WC_Order_Refund::class, $refund );
+ WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+ $order_stats_table = $wpdb->prefix . 'wc_order_stats';
+ $get_marker = static function ( int $order_id ) use ( $wpdb, $order_stats_table ) {
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be prepared.
+ return $wpdb->get_var( $wpdb->prepare( "SELECT returning_customer FROM {$order_stats_table} WHERE order_id = %d", $order_id ) );
+ };
+
+ // Older first-order recalculations could overwrite the refund's NULL marker.
+ $this->assertSame( 1, $wpdb->update( $order_stats_table, array( 'returning_customer' => 1 ), array( 'order_id' => $refund->get_id() ), array( '%d' ), array( '%d' ) ) );
+ $this->assertSame( '0', $get_marker( $order->get_id() ), 'The order row should start with a non-stale marker.' );
+
+ $cache_key = 'wc_update_11202_analytics_report';
+ $version_key = ReportsCache::VERSION_OPTION . '-transient-version';
+ $original_version = get_transient( $version_key );
+ set_transient( $version_key, 'stale-version' );
+
+ try {
+ ReportsCache::set( $cache_key, 'stale-value' );
+ $this->assertSame( 'stale-value', ReportsCache::get( $cache_key ) );
+
+ $this->assertTrue( wc_update_11202_reset_refund_returning_customer_markers(), 'A batch with stale refund rows should request another run.' );
+ $this->assertSame( $refund->get_id(), (int) get_option( 'woocommerce_update_11202_last_refund_order_id' ), 'The last processed order ID should be stored between batches.' );
+ $this->assertSame( 'stale-value', ReportsCache::get( $cache_key ), 'The cache should stay valid until the last batch completes.' );
+
+ $this->assertFalse( wc_update_11202_reset_refund_returning_customer_markers(), 'A run with no stale refund rows should complete.' );
+ $this->assertFalse( get_option( 'woocommerce_update_11202_last_refund_order_id' ), 'The last processed order ID should be cleared on completion.' );
+ $this->assertFalse( ReportsCache::get( $cache_key ), 'The cache should be invalidated once the migration completes.' );
+ } finally {
+ delete_transient( $cache_key );
+ if ( false === $original_version ) {
+ delete_transient( $version_key );
+ } else {
+ set_transient( $version_key, $original_version );
+ }
+ }
+
+ $this->assertNull( $get_marker( $refund->get_id() ), 'The refund row marker should be reset to NULL.' );
+ $this->assertSame( '0', $get_marker( $order->get_id() ), 'The order row marker should be left unchanged.' );
+ }
}