# woocommerce-pos/1.10.17/includes/API/V1/Customers_Controller.php

WCPOS – Point of Sale (POS) plugin for WooCommerce, version 1.10.17. 974 lines.

- Page: https://pluginprobe.com/plugins/woocommerce-pos/1.10.17/code/includes/API/V1/Customers_Controller.php
- Raw: https://pluginprobe.com/plugins/woocommerce-pos/1.10.17/raw/includes/API/V1/Customers_Controller.php
- Modified: 2026-09-13T08:01:26+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/woocommerce-pos/1.10.17/code/includes/API/V1/Customers_Controller.php#L10-L20`.

```php
<?php
/**
 * Customers_Controller.
 *
 * @package WCPOS\WooCommercePOS
 */

namespace WCPOS\WooCommercePOS\API\V1;

\defined( 'ABSPATH' ) || die;

if ( ! class_exists( 'WC_REST_Customers_Controller' ) ) {
	return;
}

use Exception;
use WC_Customer;
use WC_REST_Customers_Controller;
use WCPOS\WooCommercePOS\Logger;
use WCPOS\WooCommercePOS\Services\Customer_Account_Guard;
use WCPOS\WooCommercePOS\Services\Settings as SettingsService;
use WCPOS\WooCommercePOS\Services\Tax_Id_Reader;
use WCPOS\WooCommercePOS\Services\Tax_Id_Types;
use WCPOS\WooCommercePOS\Services\Tax_Id_Writer;
use WCPOS\WooCommercePOS\Sync\Collection_Rules;
use WCPOS\WooCommercePOS\Sync\Meta_Normalizer;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_User;
use WP_User_Query;

/**
 * Product Tgas controller class.
 *
 * @NOTE: methods not prefixed with wcpos_ will override WC_REST_Customers_Controller methods
 */
class Customers_Controller extends WC_REST_Customers_Controller {
	use Traits\Query_Helpers;
	use Traits\Uuid_Handler;
	use Traits\WCPOS_REST_API;

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wcpos/v1';

	/**
	 * Store user search results for merging with meta_query search results.
	 *
	 * @var array
	 */
	protected $wcpos_user_search_results = array();

	/**
	 * Store the request object for use in lifecycle methods.
	 *
	 * @var WP_REST_Request
	 */
	protected $wcpos_request;

	/**
	 * Dispatch request to parent controller, or override if needed.
	 *
	 * @param mixed           $dispatch_result Dispatch result, will be used if not empty.
	 * @param WP_REST_Request $request         Request used to generate the response.
	 * @param string          $route           Route matched for the request.
	 * @param array           $handler         Route handler used for the request.
	 */
	public function wcpos_dispatch_request( $dispatch_result, WP_REST_Request $request, $route, $handler ) {
		$this->wcpos_request = $request;

		add_filter( 'woocommerce_rest_prepare_customer', array( $this, 'wcpos_customer_response' ), 10, 3 );
		add_filter( 'woocommerce_rest_customer_query', array( $this, 'wcpos_customer_query' ), 10, 2 );
		add_filter( 'is_protected_meta', array( $this, 'wcpos_allow_uuid_meta' ), 10, 3 );

		/*
		 * Check if the request is for all customers and if the 'posts_per_page' is set to -1.
		 * Optimised query for getting all customer IDs.
		 */
		if ( Bulk_ID_Fast_Path::supports_request( $request ) ) {
			return $this->wcpos_get_all_posts( $request );
		}

		return $dispatch_result;
	}

	/**
	 * Add custom fields to the product schema.
	 */
	public function get_item_schema() {
		$schema = parent::get_item_schema();

		// Check and remove email format validation from the billing property.
		if ( isset( $schema['properties']['billing']['properties']['email']['format'] ) ) {
			unset( $schema['properties']['billing']['properties']['email']['format'] );
		}

		// Add structured tax_ids property (TaxId[]).
		$schema['properties']['tax_ids'] = array(
			'description' => /* translators: REST API schema field label or error message. */ __( 'Customer tax IDs.', 'woocommerce-pos' ),
			'type'        => 'array',
			'context'     => array( 'view', 'edit' ),
			'items'       => array(
				'type'       => 'object',
				'properties' => array(
					'type'    => array(
						'type'        => 'string',
						'enum'        => Tax_Id_Types::all_types(),
						'description' => /* translators: REST API schema field label or error message. */ __( 'Tax ID type.', 'woocommerce-pos' ),
					),
					'value'   => array(
						'type'        => 'string',
						'description' => /* translators: REST API schema field label or error message. */ __( 'Tax ID value.', 'woocommerce-pos' ),
					),
					'country' => array(
						'type'        => array( 'string', 'null' ),
						'description' => __( 'ISO 3166-1 alpha-2 country code.', 'woocommerce-pos' ),
					),
					'label'   => array(
						'type'        => array( 'string', 'null' ),
						'description' => /* translators: REST API schema field label or error message. */ __( 'Optional human-readable label.', 'woocommerce-pos' ),
					),
				),
			),
		);

		return $schema;
	}

	/**
	 * Check if a given request has access to create a customer.
	 *
	 * WC checks promote_users (< 9.9) or create_customers (9.9+). The POS
	 * fallback checks only the version-appropriate capability so it matches
	 * the toggle shown on the Access settings page.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 *
	 * @return WP_Error|bool
	 */
	public function create_item_permissions_check( $request ) {
		$permission = parent::create_item_permissions_check( $request );

		if ( is_wp_error( $permission ) ) {
			$customer_create_cap = version_compare( WC()->version, '9.9', '>=' )
				? 'create_customers'
				: 'promote_users';

			if ( current_user_can( $customer_create_cap ) ) {
				return true;
			}
		}

		return $permission;
	}

	/**
	 * Check if a given request has access to update a customer.
	 *
	 * WCPOS never widens WooCommerce's credential fence, which refuses
	 * email/password changes on non-customer roles. The guard additionally
	 * keeps non-admins off staff accounts, testing capabilities rather than
	 * WooCommerce's first-role-only test.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 *
	 * @return WP_Error|bool
	 */
	public function update_item_permissions_check( $request ) {
		return $this->wcpos_guarded_permissions_check(
			(int) $request['id'],
			function () use ( $request ) {
				return parent::update_item_permissions_check( $request );
			}
		);
	}

	/**
	 * Check if a given request has access to delete a customer.
	 *
	 * WooCommerce refuses deleting a user whose role is outside its allowed
	 * list, but it reads only the FIRST role, so an administrator who also
	 * holds the customer role is deleted by any POS user with delete_users.
	 * The guard closes that by capability.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 *
	 * @return WP_Error|bool
	 */
	public function delete_item_permissions_check( $request ) {
		return $this->wcpos_guarded_permissions_check(
			(int) $request['id'],
			function () use ( $request ) {
				return parent::delete_item_permissions_check( $request );
			}
		);
	}

	/**
	 * Run WooCommerce's own check behind the staff account guard.
	 *
	 * The guard runs first and can only refuse. When it clears the target,
	 * that target's roles are allowed through WooCommerce's shop_manager
	 * role-name restriction for the duration of the check, so a cleared
	 * subscriber or membership-plugin role is judged by capability.
	 *
	 * @param int      $target_id Target user ID.
	 * @param callable $check     Returns WooCommerce's verdict.
	 *
	 * @return WP_Error|bool
	 */
	private function wcpos_guarded_permissions_check( int $target_id, callable $check ) {
		if ( ! Customer_Account_Guard::can_modify( get_current_user_id(), $target_id ) ) {
			return Customer_Account_Guard::denial();
		}

		$restore = Customer_Account_Guard::allow_target_roles( $target_id );

		try {
			return $check();
		} finally {
			$restore();
		}
	}

	/**
	 * Add extra fields to WP_REST_Controller::get_collection_params().
	 * - add new fields to the 'orderby' enum list.
	 * - default the 'role' filter to every user, matching the proxy Read Lane.
	 */
	public function get_collection_params() {
		$params = parent::get_collection_params();

		/*
		 * The POS customer space is every user on this site, not only the users
		 * holding the `customer` role. `wcpos_get_all_posts()` has always enumerated
		 * `wp_users` unfiltered, and the proxy Read Lane defaults `role` to `all`
		 * (see Customers_Proxy_Behavior); only this paged list inherited wc/v3's
		 * `role => customer` default, so the same query answered differently on each
		 * lane and the bulk id set did not match the list it was meant to describe.
		 * Defaulting here is the parity fix; an explicit `role` still narrows.
		 */
		if ( isset( $params['role'] ) ) {
			$params['role']['default'] = 'all';
		}

		// Check if 'orderby' is set and is an array before modifying it.
		if ( isset( $params['orderby'] ) && \is_array( $params['orderby']['enum'] ) ) {
			/*
			 * A PROJECTION of the customer sort rows, so this schema enum and the proxy
			 * lane's claim list cannot disagree about which sorts WCPOS implements.
			 */
			$new_orderby_options = Collection_Rules::orderby_enum( 'customers' );
			foreach ( $new_orderby_options as $option ) {
				if ( ! \in_array( $option, $params['orderby']['enum'], true ) ) {
					$params['orderby']['enum'][] = $option;
				}
			}
		}

		// Add 'roles' filter, this allows us to filter by multiple roles.
		$params['roles'] = array(
			'description'       => __( 'Filter customers by roles.', 'woocommerce-pos' ),
			'type'              => 'array',
			'items'             => array(
				'type'          => 'string',
			),
			'required'          => false,
		);

		return $params;
	}

	/**
	 * Create a single item.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 *
	 * @return WP_Error|WP_REST_Response
	 */
	public function create_item( $request ) {
		$invalid_meta = $this->wcpos_sanitize_meta_data_param( $request );
		if ( is_wp_error( $invalid_meta ) ) {
			return $invalid_meta;
		}

		$valid_email = $this->wcpos_validate_billing_email( $request );
		if ( is_wp_error( $valid_email ) ) {
			return $valid_email;
		}

		/*
		 * Generate a password for the new user.
		 * Add filter for get_option key 'woocommerce_registration_generate_password' to ensure it is set to 'yes'.
		 */
		add_filter(
			'pre_option_woocommerce_registration_generate_password',
			function () {
				return 'yes';
			}
		);

		/*
		 * Optionally generate a username from the customer's email address.
		 * Reads the POS 'generate_username' general setting and overrides the
		 * store-level 'woocommerce_registration_generate_username' option so the
		 * POS behaviour is independent of the online-checkout setting.
		 */
		$generate_username = SettingsService::instance()->generate_username_enabled();
		add_filter(
			'pre_option_woocommerce_registration_generate_username',
			function () use ( $generate_username ) {
				return $generate_username ? 'yes' : 'no';
			}
		);

		// Proceed with the parent method to handle the creation.
		$response = parent::create_item( $request );
		$this->wcpos_persist_tax_ids_from_request( $response, $request );

		return $response;
	}

	/**
	 * Update a single order.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 *
	 * @return WP_Error|WP_REST_Response
	 */
	public function update_item( $request ) {
		$invalid_meta = $this->wcpos_sanitize_meta_data_param( $request );
		if ( is_wp_error( $invalid_meta ) ) {
			return $invalid_meta;
		}

		$valid_email = $this->wcpos_validate_billing_email( $request );
		if ( is_wp_error( $valid_email ) ) {
			return $valid_email;
		}

		// Proceed with the parent method to handle the creation.
		$response = parent::update_item( $request );
		$this->wcpos_persist_tax_ids_from_request( $response, $request );

		return $response;
	}

	/**
	 * Persist `tax_ids` from a create/update request via Tax_Id_Writer.
	 *
	 * No-op when the request did not include `tax_ids`, the response is an
	 * error, or the resolved user ID is invalid.
	 *
	 * @param mixed           $response Response from the parent controller.
	 * @param WP_REST_Request $request  Original request.
	 */
	protected function wcpos_persist_tax_ids_from_request( $response, WP_REST_Request $request ): void {
		if ( ! ( $response instanceof WP_REST_Response ) ) {
			return;
		}
		$tax_ids = $request->get_param( 'tax_ids' );
		if ( ! \is_array( $tax_ids ) ) {
			return;
		}

		$data    = $response->get_data();
		$user_id = isset( $data['id'] ) ? (int) $data['id'] : 0;
		if ( $user_id <= 0 ) {
			return;
		}

		( new Tax_Id_Writer() )->write_for_user( $user_id, $tax_ids );

		// Reflect persisted list in the response.
		$data['tax_ids'] = ( new Tax_Id_Reader() )->read_for_user( $user_id );
		$response->set_data( $data );
	}

	/**
	 * Validate billing email.
	 * NOTE: we have removed the format check to allow empty email addresses.
	 *
	 * @param WP_REST_Request $request The REST request object.
	 *
	 * @return bool|WP_Error
	 */
	public function wcpos_validate_billing_email( WP_REST_Request $request ) {
		// Your custom validation logic for the request data.
		$billing = $request['billing'] ?? null;
		$email   = \is_array( $billing ) ? ( $billing['email'] ?? null ) : null;

		if ( ! \is_null( $email ) && '' !== $email && ! is_email( $email ) ) {
			return new WP_Error(
				'rest_invalid_param',
				// translators: Use default WordPress translation.
				__( 'Invalid email address.', 'woocommerce-pos' ),
				array( 'status' => 400 )
			);
		}

		return true;
	}

	/**
	 * Filter customer data returned from the REST API.
	 *
	 * @param WP_REST_Response $response  The response object.
	 * @param WP_User          $user_data User object used to create response.
	 * @param WP_REST_Request  $request   Request object.
	 */
	public function wcpos_customer_response( WP_REST_Response $response, WP_User $user_data, WP_REST_Request $request ): WP_REST_Response {
		$data = $response->get_data();

		// Add the uuid to the response.
		$this->maybe_add_user_uuid( $user_data );

		/*
		 * Add the customer meta data to the response
		 *
		 * In the WC REST Customers Controller -> get_formatted_item_data_core function, the customer's
		 * meta_data is only added for administrators. I assume this is for privacy/security reasons?
		 *
		 * Even for administrators, meta data starting with '_' will be filtered out.
		 * We need to add the uuid meta_data to the response for all cashiers and also non-protected meta.
		 *
		 * This means we let of junk meta_data into the response, but at least we don't block data and allow
		 * saving of meta_data.
		 *
		 * @TODO - add filter settings to block/allow meta_data keys
		 */
		try {
			$customer      = new WC_Customer( $user_data->ID );
			$raw_meta_data = $customer->get_meta_data();

			// Monitor meta count.
			$this->wcpos_monitor_meta_count( $customer, $raw_meta_data );

			$filtered_meta_data = array_filter(
				$raw_meta_data,
				function ( $meta ) {
					if ( is_protected_meta( $meta->key, 'user' ) ) {
						return false;
					}
					// A single enormous value fatals the response encoder no matter how few
					// entries the customer has; same budget as the v2 sync lane.
					if ( Meta_Normalizer::exceeds_value_budget( $meta->value ) ) {
						Meta_Normalizer::note_oversized_meta( (string) $meta->key, (int) $meta->id );

						return false;
					}

					return true;
				}
			);

			// Convert to WC REST API expected format.
			$data['meta_data'] = array_map(
				function ( $meta ) {
					return array(
						'id'    => $meta->id,
						'key'   => $meta->key,
						'value' => $meta->value,
					);
				},
				array_values( $filtered_meta_data )
			);
		} catch ( Exception $e ) {
			Logger::log( 'Error getting customer meta data: ' . $e->getMessage() );
		}

		// Add structured tax_ids list (read fallback across legacy plugin meta keys).
		$data['tax_ids'] = ( new Tax_Id_Reader() )->read_for_user( $user_data->ID );

		// Estimate response size and log if excessive.
		$this->wcpos_estimate_response_size( $data, $user_data->ID, 'Customer' );

		// Set any changes to the response data.
		$response->set_data( $data );

		return $response;
	}

	/**
	 * Returns array of all customer ids.
	 *
	 * Note: user queries are a little more complicated than post queries, for example,
	 * multisite would return all users from all sites, not just the current site.
	 * Also, querying by role is not as simple as querying by post type.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 *
	 * @return WP_Error|WP_REST_Response
	 */
	public function wcpos_get_all_posts( $request ) {
		global $wpdb;

		$start_time            = microtime( true );
		$modified_after        = Bulk_ID_Fast_Path::modified_after_timestamp( $request );
		$has_modified_after    = null !== $modified_after;
		$id_with_modified_date = Bulk_ID_Fast_Path::wants_modified_date( $request );

		$args = array(
			'fields' => array( 'ID', 'user_registered' ), // Return only the ID and registered date.
			// 'role__in' => 'all', // @TODO: could be an array of roles, like ['customer', 'cashier'].
		);
		$args = Bulk_ID_Fast_Path::apply_id_filters_to_args( $args, $request );

		/*
		 * The user query is too complex to do a direct sql query, eg: multisite would return all users from all sites,
		 * not just the current site. Also, querying by role is not as simple as querying by post type.
		 *
		 * For now we get all user ids and all 'last_update' meta values, then combine them into an array of objects.
		 */
		try {
			$user_query   = new WP_User_Query( $args );
			$users        = $user_query->get_results();
			$last_updates = array();

			if ( $id_with_modified_date || $has_modified_after ) {
				$query = "
					SELECT user_id, meta_value 
					FROM $wpdb->usermeta 
					WHERE meta_key = 'last_update'
				";

				// If modified_after param is set, add the condition to the query.
				if ( $has_modified_after ) {
					$query .= $wpdb->prepare( ' AND meta_value > %d', (int) $modified_after );
				}

				$last_update_results = $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is prepared conditionally above.

				// Manually create the associative array of user_id => last_update.
				foreach ( $last_update_results as $result ) {
					$last_updates[ $result->user_id ] = is_numeric( $result->meta_value ) ? gmdate( 'Y-m-d\TH:i:s', (int) $result->meta_value ) : null;
				}
			}

			/**
			 * Performance notes:
			 * - Using a generator is faster than array_map when dealing with large datasets.
			 * - If date is in the format 'Y-m-d H:i:s' we just do preg_replace to 'Y-m-d\TH:i:s',
			 * rather than using wc_rest_prepare_date_response
			 *
			 * This resulted in execution time of 10% of the original time.
			 *
			 * If the modified_after param is set, we don't need to loop through the entire user list.
			 * The last_update_results array will only contain the users that have been modified after the given date.
			 * We just need to check they are valid user ids, this sucks, but there could be orphaned last_update meta values.
			 */
			$formatted_results = array();

			if ( $has_modified_after ) {
				foreach ( $users as $user ) {
					if ( isset( $last_updates[ $user->ID ] ) ) {
						$user_info = array( 'id' => (int) $user->ID );
						if ( $id_with_modified_date ) {
							$user_info['date_modified_gmt'] = $last_updates[ $user->ID ];
						}
						$formatted_results[] = $user_info;
					}
				}
			} else {
				$formatted_results = iterator_to_array(
					( function () use ( $users, $last_updates, $id_with_modified_date ) {
						foreach ( $users as $user ) {
							$user_info = array( 'id' => (int) $user->ID );
							if ( $id_with_modified_date ) {
								if ( isset( $last_updates[ $user->ID ] ) && ! empty( $last_updates[ $user->ID ] ) ) {
									$user_info['date_modified_gmt'] = $last_updates[ $user->ID ];
								} else {
									$user_info['date_modified_gmt'] = null; // users can have null date_modified_gmt.
								}
							}
							yield $user_info;
						}
					} )()
				);
			}

			return Bulk_ID_Fast_Path::response( $this, $formatted_results, $start_time, false );
		} catch ( Exception $e ) {
			return Bulk_ID_Fast_Path::fetch_error( 'Error fetching order IDs: ' . $e->getMessage(), 'Error fetching customer IDs.' );
		}
	}

	/**
	 * Filter arguments, before passing to WP_User_Query, when querying users via the REST API.
	 *
	 * @param array           $prepared_args Array of arguments for WP_User_Query.
	 * @param WP_REST_Request $request       The current request.
	 *
	 * @return array $prepared_args Array of arguments for WP_User_Query.
	 */
	public function wcpos_customer_query( array $prepared_args, WP_REST_Request $request ): array {
		$query_params = $request->get_query_params();

		// add modified_after date_modified_gmt.
		if ( isset( $query_params['modified_after'] ) && '' !== $query_params['modified_after'] ) {
			$timestamp = strtotime( $query_params['modified_after'] );

			/*
			 * `last_update` holds a Unix timestamp, but it is stored as usermeta text and
			 * `WP_Meta_Query` defaults an untyped clause to CHAR — which compares it as a
			 * string. Timestamps only sort the same way as strings while they are the same
			 * length, so a cutoff from before 2001-09-09 (nine digits) drops every current
			 * customer, whose timestamp is ten digits starting with a `1`: `'1787465309' >
			 * '946684800'` is false, character by character. NUMERIC casts to SIGNED and
			 * compares the numbers, which is what the bulk-id fast path below has always
			 * done by binding the same value with `%d`.
			 */
			$prepared_args['meta_query'] = $this->wcpos_merge_meta_queries(
				array(
					array(
						'key'     => 'last_update',
						'value'   => $timestamp ? (string) $timestamp : '',
						'compare' => '>',
						'type'    => 'NUMERIC',
					),
				),
				$prepared_args['meta_query'] ?? array()
			);
		}

		// Handle orderby cases.
		if ( isset( $query_params['orderby'] ) ) {
			switch ( $query_params['orderby'] ) {
				case 'first_name':
					$prepared_args['meta_key'] = 'first_name';
					$prepared_args['orderby']  = 'meta_value';

					break;

				case 'last_name':
					$prepared_args['meta_key'] = 'last_name';
					$prepared_args['orderby']  = 'meta_value';

					break;

				case 'email':
					$prepared_args['orderby'] = 'user_email';

					break;

				case 'role':
					/*
					 * Roles live in the serialized `wp_capabilities` usermeta
					 * (eg. a:1:{s:8:"customer";b:1;}), so a plain `orderby =>
					 * meta_value` sorts by that opaque string — dominated by the
					 * s:N: length prefix, not the role. Defer to a pre_user_query
					 * callback that ranks by role hierarchy instead. We leave
					 * `orderby`/`meta_key` untouched (no WP meta join to fight)
					 * and mark the query so the callback only touches ours.
					 */
					$prepared_args['_wcpos_orderby_role'] = true;
					add_action( 'pre_user_query', array( $this, 'wcpos_orderby_role' ) );

					break;

				case 'username':
					$prepared_args['orderby'] = 'user_login';

					break;

				default:
					break;
			}
		}

		// Handle search. A whitespace-only search has no terms, so treat it as no search at all.
		if ( isset( $query_params['search'] ) && 0 !== preg_match( '/\S/u', (string) $query_params['search'] ) ) {
			$search_keyword = $query_params['search'];

			/*
			 * It seems that you can't search by user_email, user_login etc and meta_query at the same time.
			 *
			 * We will unset the search param and add a hook to modify the user query to search the user table
			 */
			unset( $prepared_args['search'] );
			$prepared_args['_wcpos_search'] = $search_keyword; // store the search keyword for later use.
			add_action( 'pre_user_query', array( $this, 'wcpos_search_user_table' ) );
		} elseif ( isset( $query_params['search'] ) ) {
			unset( $prepared_args['search'] );
		}

		// Handle include/exclude.
		if ( isset( $request['wcpos_include'] ) || isset( $request['wcpos_exclude'] ) ) {
			add_action( 'pre_user_query', array( $this, 'wcpos_include_exclude_users_by_id' ) );
		}

		// Filter by roles (this is a comma separated list of roles).
		if ( ! empty( $request['roles'] ) && \is_array( $request['roles'] ) ) {
			$roles                     = array_map( 'sanitize_text_field', $request['roles'] );
			$prepared_args['role__in'] = $roles;
			// remove $prepared_args['role'] to prevent it from overriding $prepared_args['role__in'].
			unset( $prepared_args['role'] );
		}

		return $prepared_args;
	}

	/**
	 * Combine two meta_query arrays.
	 *
	 * Moved here from the Query_Helpers trait, of which this controller was the only caller.
	 *
	 * @param array $meta_query1 First meta query array.
	 * @param array $meta_query2 Second meta query array.
	 *
	 * @return array Combined meta query array.
	 */
	private function wcpos_merge_meta_queries( $meta_query1, $meta_query2 ) {
		// If either meta_query is empty, return the other.
		if ( empty( $meta_query1 ) ) {
			return $meta_query2;
		}
		if ( empty( $meta_query2 ) ) {
			return $meta_query1;
		}

		// Check if both meta_queries have 'AND' as their top-level relation.
		if ( isset( $meta_query1['relation'] ) && 'AND' === $meta_query1['relation'] &&
			isset( $meta_query2['relation'] ) && 'AND' === $meta_query2['relation'] ) {
			// Remove the 'relation' element and combine the arrays.
			unset( $meta_query1['relation'], $meta_query2['relation'] );
			$combined = array_merge( $meta_query1, $meta_query2 );
			array_unshift( $combined, array( 'relation' => 'AND' ) );

			return $combined;
		}

		// If both meta_queries are not empty and do not both have 'AND', combine them with 'AND' relation.
		return array(
			'relation' => 'AND',
			$meta_query1,
			$meta_query2,
		);
	}

	/**
	 * Add the customer search conditions to the user query.
	 *
	 * @param WP_User_Query $query The WP_User_Query instance (passed by reference).
	 */
	public function wcpos_search_user_table( $query ): void {
		global $wpdb;

		// Remove the hook.
		remove_action( 'pre_user_query', array( $this, 'wcpos_search_user_table' ) );

		$query_params = $query->query_vars;

		/*
		 * Only act on the customer query we prepared. WordPress fires pre_user_query for every
		 * WP_User_Query, and this callback can survive onto an unrelated one if our own query is
		 * short-circuited (eg. via the users_pre_query filter) before it runs. Without our search
		 * marker there is nothing to do, and appending a condition would corrupt that other query.
		 */
		if ( empty( $query_params['_wcpos_search'] ) ) {
			return;
		}

		$terms = preg_split( '/\s+/u', (string) $query_params['_wcpos_search'], -1, PREG_SPLIT_NO_EMPTY );

		/*
		 * Whitespace-only searches are filtered out before the hook is added, so reaching this
		 * point means the string could not be split (eg. malformed UTF-8). We can't honour the
		 * search, and falling through would hand back the entire customer list, so match nothing.
		 */
		if ( false === $terms || empty( $terms ) ) {
			$query->query_where .= ' AND 1 = 0';

			return;
		}

		$terms = array_slice( $terms, 0, 10 );

		$meta_keys = array_merge(
			array(
				'first_name',
				'last_name',
				'billing_first_name',
				'billing_last_name',
				'billing_email',
				'billing_company',
				'billing_phone',
			),
			Tax_Id_Reader::fallback_user_meta_keys()
		);

		$meta_key_placeholders = implode( ', ', array_fill( 0, \count( $meta_keys ), '%s' ) );
		$term_groups           = array();

		foreach ( $terms as $term ) {
			$like         = '%' . $wpdb->esc_like( $term ) . '%';
			$prepare_args = array_merge( array( $like, $like, $like ), $meta_keys, array( $like ) );

			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names come from $wpdb; $meta_key_placeholders is a generated list of %s placeholders, and the keys themselves are passed to prepare() as arguments.
			$term_groups[] = $wpdb->prepare(
				"( {$wpdb->users}.user_email LIKE %s
					OR {$wpdb->users}.user_login LIKE %s
					OR {$wpdb->users}.display_name LIKE %s
					OR EXISTS (
						SELECT 1
						FROM {$wpdb->usermeta} AS wcpos_search_meta
						WHERE wcpos_search_meta.user_id = {$wpdb->users}.ID
							AND wcpos_search_meta.meta_key IN ($meta_key_placeholders)
							AND wcpos_search_meta.meta_value LIKE %s
					)
				)",
				$prepare_args
			);
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		}

		$query->query_where .= ' AND ( ' . implode( ' AND ', $term_groups ) . ' )';
	}

	/**
	 * Include or exclude users by ID.
	 *
	 * @param WP_User_Query $query The WP_User_Query instance (passed by reference).
	 */
	public function wcpos_include_exclude_users_by_id( $query ): void {
		global $wpdb;

		// Remove the hook.
		remove_action( 'pre_user_query', array( $this, 'wcpos_include_exclude_users_by_id' ) );

		// Handle 'wcpos_include'.
		if ( ! empty( $this->wcpos_request['wcpos_include'] ) ) {
			$include_ids = array_map( 'intval', (array) $this->wcpos_request['wcpos_include'] );
			$ids_format  = implode( ',', array_fill( 0, \count( $include_ids ), '%d' ) );
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $ids_format is a safe placeholder string.
			$query->query_where .= $wpdb->prepare( " AND {$wpdb->users}.ID IN ($ids_format) ", $include_ids );
		}

		// Handle 'wcpos_exclude'.
		if ( ! empty( $this->wcpos_request['wcpos_exclude'] ) ) {
			$exclude_ids = array_map( 'intval', (array) $this->wcpos_request['wcpos_exclude'] );
			$ids_format  = implode( ',', array_fill( 0, \count( $exclude_ids ), '%d' ) );
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $ids_format is a safe placeholder string.
			$query->query_where .= $wpdb->prepare( " AND {$wpdb->users}.ID NOT IN ($ids_format) ", $exclude_ids );
		}
	}

	/**
	 * Order the customer query by role hierarchy.
	 *
	 * Roles are stored in the serialized `wp_capabilities` usermeta, which SQL
	 * cannot meaningfully ORDER BY (the value's leading `s:N:` length prefix, not
	 * the role name, dominates a string sort). Instead of extracting the slug we
	 * LEFT JOIN that meta row and rank it with a CASE ladder built from the known
	 * role hierarchy, matching each role by its quoted, serialization-safe slug
	 * (`"customer"`) so the length prefix is irrelevant.
	 *
	 * Design decisions:
	 * - Hierarchy, not alphabetical/label: the POS customer space is every WP
	 *   user (#1379) and is overwhelmingly `customer`. A cashier sorting by role
	 *   wants staff grouped and predictably separated from buyers, not scattered
	 *   across the alphabet, so we rank by privilege (administrator → subscriber),
	 *   unknown/custom/no-role users last.
	 * - Multi-role → highest privilege: a user who is both shop_manager and
	 *   customer is treated as staff. The ladder tests the most-privileged role
	 *   first, so the highest role a user holds fixes their position — which is
	 *   more correct than WP core's "first role in the array" (insertion order).
	 * - Ties (same rank) fall back to `user_login` for a deterministic order.
	 *
	 * @param WP_User_Query $query The WP_User_Query instance (passed by reference).
	 */
	public function wcpos_orderby_role( $query ): void {
		global $wpdb;

		// Remove the hook.
		remove_action( 'pre_user_query', array( $this, 'wcpos_orderby_role' ) );

		/*
		 * Only act on the customer query we prepared. WordPress fires
		 * pre_user_query for every WP_User_Query, and this callback can survive
		 * onto an unrelated one if our own query is short-circuited (eg. via the
		 * users_pre_query filter) before it runs. See wcpos_search_user_table.
		 */
		if ( empty( $query->query_vars['_wcpos_orderby_role'] ) ) {
			return;
		}

		$order = ( isset( $query->query_vars['order'] ) && 'DESC' === strtoupper( (string) $query->query_vars['order'] ) )
			? 'DESC'
			: 'ASC';

		// Role privilege hierarchy, highest first. Matched on the quoted slug so
		// the serialized length prefix (s:N:) never affects the comparison.
		// `cashier` is WCPOS's own registered POS-staff role (see Activator), so
		// it groups with staff — above the content roles — rather than falling
		// into the trailing bucket with customers/subscribers.
		$hierarchy = array(
			'administrator',
			'shop_manager',
			'cashier',
			'editor',
			'author',
			'contributor',
			'customer',
			'subscriber',
		);

		/*
		 * Capabilities meta is blog-prefixed (wp_capabilities on the main site,
		 * wp_<id>_capabilities on subsites), so resolve it via get_blog_prefix()
		 * rather than hardcoding — the POS customer space is the current site's
		 * users.
		 */
		$cap_key = $wpdb->get_blog_prefix() . 'capabilities';

		// LEFT JOIN so users with no capabilities row still sort (into the
		// trailing "everyone else" bucket). Each user has at most one such row,
		// so the join never multiplies rows or corrupts the total count.
		//
		// Guard against a duplicate alias: `woocommerce_rest_customer_query` can
		// carry more than one registered instance of this controller (each adds
		// its own pre_user_query action), so this callback may run several times
		// for one query. Appending the join unconditionally would emit two
		// `wcpos_role_meta` aliases and fail with "Not unique table/alias".
		if ( false === strpos( $query->query_from, 'wcpos_role_meta' ) ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names come from $wpdb; the meta key is passed to prepare() as %s.
			$query->query_from .= $wpdb->prepare(
				" LEFT JOIN {$wpdb->usermeta} AS wcpos_role_meta ON ( {$wpdb->users}.ID = wcpos_role_meta.user_id AND wcpos_role_meta.meta_key = %s )",
				$cap_key
			);
		}

		$when_sql      = '';
		$when_args     = array();
		$rank          = 1;
		foreach ( $hierarchy as $role_slug ) {
			$when_sql   .= ' WHEN wcpos_role_meta.meta_value LIKE %s THEN ' . $rank;
			$when_args[] = '%' . $wpdb->esc_like( '"' . $role_slug . '"' ) . '%';
			++$rank;
		}
		// $rank is now the "everyone else" bucket (unknown/custom/no role).
		$case_sql = "CASE{$when_sql} ELSE {$rank} END";

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $case_sql is built from a static role whitelist; the only variables are %s LIKE placeholders passed to prepare().
		$order_by = $wpdb->prepare( $case_sql, $when_args );

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_by is prepared above; $order is a validated ASC|DESC literal; the table/column come from $wpdb.
		$query->query_orderby = "ORDER BY ( {$order_by} ) {$order}, {$wpdb->users}.user_login ASC";
	}

	/**
	 * Allow the _woocommerce_pos_uuid meta key to be saved via the REST API.
	 *
	 * By default, meta keys starting with '_' are protected and cannot be set via the REST API.
	 * We need to allow our UUID meta key so that UUIDs sent from the POS app are preserved.
	 *
	 * @param bool   $protected Whether the meta key is considered protected.
	 * @param string $meta_key  The meta key being checked.
	 * @param string $meta_type The type of object the meta is for (post, user, etc.).
	 *
	 * @return bool Whether the meta key should be protected.
	 */
	public function wcpos_allow_uuid_meta( $protected, $meta_key, $meta_type ) {
		if ( '_woocommerce_pos_uuid' === $meta_key && 'user' === $meta_type ) {
			return false;
		}

		return $protected;
	}
}

```
