Commit a243397092 for woocommerce

commit a2433970927120d66b359b71b5f5b71c59e06d6c
Author: Mike Jolley <mike.jolley@me.com>
Date:   Fri Dec 12 16:10:48 2025 +0000

    Rest API - Update analytics customer reports routes to support additional filtering and sorting options (#62267)

    * Add user_type param to customer report API, w/ test coverage

    * Include first and last name in response

    * Allow sorting by first name, last name, email

    * Trim whitespace from names

    * Sort by location (state and country)

    * location filtering and tests

    * Rename customer to registered

    * Add phone field to customer lookup table

    * Remove state_includes

    * reduce diff

    * changelog

    * update comments in tests

    * update schema test due to new fields

    * Anonymize phone field

    * Remove phone field

    * update changelog

    * remove phone from schema

    * remove phone from tests

    * Apply CI feedback

    * Fix get_customer_order_data_and_format

    * Add return type to add_order_by_sql_params

    * Protect against empty where clause

    * Fix concat usage

    * set default orderby in add_order_by_sql_params

    * Fix aggregated field query missing table name

diff --git a/plugins/woocommerce/changelog/wooprd-1297-improvements-needed-for-customer-reports-route-and-lookup b/plugins/woocommerce/changelog/wooprd-1297-improvements-needed-for-customer-reports-route-and-lookup
new file mode 100644
index 0000000000..5364c0de8e
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooprd-1297-improvements-needed-for-customer-reports-route-and-lookup
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Updated wc-analytics/customers API route to include location filtering (country and state), and user type filtering (registered or guest)
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php
index 56861a82c9..0d61292905 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php
@@ -87,6 +87,9 @@ class Controller extends GenericController implements ExportableInterface {
 		$args['users']               = $request['users'];
 		$args['force_cache_refresh'] = $request['force_cache_refresh'];
 		$args['filter_empty']        = $request['filter_empty'];
+		$args['user_type']           = $request['user_type'];
+		$args['location_includes']   = $request['location_includes'];
+		$args['location_excludes']   = $request['location_excludes'];

 		$between_params_numeric    = array( 'orders_count', 'total_spend', 'avg_order_value' );
 		$normalized_params_numeric = TimeInterval::normalize_between_params( $request, $between_params_numeric, false );
@@ -133,6 +136,8 @@ class Controller extends GenericController implements ExportableInterface {
 	public function prepare_item_for_response( $report, $request ) {
 		$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
 		$data    = $this->add_additional_fields_to_object( $report, $request );
+		// Trim name field to prevent whitespace issues.
+		$data['name'] = trim( $data['name'] );
 		// Registered date is UTC.
 		$data['date_registered_gmt'] = wc_rest_prepare_date_response( $data['date_registered'] );
 		$data['date_registered']     = wc_rest_prepare_date_response( $data['date_registered'], false );
@@ -207,6 +212,24 @@ class Controller extends GenericController implements ExportableInterface {
 					'context'     => array( 'view', 'edit' ),
 					'readonly'    => true,
 				),
+				'first_name'           => array(
+					'description' => __( 'First name.', 'woocommerce' ),
+					'type'        => 'string',
+					'context'     => array( 'view', 'edit' ),
+					'readonly'    => true,
+				),
+				'last_name'            => array(
+					'description' => __( 'Last name.', 'woocommerce' ),
+					'type'        => 'string',
+					'context'     => array( 'view', 'edit' ),
+					'readonly'    => true,
+				),
+				'email'                => array(
+					'description' => __( 'Email address.', 'woocommerce' ),
+					'type'        => 'string',
+					'context'     => array( 'view', 'edit' ),
+					'readonly'    => true,
+				),
 				'username'             => array(
 					'description' => __( 'Username.', 'woocommerce' ),
 					'type'        => 'string',
@@ -308,6 +331,10 @@ class Controller extends GenericController implements ExportableInterface {
 			array(
 				'username',
 				'name',
+				'first_name',
+				'last_name',
+				'email',
+				'location',
 				'country',
 				'city',
 				'state',
@@ -527,7 +554,27 @@ class Controller extends GenericController implements ExportableInterface {
 				),
 			),
 		);
-
+		$params['user_type']               = array(
+			'description'       => __( 'Limit result to items with specified user type.', 'woocommerce' ),
+			'type'              => 'string',
+			'default'           => 'all',
+			'validate_callback' => 'rest_validate_request_arg',
+			'enum'              => array(
+				'all',
+				'registered',
+				'guest',
+			),
+		);
+		$params['location_includes']       = array(
+			'description'       => __( 'Includes customers by location (state, country). Provide a comma-separated list of locations. Each location can be a country code (e.g. GB) or combination of country and state (e.g. US:CA).', 'woocommerce' ),
+			'type'              => 'string',
+			'validate_callback' => 'rest_validate_request_arg',
+		);
+		$params['location_excludes']       = array(
+			'description'       => __( 'Excludes customers by location (state, country). Provide a comma-separated list of locations. Each location can be a country code (e.g. GB) or combination of country and state (e.g. US:CA).', 'woocommerce' ),
+			'type'              => 'string',
+			'validate_callback' => 'rest_validate_request_arg',
+		);
 		return $params;
 	}

diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
index 7c711d56aa..a60857162f 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
@@ -76,6 +76,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 			'user_id'          => 'user_id',
 			'username'         => 'username',
 			'name'             => "CONCAT_WS( ' ', first_name, last_name ) as name", // @xxx: What does this mean for RTL?
+			'first_name'       => 'first_name',
+			'last_name'        => 'last_name',
 			'email'            => 'email',
 			'country'          => 'country',
 			'city'             => 'city',
@@ -176,20 +178,45 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		return 1 === $result;
 	}

+	/**
+	 * Fills ORDER BY clause of SQL request based on user supplied parameters. Overridden here to allow multiple direction
+	 * clauses.
+	 *
+	 * @since 10.5.0
+	 * @param array $query_args Parameters supplied by the user.
+	 * @return void
+	 */
+	protected function add_order_by_sql_params( $query_args ) {
+		$order_by_clause = $this->normalize_order_by_clause(
+			$query_args['orderby'] ?? 'date_registered',
+			$query_args['order'] ?? 'desc'
+		);
+		$this->clear_sql_clause( 'order_by' );
+		$this->add_sql_clause( 'order_by', $order_by_clause );
+	}
+
 	/**
 	 * Maps ordering specified by the user to columns in the database/fields in the data.
 	 *
-	 * @override ReportsDataStore::normalize_order_by()
+	 * Handles both order_by and direction.
 	 *
+	 * @since 10.5.0
 	 * @param string $order_by Sorting criterion.
+	 * @param string $order Order direction.
 	 * @return string
 	 */
-	protected function normalize_order_by( $order_by ) {
-		if ( 'name' === $order_by ) {
-			return "CONCAT_WS( ' ', first_name, last_name )";
+	protected function normalize_order_by_clause( $order_by, $order = 'desc' ) {
+		$order_by        = esc_sql( $order_by );
+		$order           = strtolower( $order ) === 'asc' ? 'ASC' : 'DESC';
+		$order_by_clause = '';
+
+		if ( 'location' === $order_by ) {
+			$order_by_clause = "state {$order}, country {$order}";
+		} else {
+			$order_by_clause = "{$order_by} {$order}";
 		}

-		return $order_by;
+		return $order_by_clause;
 	}

 	/**
@@ -365,6 +392,28 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 			$where_clauses[] = "{$customer_lookup_table}.user_id IN ({$included_users})";
 		}

+		// Allow a list of locations to be specified (includes).
+		if ( ! empty( $query_args['location_includes'] ) ) {
+			$location_clause = $this->build_location_filter_clause( $query_args['location_includes'], true );
+			if ( '' !== $location_clause ) {
+				$where_clauses[] = $location_clause;
+			}
+		}
+
+		// Allow a list of locations to be excluded.
+		if ( ! empty( $query_args['location_excludes'] ) ) {
+			$location_clause = $this->build_location_filter_clause( $query_args['location_excludes'], false );
+			if ( '' !== $location_clause ) {
+				$where_clauses[] = $location_clause;
+			}
+		}
+
+		// Filter by user type.
+		if ( ! empty( $query_args['user_type'] ) && 'all' !== $query_args['user_type'] ) {
+			$user_type       = $query_args['user_type'];
+			$where_clauses[] = "{$customer_lookup_table}.user_id IS " . ( 'registered' === $user_type ? 'NOT NULL' : 'NULL' );
+		}
+
 		$numeric_params = array(
 			'orders_count'    => array(
 				'column' => 'COUNT( order_id )',
@@ -520,9 +569,20 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		$this->subquery->clear_sql_clause( 'select' );
 		$this->subquery->add_sql_clause( 'select', $selections );
 		// For aggregated fields, ensure deterministic ordering by including GROUP BY field.
-		$order_by = $this->get_sql_clause( 'order_by' );
-		if ( in_array( $order_by, array( 'orders_count', 'total_spend', 'avg_order_value' ), true ) ) {
-			$this->subquery->add_sql_clause( 'order_by', $order_by . ', customer_id' );
+		$order_by             = $this->get_sql_clause( 'order_by' );
+		$aggregated_fields    = array( 'orders_count', 'total_spend', 'avg_order_value' );
+		$has_aggregated_field = false;
+
+		foreach ( $aggregated_fields as $field ) {
+			if ( false !== strpos( $order_by, $field ) ) {
+				$has_aggregated_field = true;
+				break;
+			}
+		}
+
+		if ( $has_aggregated_field ) {
+			$customer_lookup_table = self::get_db_table_name();
+			$this->subquery->add_sql_clause( 'order_by', $order_by . ", {$customer_lookup_table}.customer_id" );
 		} else {
 			$this->subquery->add_sql_clause( 'order_by', $order_by );
 		}
@@ -835,6 +895,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 			'%s',
 			'%s',
 			'%s',
+			'%s',
 		);

 		$customer_id = self::get_customer_id_by_user_id( $user_id );
@@ -1004,6 +1065,71 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		}
 	}

+	/**
+	 * Build location filter SQL clause for includes or excludes.
+	 *
+	 * @since 10.5.0
+	 * @param string $locations_string Comma-separated list of locations (e.g., "US:CA,US:NY,GB").
+	 * @param bool   $is_include       True for IN clause, false for NOT IN clause.
+	 * @return string SQL WHERE clause condition.
+	 */
+	protected function build_location_filter_clause( $locations_string, $is_include = true ) {
+		$customer_lookup_table = self::get_db_table_name();
+		$locations_array       = explode( ',', $locations_string );
+		$country_state_pairs   = array();
+		$countries             = array();
+
+		foreach ( $locations_array as $location ) {
+			$location = trim( $location );
+			if ( empty( $location ) ) {
+				continue;
+			}
+
+			if ( false !== strpos( $location, ':' ) ) {
+				$parts = explode( ':', $location );
+				if ( 2 === count( $parts ) ) {
+					$country_state_pairs[] = array(
+						'country' => esc_sql( $parts[0] ),
+						'state'   => esc_sql( $parts[1] ),
+					);
+				}
+			} else {
+				$countries[] = esc_sql( $location );
+			}
+		}
+
+		$conditions = array();
+
+		// Build country:state pair conditions.
+		if ( ! empty( $country_state_pairs ) ) {
+			$pair_conditions = array();
+			foreach ( $country_state_pairs as $pair ) {
+				if ( $is_include ) {
+					$pair_conditions[] = "({$customer_lookup_table}.country = '{$pair['country']}' AND {$customer_lookup_table}.state = '{$pair['state']}')";
+				} else {
+					$pair_conditions[] = "({$customer_lookup_table}.country != '{$pair['country']}' OR {$customer_lookup_table}.state != '{$pair['state']}')";
+				}
+			}
+			$pair_connector = $is_include ? ' OR ' : ' AND ';
+			$conditions[]   = '(' . implode( $pair_connector, $pair_conditions ) . ')';
+		}
+
+		// Build country-only conditions.
+		if ( ! empty( $countries ) ) {
+			$operator     = $is_include ? 'IN' : 'NOT IN';
+			$conditions[] = "{$customer_lookup_table}.country {$operator} ('" . implode( "','", $countries ) . "')";
+		}
+
+		if ( empty( $conditions ) ) {
+			return '';
+		}
+
+		// Combine conditions with OR for includes, AND for excludes.
+		$connector = $is_include ? ' OR ' : ' AND ';
+
+		return '(' . implode( $connector, $conditions ) . ')';
+	}
+
 	/**
 	 * Initialize query objects.
 	 */
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php
index 6811da2b4f..a1358ea66a 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php
@@ -64,6 +64,9 @@ class WC_Admin_Tests_API_Reports_Customers extends WC_REST_Unit_Test_Case {
 		$this->assertArrayHasKey( 'id', $schema );
 		$this->assertArrayHasKey( 'user_id', $schema );
 		$this->assertArrayHasKey( 'name', $schema );
+		$this->assertArrayHasKey( 'first_name', $schema );
+		$this->assertArrayHasKey( 'last_name', $schema );
+		$this->assertArrayHasKey( 'email', $schema );
 		$this->assertArrayHasKey( 'username', $schema );
 		$this->assertArrayHasKey( 'country', $schema );
 		$this->assertArrayHasKey( 'city', $schema );
@@ -91,7 +94,7 @@ class WC_Admin_Tests_API_Reports_Customers extends WC_REST_Unit_Test_Case {
 		$data       = $response->get_data();
 		$properties = $data['schema']['properties'];

-		$this->assertCount( 15, $properties );
+		$this->assertCount( 18, $properties );
 		$this->assert_report_item_schema( $properties );
 	}

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
new file mode 100644
index 0000000000..1961b5655d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-reports-customers-controller-test.php
@@ -0,0 +1,642 @@
+<?php
+declare( strict_types=1 );
+
+use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore;
+use Automattic\WooCommerce\Enums\OrderStatus;
+
+/**
+ * Reports Customers REST API Test Class
+ *
+ * @package WooCommerce\Admin\Tests\API
+ */
+class WC_Admin_Reports_Customers_Controller_Test extends WC_REST_Unit_Test_Case {
+	/**
+	 * Endpoint.
+	 *
+	 * @var string
+	 */
+	protected $endpoint = '/wc-analytics/reports/customers';
+
+	/**
+	 * User ID for authentication.
+	 *
+	 * @var int
+	 */
+	protected $user = 0;
+
+	/**
+	 * Test product used for orders.
+	 *
+	 * @var WC_Product_Simple
+	 */
+	protected $product;
+
+	/**
+	 * Registered customers.
+	 *
+	 * @var array
+	 */
+	protected $registered_customers = array();
+
+	/**
+	 * Guest orders (no user_id).
+	 *
+	 * @var array
+	 */
+	protected $guest_orders = array();
+
+	/**
+	 * Setup test reports customers data.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->user = $this->factory->user->create(
+			array(
+				'role' => 'administrator',
+			)
+		);
+
+		wp_set_current_user( $this->user );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		// Create a test product.
+		$this->product = new WC_Product_Simple();
+		$this->product->set_name( 'Test Product' );
+		$this->product->set_regular_price( 25 );
+		$this->product->save();
+
+		// Create registered customers with different names for search testing.
+		$customer1 = WC_Helper_Customer::create_customer( 'customer1', 'password', 'customer1@example.com' );
+		$customer1->set_first_name( 'John' );
+		$customer1->set_last_name( 'Doe' );
+		$customer1->set_billing_state( 'CA' );
+		$customer1->set_billing_country( 'US' );
+		$customer1->save();
+		$this->registered_customers[] = $customer1;
+
+		$customer2 = WC_Helper_Customer::create_customer( 'customer2', 'password', 'customer2@example.com' );
+		$customer2->set_first_name( 'Jane' );
+		$customer2->set_last_name( 'Smith' );
+		$customer2->set_billing_state( 'NY' );
+		$customer2->set_billing_country( 'US' );
+		$customer2->save();
+		$this->registered_customers[] = $customer2;
+
+		$customer3 = WC_Helper_Customer::create_customer( 'customer3', 'password', 'customer3@example.com' );
+		$customer3->set_first_name( 'Bob' );
+		$customer3->set_last_name( 'Johnson' );
+		$customer3->set_billing_state( 'CA' );
+		$customer3->set_billing_country( 'US' );
+		$customer3->save();
+		$this->registered_customers[] = $customer3;
+
+		// Create orders for registered customers with location data.
+		foreach ( $this->registered_customers as $index => $customer ) {
+			$order = WC_Helper_Order::create_order( $customer->get_id(), $this->product );
+			$order->set_status( OrderStatus::COMPLETED );
+			$order->set_total( 100 + ( $index * 50 ) );
+			$order->set_billing_state( $customer->get_billing_state() );
+			$order->set_billing_country( $customer->get_billing_country() );
+			$order->save();
+		}
+
+		// Create guest orders (no user_id) with different locations.
+		$guest_order1 = WC_Helper_Order::create_order( 0, $this->product );
+		$guest_order1->set_billing_email( 'guest1@example.com' );
+		$guest_order1->set_billing_first_name( 'Guest' );
+		$guest_order1->set_billing_last_name( 'Customer' );
+		$guest_order1->set_billing_state( 'TX' );
+		$guest_order1->set_billing_country( 'US' );
+		$guest_order1->set_status( OrderStatus::COMPLETED );
+		$guest_order1->set_total( 50 );
+		$guest_order1->save();
+		$this->guest_orders[] = $guest_order1;
+
+		$guest_order2 = WC_Helper_Order::create_order( 0, $this->product );
+		$guest_order2->set_billing_email( 'guest2@example.com' );
+		$guest_order2->set_billing_first_name( 'Guest' );
+		$guest_order2->set_billing_last_name( 'User' );
+		$guest_order2->set_billing_state( 'ON' );
+		$guest_order2->set_billing_country( 'CA' );
+		$guest_order2->set_status( OrderStatus::COMPLETED );
+		$guest_order2->set_total( 75 );
+		$guest_order2->save();
+		$this->guest_orders[] = $guest_order2;
+
+		// Sync all data to lookup tables.
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+	}
+
+	/**
+	 * Test route registration.
+	 */
+	public function test_register_routes() {
+		// This namespace may be lazy loaded, so we make a discovery request to trigger loading for this test.
+		$this->server->dispatch( new WP_REST_Request( 'GET', '/' ) );
+		$routes = $this->server->get_routes();
+
+		$this->assertArrayHasKey( $this->endpoint, $routes );
+	}
+
+	/**
+	 * Test getting reports without valid permissions.
+	 */
+	public function test_get_reports_without_permission() {
+		wp_set_current_user( 0 );
+		$response = $this->server->dispatch( new WP_REST_Request( 'GET', $this->endpoint ) );
+		$this->assertEquals( 401, $response->get_status() );
+	}
+
+	/**
+	 * Test user_type parameter with 'all' value (default).
+	 */
+	public function test_user_type_all() {
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'user_type' => 'all',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+		$headers  = $response->get_headers();
+
+		$this->assertEquals( 200, $response->get_status() );
+		// Should return both registered and guest customers.
+		// We have 3 registered customers and 2 guest customers = 5 total.
+		$this->assertGreaterThanOrEqual( 5, count( $reports ) );
+		$this->assertArrayHasKey( 'X-WP-Total', $headers );
+		$this->assertGreaterThanOrEqual( 5, $headers['X-WP-Total'] );
+	}
+
+	/**
+	 * Test user_type parameter with 'registered' value.
+	 */
+	public function test_user_type_registered() {
+		// Test with user_type='registered'.
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'user_type' => 'registered',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+		$headers  = $response->get_headers();
+
+		$this->assertEquals( 200, $response->get_status() );
+		// Should only return registered customers (with user_id).
+		// We have 3 registered customers.
+		$this->assertGreaterThanOrEqual( 3, count( $reports ) );
+		$this->assertArrayHasKey( 'X-WP-Total', $headers );
+		$this->assertGreaterThanOrEqual( 3, $headers['X-WP-Total'] );
+
+		// Verify all returned customers have a user_id.
+		foreach ( $reports as $report ) {
+			$this->assertNotNull( $report['user_id'], 'All customers should have a user_id when user_type=registered' );
+		}
+	}
+
+	/**
+	 * Test user_type parameter with 'guest' value.
+	 */
+	public function test_user_type_guest() {
+		// Test with user_type='guest'.
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'user_type' => 'guest',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+		$headers  = $response->get_headers();
+
+		$this->assertEquals( 200, $response->get_status() );
+		// Should only return guest customers (without user_id).
+		// We have 2 guest customers.
+		$this->assertGreaterThanOrEqual( 2, count( $reports ) );
+		$this->assertArrayHasKey( 'X-WP-Total', $headers );
+		$this->assertGreaterThanOrEqual( 2, $headers['X-WP-Total'] );
+
+		// Verify all returned customers have user_id of 0 (intval converts NULL to 0).
+		foreach ( $reports as $report ) {
+			$this->assertEquals( 0, $report['user_id'], 'All customers should have user_id of 0 when user_type=guest' );
+		}
+	}
+
+	/**
+	 * Test user_type parameter default behavior (when not specified).
+	 */
+	public function test_user_type_default() {
+		// Test without specifying user_type (should default to 'all').
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+		$headers  = $response->get_headers();
+
+		$this->assertEquals( 200, $response->get_status() );
+		// Should return both registered and guest customers (default behavior).
+		// We have 3 registered customers and 2 guest customers = 5 total.
+		$this->assertGreaterThanOrEqual( 5, count( $reports ) );
+		$this->assertArrayHasKey( 'X-WP-Total', $headers );
+		$this->assertGreaterThanOrEqual( 5, $headers['X-WP-Total'] );
+	}
+
+	/**
+	 * Test user_type parameter combined with other filters.
+	 */
+	public function test_user_type_with_other_filters() {
+		// Test user_type='registered' combined with search.
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'user_type' => 'registered',
+				'search'    => 'John',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		// Should only return registered customers matching the search.
+		// We have one customer named "John Doe".
+		$this->assertGreaterThanOrEqual( 1, count( $reports ) );
+		foreach ( $reports as $report ) {
+			$this->assertNotNull( $report['user_id'], 'All customers should have a user_id when user_type=registered' );
+		}
+
+		// Verify the search actually filtered correctly.
+		$found_john = false;
+		foreach ( $reports as $report ) {
+			if ( false !== strpos( $report['name'], 'John' ) ) {
+				$found_john = true;
+				break;
+			}
+		}
+		$this->assertTrue( $found_john, 'Search should return customer named John' );
+	}
+
+	/**
+	 * Test location ordering parameter (sorts by state, then country).
+	 */
+	public function test_orderby_location() {
+		// Test with orderby='location' ascending.
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'orderby' => 'location',
+				'order'   => 'asc',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		// Should have at least 5 customers (3 registered + 2 guest).
+		$this->assertGreaterThanOrEqual( 5, count( $reports ) );
+
+		// Verify ordering: should sort by state first, then country.
+		// Expected order (ascending by state, then country):
+		// - CA, US (customer1).
+		// - CA, US (customer3).
+		// - NY, US (customer2).
+		// - ON, CA (guest2).
+		// - TX, US (guest1).
+		$previous_state   = '';
+		$previous_country = '';
+		foreach ( $reports as $report ) {
+			$current_state   = $report['state'] ?? '';
+			$current_country = $report['country'] ?? '';
+
+			// If we have a previous entry, verify ordering.
+			if ( '' !== $previous_state ) {
+				// State should be >= previous state (ascending).
+				// If states are equal, country should be >= previous country.
+				$state_comparison = strcmp( $current_state, $previous_state );
+				if ( 0 === $state_comparison ) {
+					// States are equal, so country should be >= (ascending).
+					$this->assertGreaterThanOrEqual(
+						0,
+						strcmp( $current_country, $previous_country ),
+						"When states are equal ({$current_state}), countries should be in ascending order. Previous: {$previous_country}, Current: {$current_country}"
+					);
+				} else {
+					// States are different, current should be >= previous (ascending).
+					$this->assertGreaterThanOrEqual(
+						0,
+						$state_comparison,
+						"States should be in ascending order. Previous: {$previous_state}, Current: {$current_state}"
+					);
+				}
+			}
+
+			$previous_state   = $current_state;
+			$previous_country = $current_country;
+		}
+
+		// Test with orderby='location' descending.
+		$request->set_query_params(
+			array(
+				'orderby' => 'location',
+				'order'   => 'desc',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertGreaterThanOrEqual( 5, count( $reports ) );
+
+		// Verify descending ordering.
+		$previous_state   = '';
+		$previous_country = '';
+		foreach ( $reports as $report ) {
+			$current_state   = $report['state'] ?? '';
+			$current_country = $report['country'] ?? '';
+
+			if ( '' !== $previous_state ) {
+				$state_comparison = strcmp( $current_state, $previous_state );
+				if ( 0 === $state_comparison ) {
+					// States are equal, so country should be <= (descending).
+					$this->assertLessThanOrEqual(
+						0,
+						strcmp( $current_country, $previous_country ),
+						"When states are equal ({$current_state}), countries should be in descending order. Previous: {$previous_country}, Current: {$current_country}"
+					);
+				} else {
+					// States are different, current should be <= previous (descending).
+					$this->assertLessThanOrEqual(
+						0,
+						$state_comparison,
+						"States should be in descending order. Previous: {$previous_state}, Current: {$current_state}"
+					);
+				}
+			}
+
+			$previous_state   = $current_state;
+			$previous_country = $current_country;
+		}
+	}
+
+	/**
+	 * Test location_includes parameter with country:state format.
+	 */
+	public function test_location_includes_country_state() {
+		// Test with location_includes='US:CA' (should return 2 customers: customer1 and customer3).
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'location_includes' => 'US:CA',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 2, $reports, 'Should return 2 customers from US:CA' );
+		foreach ( $reports as $report ) {
+			$this->assertEquals( 'US', $report['country'], 'All customers should be from US' );
+			$this->assertEquals( 'CA', $report['state'], 'All customers should be from CA' );
+		}
+
+		// Test with location_includes='US:NY' (should return 1 customer: customer2).
+		$request->set_query_params(
+			array(
+				'location_includes' => 'US:NY',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 1, $reports, 'Should return 1 customer from US:NY' );
+		$this->assertEquals( 'US', $reports[0]['country'] );
+		$this->assertEquals( 'NY', $reports[0]['state'] );
+
+		// Test with location_includes='CA:ON' (should return 1 customer: guest2).
+		$request->set_query_params(
+			array(
+				'location_includes' => 'CA:ON',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 1, $reports, 'Should return 1 customer from CA:ON' );
+		$this->assertEquals( 'CA', $reports[0]['country'] );
+		$this->assertEquals( 'ON', $reports[0]['state'] );
+	}
+
+	/**
+	 * Test location_includes parameter with country format.
+	 */
+	public function test_location_includes_country() {
+		// Test with location_includes='US' (should return 4 customers: customer1, customer2, customer3, guest1).
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'location_includes' => 'US',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 4, $reports, 'Should return 4 customers from US' );
+		foreach ( $reports as $report ) {
+			$this->assertEquals( 'US', $report['country'], 'All customers should be from US' );
+		}
+
+		// Test with location_includes='CA' (should return 1 customer: guest2).
+		$request->set_query_params(
+			array(
+				'location_includes' => 'CA',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 1, $reports, 'Should return 1 customer from CA' );
+		$this->assertEquals( 'CA', $reports[0]['country'] );
+	}
+
+	/**
+	 * Test location_includes parameter with mixed formats.
+	 */
+	public function test_location_includes_mixed() {
+		// Test with location_includes='US:CA,US:NY' (should return 3 customers: customer1, customer2, customer3).
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'location_includes' => 'US:CA,US:NY',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 3, $reports, 'Should return 3 customers from US:CA and US:NY' );
+
+		// Test with location_includes='US:CA,CA' (should return 3 customers: customer1, customer3, guest2).
+		$request->set_query_params(
+			array(
+				'location_includes' => 'US:CA,CA',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 3, $reports, 'Should return 3 customers from US:CA and CA country' );
+	}
+
+	/**
+	 * Test location_excludes parameter with country:state format.
+	 */
+	public function test_location_excludes_country_state() {
+		// Test with location_excludes='US:CA' (should exclude 2 customers, return 3: customer2, guest1, guest2).
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'location_excludes' => 'US:CA',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 3, $reports, 'Should return 3 customers excluding US:CA' );
+		foreach ( $reports as $report ) {
+			$location = $report['country'] . ':' . $report['state'];
+			$this->assertNotEquals( 'US:CA', $location, 'No customers should be from US:CA' );
+		}
+
+		// Test with location_excludes='US:TX' (should exclude 1 customer, return 4: customer1, customer2, customer3, guest2).
+		$request->set_query_params(
+			array(
+				'location_excludes' => 'US:TX',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 4, $reports, 'Should return 4 customers excluding US:TX' );
+	}
+
+	/**
+	 * Test location_excludes parameter with country format.
+	 */
+	public function test_location_excludes_country() {
+		// Test with location_excludes='US' (should exclude 4 customers, return 1: guest2).
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'location_excludes' => 'US',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 1, $reports, 'Should return 1 customer excluding US' );
+		$this->assertEquals( 'CA', $reports[0]['country'], 'Remaining customer should be from CA' );
+
+		// Test with location_excludes='CA' (should exclude 1 customer, return 4: customer1, customer2, customer3, guest1).
+		$request->set_query_params(
+			array(
+				'location_excludes' => 'CA',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 4, $reports, 'Should return 4 customers excluding CA' );
+		foreach ( $reports as $report ) {
+			$this->assertEquals( 'US', $report['country'], 'All customers should be from US' );
+		}
+	}
+
+	/**
+	 * Test location_excludes parameter with mixed formats.
+	 */
+	public function test_location_excludes_mixed() {
+		// Test with location_excludes='US:CA,US:TX' (should exclude 3 customers, return 2: customer2, guest2).
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'location_excludes' => 'US:CA,US:TX',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 2, $reports, 'Should return 2 customers excluding US:CA and US:TX' );
+
+		// Test with location_excludes='US:CA,CA' (should exclude 3 customers, return 2: customer2, guest1).
+		$request->set_query_params(
+			array(
+				'location_excludes' => 'US:CA,CA',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 2, $reports, 'Should return 2 customers excluding US:CA and CA country' );
+		foreach ( $reports as $report ) {
+			$this->assertEquals( 'US', $report['country'], 'All remaining customers should be from US' );
+			$this->assertNotEquals( 'CA', $report['state'], 'No customers should be from CA state' );
+		}
+	}
+
+	/**
+	 * Test location_includes and location_excludes combined.
+	 */
+	public function test_location_includes_and_excludes() {
+		// Test with location_includes='US' and location_excludes='US:CA' (should return 2: customer2, guest1).
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'location_includes' => 'US',
+				'location_excludes' => 'US:CA',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 2, $reports, 'Should return 2 customers from US excluding US:CA' );
+		foreach ( $reports as $report ) {
+			$this->assertEquals( 'US', $report['country'], 'All customers should be from US' );
+			$this->assertNotEquals( 'CA', $report['state'], 'No customers should be from CA state' );
+		}
+	}
+}