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