Commit 6d8e7abe8ac for woocommerce

commit 6d8e7abe8ac08990f31f48f8b35f52d316c892ca
Author: Ján Mikláš <neosinner@gmail.com>
Date:   Fri Sep 4 15:49:26 2026 +0200

    Show guest order names correctly in Orders Analytics (#68311)

    * Use guest order names in analytics reports

    * Add changelog

    * Prevent Analytics report test time-window flakes

    * Fall back to an order's shipping name in Orders Analytics

    Reading only the billing name meant an order stored without one lost the name
    it used to display. Automattic\WooCommerce\Admin\Overrides\Order, which builds
    the customer lookup row this replaces, falls back from billing to shipping per
    field, so orders taken in person or over the API were showing a shipping name
    before and a blank Customer cell after.

    Match that fallback in both storage branches, and rename the lookup to say it
    returns the customer name rather than the billing name specifically.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_0169i1KYNcjwBdCCUZ2cpX8h

    ---------

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/wooplug-1772-per-order-guest-names b/plugins/woocommerce/changelog/wooplug-1772-per-order-guest-names
new file mode 100644
index 00000000000..589e6e614a9
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-1772-per-order-guest-names
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show each guest order's billing name in Analytics when orders share an email address.
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php
index 7af8f0f9e73..e5e6730ea01 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php
@@ -386,6 +386,10 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		$order_attributions = $this->get_order_attributions_by_order_ids( array_keys( $mapped_orders ) );
 		$customers          = $this->get_customers_by_orders( $orders_data );
 		$mapped_customers   = $this->map_array_by_key( $customers, 'customer_id' );
+		$customer_details   = $this->map_array_by_key(
+			$this->get_order_customer_details_by_order_ids( $order_ids ),
+			'order_id'
+		);

 		$mapped_data = array();
 		foreach ( $products as $product ) {
@@ -448,6 +452,20 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 				$orders_data[ $key ]['extended_info']['customer'] = $mapped_customers[ $order_data['customer_id'] ];
 			}

+			$name_order_id = $order_id;
+			if (
+				isset( $customer_details[ $order_id ] )
+				&& 'shop_order_refund' === $customer_details[ $order_id ]['order_type']
+				&& $order_data['parent_id']
+			) {
+				$name_order_id = $order_data['parent_id'];
+			}
+
+			if ( isset( $customer_details[ $name_order_id ] ) && 0 === (int) $customer_details[ $name_order_id ]['customer_id'] ) {
+				$orders_data[ $key ]['extended_info']['customer']['first_name'] = (string) $customer_details[ $name_order_id ]['first_name'];
+				$orders_data[ $key ]['extended_info']['customer']['last_name']  = (string) $customer_details[ $name_order_id ]['last_name'];
+			}
+
 			$source_type = $order_attributions[ $order_id ]['_wc_order_attribution_source_type'] ?? '';
 			$utm_source  = $order_attributions[ $order_id ]['_wc_order_attribution_utm_source'] ?? '';
 			$orders_data[ $key ]['extended_info']['attribution']['origin'] = $this->get_origin_label( $source_type, $utm_source );
@@ -556,6 +574,76 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		return $customers;
 	}

+	/**
+	 * Get the customer ID and customer name stored on each order.
+	 *
+	 * The name falls back from billing to shipping per field, matching how
+	 * Automattic\WooCommerce\Admin\Overrides\Order builds the customer lookup row. Without that
+	 * fallback an order that only has a shipping name would lose the name it used to display.
+	 *
+	 * @param int[] $order_ids Order IDs.
+	 * @return array
+	 */
+	private function get_order_customer_details_by_order_ids( $order_ids ) {
+		global $wpdb;
+
+		$order_ids = array_values( array_unique( array_filter( array_map( 'absint', $order_ids ) ) ) );
+		if ( empty( $order_ids ) ) {
+			return array();
+		}
+
+		$included_order_ids = implode( ',', $order_ids );
+
+		if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
+			$orders_table    = OrdersTableDataStore::get_orders_table_name();
+			$addresses_table = OrdersTableDataStore::get_addresses_table_name();
+
+			/* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
+			return $wpdb->get_results(
+				"SELECT orders.id AS order_id,
+					orders.type AS order_type,
+					orders.customer_id,
+					COALESCE( NULLIF( billing.first_name, '' ), shipping.first_name, '' ) AS first_name,
+					COALESCE( NULLIF( billing.last_name, '' ), shipping.last_name, '' ) AS last_name
+				FROM {$orders_table} orders
+				LEFT JOIN {$addresses_table} billing
+					ON orders.id = billing.order_id
+					AND billing.address_type = 'billing'
+				LEFT JOIN {$addresses_table} shipping
+					ON orders.id = shipping.order_id
+					AND shipping.address_type = 'shipping'
+				WHERE orders.id IN ({$included_order_ids})",
+				ARRAY_A
+			);
+			/* phpcs:enable */
+		}
+
+		/* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
+		return $wpdb->get_results(
+			"SELECT orders.ID AS order_id,
+				orders.post_type AS order_type,
+				MAX( CASE WHEN order_meta.meta_key = '_customer_user' THEN order_meta.meta_value END ) AS customer_id,
+				COALESCE(
+					NULLIF( MAX( CASE WHEN order_meta.meta_key = '_billing_first_name' THEN order_meta.meta_value END ), '' ),
+					MAX( CASE WHEN order_meta.meta_key = '_shipping_first_name' THEN order_meta.meta_value END ),
+					''
+				) AS first_name,
+				COALESCE(
+					NULLIF( MAX( CASE WHEN order_meta.meta_key = '_billing_last_name' THEN order_meta.meta_value END ), '' ),
+					MAX( CASE WHEN order_meta.meta_key = '_shipping_last_name' THEN order_meta.meta_value END ),
+					''
+				) AS last_name
+			FROM {$wpdb->posts} orders
+			LEFT JOIN {$wpdb->postmeta} order_meta
+				ON orders.ID = order_meta.post_id
+				AND order_meta.meta_key IN ( '_customer_user', '_billing_first_name', '_billing_last_name', '_shipping_first_name', '_shipping_last_name' )
+			WHERE orders.ID IN ({$included_order_ids})
+			GROUP BY orders.ID, orders.post_type",
+			ARRAY_A
+		);
+		/* phpcs:enable */
+	}
+
 	/**
 	 * Get coupon information from order IDs.
 	 *
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 471616841f8..7a519461754 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
@@ -120,6 +120,155 @@ class WC_Admin_Tests_Reports_Orders extends WC_Unit_Test_Case {
 		$this->assertEquals( $expected, $data );
 	}

+	/**
+	 * @testdox Should use each guest order's billing name when the orders share an email address.
+	 */
+	public function test_extended_info_uses_each_guest_orders_billing_name_when_email_is_shared(): void {
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$registered_customer = new WC_Customer();
+		$registered_customer->set_username( 'marketplace-account' );
+		$registered_customer->set_password( 'password' );
+		$registered_customer->set_email( 'marketplace@example.org' );
+		$registered_customer->set_first_name( 'Registered' );
+		$registered_customer->set_last_name( 'Customer' );
+		$registered_customer->save();
+
+		$product = WC_Helper_Product::create_simple_product();
+
+		$first_order = $this->create_guest_order( $product, 'marketplace@example.org' );
+		$first_order->set_billing_first_name( 'Ada' );
+		$first_order->set_billing_last_name( 'Lovelace' );
+		$first_order->save();
+
+		$second_order = $this->create_guest_order( $product, 'marketplace@example.org' );
+		$second_order->set_billing_first_name( 'Grace' );
+		$second_order->set_billing_last_name( 'Hopper' );
+		$second_order->save();
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$rows = $this->get_extended_report_rows_by_order_id(
+			$first_order,
+			array( $first_order->get_id(), $second_order->get_id() )
+		);
+
+		$this->assertNotEmpty( $rows[ $first_order->get_id() ]['customer_id'], 'Guest orders should have an analytics customer ID' );
+		$this->assertEquals(
+			$rows[ $first_order->get_id() ]['customer_id'],
+			$rows[ $second_order->get_id() ]['customer_id'],
+			'Orders sharing an email should retain the same analytics customer identity'
+		);
+		$this->assertSame( 'Ada', $rows[ $first_order->get_id() ]['extended_info']['customer']['first_name'], 'The first order should use its own billing first name' );
+		$this->assertSame( 'Lovelace', $rows[ $first_order->get_id() ]['extended_info']['customer']['last_name'], 'The first order should use its own billing last name' );
+		$this->assertSame( 'Grace', $rows[ $second_order->get_id() ]['extended_info']['customer']['first_name'], 'The second order should use its own billing first name' );
+		$this->assertSame( 'Hopper', $rows[ $second_order->get_id() ]['extended_info']['customer']['last_name'], 'The second order should use its own billing last name' );
+	}
+
+	/**
+	 * @testdox Should fall back to a guest order's shipping name when it has no billing name.
+	 */
+	public function test_extended_info_falls_back_to_shipping_name_for_a_guest_order(): void {
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$product = WC_Helper_Product::create_simple_product();
+
+		$named_order = $this->create_guest_order( $product, 'warehouse@example.org' );
+		$named_order->set_billing_first_name( 'Katherine' );
+		$named_order->set_billing_last_name( 'Johnson' );
+		$named_order->save();
+
+		// Orders taken in person or over the API can reach Analytics with a shipping name only.
+		$shipping_only_order = $this->create_guest_order( $product, 'warehouse@example.org' );
+		$shipping_only_order->set_billing_first_name( '' );
+		$shipping_only_order->set_billing_last_name( '' );
+		$shipping_only_order->set_shipping_first_name( 'Mary' );
+		$shipping_only_order->set_shipping_last_name( 'Jackson' );
+		$shipping_only_order->save();
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$rows = $this->get_extended_report_rows_by_order_id(
+			$named_order,
+			array( $named_order->get_id(), $shipping_only_order->get_id() )
+		);
+
+		$this->assertSame( 'Mary', $rows[ $shipping_only_order->get_id() ]['extended_info']['customer']['first_name'], 'An order without a billing first name should use its own shipping first name' );
+		$this->assertSame( 'Jackson', $rows[ $shipping_only_order->get_id() ]['extended_info']['customer']['last_name'], 'An order without a billing last name should use its own shipping last name' );
+		$this->assertSame( 'Katherine', $rows[ $named_order->get_id() ]['extended_info']['customer']['first_name'], 'The billing name should still win when the order has one' );
+		$this->assertSame( 'Johnson', $rows[ $named_order->get_id() ]['extended_info']['customer']['last_name'], 'The billing name should still win when the order has one' );
+	}
+
+	/**
+	 * @testdox Should keep the profile name for a registered customer's order.
+	 */
+	public function test_extended_info_keeps_registered_customer_profile_name(): void {
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$customer_id = $this->factory->user->create(
+			array(
+				'role'       => 'customer',
+				'user_email' => 'registered-customer@example.org',
+				'first_name' => 'Profile',
+				'last_name'  => 'Customer',
+			)
+		);
+		$product     = WC_Helper_Product::create_simple_product();
+		$order       = WC_Helper_Order::create_order( $customer_id, $product );
+		$order->set_billing_first_name( 'Order' );
+		$order->set_billing_last_name( 'Name' );
+		$order->set_total( 25 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$rows = $this->get_extended_report_rows_by_order_id( $order, array( $order->get_id() ) );
+
+		$this->assertSame( 'Profile', $rows[ $order->get_id() ]['extended_info']['customer']['first_name'] );
+		$this->assertSame( 'Customer', $rows[ $order->get_id() ]['extended_info']['customer']['last_name'] );
+	}
+
+	/**
+	 * @testdox Should use the parent billing name for refunds and the order billing name for ordinary child orders.
+	 */
+	public function test_extended_info_resolves_billing_name_source_by_order_type(): void {
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$product = WC_Helper_Product::create_simple_product();
+
+		$parent_order = $this->create_guest_order( $product, 'parent@example.org' );
+		$parent_order->set_billing_first_name( 'Parent' );
+		$parent_order->set_billing_last_name( 'Customer' );
+		$parent_order->save();
+
+		$refund = wc_create_refund(
+			array(
+				'amount'   => 25,
+				'order_id' => $parent_order->get_id(),
+			)
+		);
+		$this->assertInstanceOf( WC_Order_Refund::class, $refund, 'The refund fixture should be created' );
+
+		$child_order = $this->create_guest_order( $product, 'child@example.org' );
+		$child_order->set_parent_id( $parent_order->get_id() );
+		$child_order->set_billing_first_name( 'Child' );
+		$child_order->set_billing_last_name( 'Customer' );
+		$child_order->save();
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$rows = $this->get_extended_report_rows_by_order_id(
+			$parent_order,
+			array( $refund->get_id(), $child_order->get_id() )
+		);
+
+		$this->assertSame( 'Parent', $rows[ $refund->get_id() ]['extended_info']['customer']['first_name'], 'A refund should use its parent order billing name' );
+		$this->assertSame( 'Customer', $rows[ $refund->get_id() ]['extended_info']['customer']['last_name'], 'A refund should use its parent order billing name' );
+		$this->assertSame( 'Child', $rows[ $child_order->get_id() ]['extended_info']['customer']['first_name'], 'An ordinary child order should use its own billing name' );
+		$this->assertSame( 'Customer', $rows[ $child_order->get_id() ]['extended_info']['customer']['last_name'], 'An ordinary child order should use its own billing name' );
+	}
+
 	/**
 	 * Test that refunded orders in list have products.
 	 */
@@ -331,8 +480,7 @@ 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.
+	 * Creates a completed order for a guest customer.
 	 *
 	 * @param WC_Product $product      Product to add to the order.
 	 * @param string     $email        Billing email used to identify the guest customer.
@@ -357,6 +505,27 @@ class WC_Admin_Tests_Reports_Orders extends WC_Unit_Test_Case {
 		return $order;
 	}

+	/**
+	 * Returns extended report rows keyed by order ID.
+	 *
+	 * @param WC_Order $order     Order used to derive the reporting time frame.
+	 * @param int[]    $order_ids Order IDs to include.
+	 * @return array
+	 */
+	private function get_extended_report_rows_by_order_id( $order, $order_ids ) {
+		$data_store = new OrdersDataStore();
+		$data       = $data_store->get_data(
+			array(
+				'after'          => gmdate( 'Y-m-d H:i:s', $order->get_date_created()->getOffsetTimestamp() - DAY_IN_SECONDS ),
+				'before'         => gmdate( 'Y-m-d H:i:s', $order->get_date_created()->getOffsetTimestamp() + DAY_IN_SECONDS ),
+				'extended_info'  => 1,
+				'order_includes' => $order_ids,
+			)
+		);
+
+		return array_column( $data->data, null, 'order_id' );
+	}
+
 	/**
 	 * Returns the customer type of every row in the report, keyed by order ID.
 	 *