Commit bb62a893bc6 for woocommerce
commit bb62a893bc645f9f0a406bc08057126ba311d336
Author: Ján Mikláš <neosinner@gmail.com>
Date: Thu Aug 13 13:41:36 2026 +0200
Fix Analytics customer type depending on the order in which orders are imported (#67664)
* Break ties on order ID when deciding a customer's first order
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YG3DJ1siMNnroqMDodMF1H
* Exclude refunds from a customer's oldest orders
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YG3DJ1siMNnroqMDodMF1H
* Add regression tests pinning the customer type against import order
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YG3DJ1siMNnroqMDodMF1H
* Add changelog entry
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YG3DJ1siMNnroqMDodMF1H
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-analytics-customer-type-import-order b/plugins/woocommerce/changelog/fix-analytics-customer-type-import-order
new file mode 100644
index 00000000000..a84f5435a2f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-analytics-customer-type-import-order
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Report a customer's first order as new in Analytics regardless of the order in which its orders and refunds are imported
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
index 0f21c5ec573..4683dcc090e 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
@@ -867,6 +867,12 @@ 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.
+ *
* @param int $customer_id Customer ID.
* @return array Orders.
*/
@@ -883,7 +889,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 {$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 ( parent_id = 0 OR returning_customer IS NOT NULL ) {$excluded_statuses_condition} ORDER BY date_created, order_id ASC LIMIT 2",
$customer_id
)
);
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
index 6226f26bad7..a39e8fda798 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
@@ -871,10 +871,19 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
$second_order = isset( $oldest_orders[1] ) ? $oldest_orders[1] : false;
$excluded_statuses = self::get_excluded_report_order_statuses();
- // Order is older than previous first order.
- if ( $order->get_date_created() < wc_string_to_datetime( $first_order->date_created ) &&
- ! in_array( $order->get_status(), $excluded_statuses, true )
- ) {
+ // Order is older than previous first order. Stats dates only have second resolution, so
+ // orders placed within the same second are ranked by ID, the tie breaker
+ // get_oldest_orders() already sorts by. Without it, whichever of the two is imported
+ // last is reported as returning, making the customer type depend on the import order.
+ $order_date = $order->get_date_created();
+ $first_order_date = wc_string_to_datetime( $first_order->date_created );
+ $is_older = $order_date < $first_order_date || (
+ $order_date &&
+ $order_date->getTimestamp() === $first_order_date->getTimestamp() &&
+ (int) $order->get_id() < (int) $first_order->order_id
+ );
+
+ if ( $is_older && ! in_array( $order->get_status(), $excluded_statuses, true ) ) {
self::set_customer_first_order( $customer_id, $order->get_id() );
return false;
}
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 d6db1a193c1..471616841f8 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
@@ -7,6 +7,7 @@
use Automattic\WooCommerce\Admin\API\Reports\Orders\DataStore as OrdersDataStore;
use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler;
/**
* Class WC_Admin_Tests_Reports_Orders
@@ -333,15 +334,24 @@ class WC_Admin_Tests_Reports_Orders extends WC_Unit_Test_Case {
* Creates a completed order for a guest customer, using a billing email that is not attached to
* any registered user.
*
- * @param WC_Product $product Product to add to the order.
- * @param string $email Billing email used to identify the guest customer.
+ * @param WC_Product $product Product to add to the order.
+ * @param string $email Billing email used to identify the guest customer.
+ * @param int|null $date_created Optional timestamp to place the order at.
* @return WC_Order
*/
- private function create_guest_order( $product, $email ) {
+ private function create_guest_order( $product, $email, $date_created = null ) {
$order = WC_Helper_Order::create_order( 0, $product );
$order->set_billing_email( $email );
$order->set_total( 25 );
$order->set_status( OrderStatus::COMPLETED );
+
+ if ( $date_created ) {
+ // The report is filtered by date_paid, so both dates are pinned to keep the
+ // reporting time frame of the order independent of when the test runs.
+ $order->set_date_created( $date_created );
+ $order->set_date_paid( $date_created );
+ }
+
$order->save();
return $order;
@@ -545,4 +555,106 @@ class WC_Admin_Tests_Reports_Orders extends WC_Unit_Test_Case {
$this->assertEmpty( $returning_customer_rows, 'Filtering by returning customers should not match the refund after the recalculation' );
}
+
+ /**
+ * Orders can be given a parent through set_parent_id(), so a parent alone does not make a
+ * stats row a refund: such orders still count as orders of the customer.
+ *
+ * @testdox Should keep counting a customer's orders that have a parent order.
+ */
+ public function test_orders_with_a_parent_are_still_the_customers_orders() {
+ WC_Helper_Reports::reset_stats_dbs();
+
+ $simple_product = new WC_Product_Simple();
+ $simple_product->set_name( 'Simple Product' );
+ $simple_product->set_regular_price( 25 );
+ $simple_product->save();
+
+ $parent_order = $this->create_guest_order( $simple_product, 'guest-parent-order@example.org' );
+
+ $first_order = $this->create_guest_order( $simple_product, 'guest-child-order@example.org' );
+ $first_order->set_parent_id( $parent_order->get_id() );
+ $first_order->save();
+
+ $second_order = $this->create_guest_order( $simple_product, 'guest-child-order@example.org' );
+ $second_order->set_parent_id( $parent_order->get_id() );
+ $second_order->save();
+
+ WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+ $customer_types = $this->get_customer_types_by_order_id( $first_order );
+
+ $this->assertEquals( 'new', $customer_types[ $first_order->get_id() ], "The customer's first order should be reported as new" );
+ $this->assertEquals( 'returning', $customer_types[ $second_order->get_id() ], 'The second order of the customer should be reported as returning' );
+ }
+
+ /**
+ * A refund carries the customer of the order it refunds, but it is not one of that
+ * customer's orders and must never be picked as their first one — the recalculation would
+ * then flag every actual order of the customer as returning.
+ *
+ * @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() {
+ WC_Helper_Reports::reset_stats_dbs();
+
+ $simple_product = new WC_Product_Simple();
+ $simple_product->set_name( 'Simple Product' );
+ $simple_product->set_regular_price( 25 );
+ $simple_product->save();
+
+ $order = $this->create_guest_order( $simple_product, 'guest-refund-first-order@example.org' );
+
+ wc_create_refund(
+ array(
+ 'amount' => 25,
+ 'order_id' => $order->get_id(),
+ )
+ );
+
+ WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+ // Moving the order past its refund makes the refund the oldest row of the customer,
+ // which triggers the first order recalculation.
+ $moved_to = $order->get_date_created()->getTimestamp() + HOUR_IN_SECONDS;
+ $order->set_date_created( $moved_to );
+ $order->set_date_paid( $moved_to );
+ $order->save();
+
+ WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+ $customer_types = $this->get_customer_types_by_order_id( $order );
+
+ $this->assertEquals( 'new', $customer_types[ $order->get_id() ], "The customer's only order should still be reported as new" );
+ }
+
+ /**
+ * Orders are imported asynchronously and stats dates only have second resolution, so two
+ * orders placed within the same second carry the same date_created. Which one is the
+ * customer's first order has to be decided by ID rather than by import order.
+ *
+ * @testdox Should report the same customer types when a customer's orders are imported newest first.
+ */
+ public function test_customer_type_does_not_depend_on_the_order_import_order() {
+ WC_Helper_Reports::reset_stats_dbs();
+
+ $simple_product = new WC_Product_Simple();
+ $simple_product->set_name( 'Simple Product' );
+ $simple_product->set_regular_price( 25 );
+ $simple_product->save();
+
+ $date_created = time();
+ $first_order = $this->create_guest_order( $simple_product, 'guest-import-order@example.org', $date_created );
+ $second_order = $this->create_guest_order( $simple_product, 'guest-import-order@example.org', $date_created );
+
+ // Import the newer order first, which is what an unordered queue can do.
+ WC_Helper_Queue::cancel_all_pending();
+ OrdersScheduler::import( $second_order->get_id() );
+ OrdersScheduler::import( $first_order->get_id() );
+
+ $customer_types = $this->get_customer_types_by_order_id( $first_order );
+
+ $this->assertEquals( 'new', $customer_types[ $first_order->get_id() ], 'The first order should be reported as new even when imported last' );
+ $this->assertEquals( 'returning', $customer_types[ $second_order->get_id() ], 'The second order should be reported as returning even when imported first' );
+ }
}