WooCommerce Code Reference

CheckoutFields.php

Source code

<?php
declare( strict_types = 1);

namespace Automattic\WooCommerce\Blocks\Domain\Services;

use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry;
use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsSchema\{
	DocumentObject, Validation
};
use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldTypes\{
	AbstractFieldType, CheckboxFieldType, DateFieldType, SelectFieldType, TextFieldType
};
use WC_Customer;
use WC_Data;
use WC_Order;
use WP_Error;

/**
 * Service class managing checkout fields and its related extensibility points.
 */
class CheckoutFields {

	use CheckoutFieldsStorage;

	/**
	 * Additional checkout fields.
	 *
	 * @var array
	 */
	private $additional_fields = [];

	/**
	 * Fields locations.
	 *
	 * @var array
	 */
	private $fields_locations;

	/**
	 * Supported field types, keyed by their type slug.
	 *
	 * @var array<string, AbstractFieldType>
	 */
	private $field_types = [];

	/**
	 * Groups of fields to be saved.
	 *
	 * @var array
	 */
	private $groups = [ 'billing', 'shipping', 'other' ];

	/**
	 * Instance of the asset data registry.
	 *
	 * @var AssetDataRegistry
	 */
	private $asset_data_registry;

	/**
	 * Billing fields meta key.
	 *
	 * @var string
	 */
	const BILLING_FIELDS_PREFIX = '_wc_billing/';

	/**
	 * Shipping fields meta key.
	 *
	 * @var string
	 */
	const SHIPPING_FIELDS_PREFIX = '_wc_shipping/';

	/**
	 * Additional fields meta key.
	 *
	 * @var string
	 * @deprecated 8.9.0 Use OTHER_FIELDS_PREFIX instead.
	 */
	const ADDITIONAL_FIELDS_PREFIX = '_wc_additional/';

	/**
	 * Other fields meta key.
	 *
	 * @var string
	 */
	const OTHER_FIELDS_PREFIX = '_wc_other/';

	/**
	 * Sets up core fields.
	 *
	 * @param AssetDataRegistry $asset_data_registry Instance of the asset data registry.
	 */
	public function __construct( AssetDataRegistry $asset_data_registry ) {
		$this->asset_data_registry = $asset_data_registry;

		$this->field_types = [
			'text'     => new TextFieldType(),
			'select'   => new SelectFieldType(),
			'checkbox' => new CheckboxFieldType(),
			'date'     => new DateFieldType(),
		];

		$this->fields_locations = [
			// omit email from shipping and billing fields.
			'address' => array_merge( \array_diff_key( $this->get_core_fields_keys(), array( 'email' ) ) ),
			'contact' => array( 'email' ),
			'order'   => [],
		];
	}

	/**
	 * Initialize hooks.
	 */
	public function init() {
		add_filter( 'woocommerce_get_country_locale_default', array( $this, 'update_default_locale_with_fields' ) );
		add_action( 'woocommerce_blocks_checkout_enqueue_data', array( $this, 'add_fields_data' ) );
		add_action( 'woocommerce_blocks_cart_enqueue_data', array( $this, 'add_fields_data' ) );
		add_filter( 'woocommerce_customer_allowed_session_meta_keys', array( $this, 'add_session_meta_keys' ) );
	}

	/**
	 * Add fields data to the asset data registry.
	 */
	public function add_fields_data() {
		$this->asset_data_registry->add( 'defaultFields', array_merge( $this->get_core_fields(), $this->get_additional_fields() ) );
		$this->asset_data_registry->add( 'addressFieldsLocations', $this->fields_locations );
	}

	/**
	 * Add session meta keys.
	 *
	 * This is an allow-list of meta data keys which we want to store in session.
	 *
	 * @param array $keys Session meta keys.
	 * @return array
	 */
	public function add_session_meta_keys( $keys ) {
		$meta_keys = array();
		try {
			foreach ( $this->get_additional_fields() as $field_key => $field ) {
				if ( 'address' === $field['location'] ) {
					$meta_keys[] = self::BILLING_FIELDS_PREFIX . $field_key;
					$meta_keys[] = self::SHIPPING_FIELDS_PREFIX . $field_key;
				} else {
					$meta_keys[] = self::OTHER_FIELDS_PREFIX . $field_key;
				}
			}
		} catch ( \Throwable $e ) {
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
			trigger_error(
				sprintf(
					'Error adding session meta keys for checkout fields. %s',
					esc_attr( $e->getMessage() )
				),
				E_USER_WARNING
			);

			return $keys;
		}

		return array_merge( $keys, $meta_keys );
	}

	/**
	 * If a field does not declare a sanitization callback, this is the default sanitization callback.
	 *
	 * @deprecated 11.2.0 Fields are wired to their field type's sanitize() method instead.
	 *
	 * @param mixed $value Value to sanitize.
	 * @param array $field Field data.
	 * @return mixed
	 */
	public function default_sanitize_callback( $value, $field ) {
		return $this->get_field_type( $field )->sanitize( $value, $field );
	}

	/**
	 * Returns the field type handling a field, falling back to text for core and unknown types.
	 *
	 * @param array $field The field, or the options supplied during field registration.
	 * @return AbstractFieldType
	 */
	private function get_field_type( array $field ): AbstractFieldType {
		return $this->field_types[ $field['type'] ?? '' ] ?? $this->field_types['text'];
	}

	/**
	 * If a field does not declare a validation callback, this is the default validation callback.
	 *
	 * @deprecated 11.2.0 Fields are wired to their field type's default_validate() method instead.
	 *
	 * @param mixed $value Value to sanitize.
	 * @param array $field Field data.
	 * @return WP_Error|void If there is a validation error, return an WP_Error object.
	 */
	public function default_validate_callback( $value, $field ) {
		return $this->get_field_type( $field )->default_validate( $value, $field );
	}

	/**
	 * Registers an additional field for Checkout.
	 *
	 * @param array $options The field options.
	 *
	 * @return WP_Error|void True if the field was registered, a WP_Error otherwise.
	 */
	public function register_checkout_field( $options ) {
		// Warn when fields are registered before `after_setup_theme`. Registering that early can cause problems, such as loading translations before they're ready.
		if ( ! did_action( 'after_setup_theme' ) && ! doing_action( 'after_setup_theme' ) ) {
			_doing_it_wrong( 'woocommerce_register_additional_checkout_field', 'Additional checkout fields should be registered on the woocommerce_init action or later.', '11.0.0' );
		}

		// Check the options and show warnings if they're not supplied. Return early if an error that would prevent registration is encountered.
		if ( false === $this->validate_options( $options ) ) {
			return;
		}

		$field_type = $this->get_field_type( $options );

		// The above validate_options function ensures these options are valid. Type might not be supplied but then it defaults to text.
		$field_data = wp_parse_args(
			$options,
			[
				'id'                         => '',
				'label'                      => '',
				/* translators: %s Field label. */
				'optionalLabel'              => sprintf( __( '%s (optional)', 'woocommerce' ), $options['label'] ),
				'location'                   => '',
				'type'                       => 'text',
				'hidden'                     => false,
				'required'                   => false,
				'attributes'                 => [],
				'show_in_order_confirmation' => true,
				'sanitize_callback'          => array( $field_type, 'sanitize' ),
				'validate_callback'          => array( $field_type, 'default_validate' ),
				'validation'                 => [],
			],
		);

		$field_data = $this->process_field_options( $field_data, $options );

		// $field_data will be false if an error that will prevent the field being registered is encountered.
		if ( false === $field_data ) {
			return;
		}

		// Insert new field into the correct location array.
		$this->additional_fields[ $field_data['id'] ]        = $field_data;
		$this->fields_locations[ $field_data['location'] ][] = $field_data['id'];
	}

	/**
	 * Returns true if the field is required. Takes rules into consideration if a document object is provided.
	 *
	 * @param array|string        $field The field array or field key.
	 * @param DocumentObject|null $document_object The document object.
	 * @return bool
	 */
	public function is_required_field( $field, $document_object = null ) {
		if ( is_string( $field ) ) {
			$field = $this->additional_fields[ $field ] ?? [];
		}

		if ( empty( $field ) ) {
			return false;
		}

		if ( $document_object ) {
			// Hidden fields cannot be required.
			if ( $this->is_hidden_field( $field, $document_object ) ) {
				return false;
			}
			if ( $this->contains_valid_rules( $field['required'] ) ) {
				return true === Validation::validate_document_object( $document_object, $field['required'] );
			}
		}
		return true === $field['required'];
	}

	/**
	 * Returns true if the field is hidden. Takes rules into consideration if a document object is provided.
	 *
	 * @param array|string        $field The field array or field key.
	 * @param DocumentObject|null $document_object The document object.
	 * @return bool
	 */
	public function is_hidden_field( $field, $document_object = null ) {
		if ( is_string( $field ) ) {
			$field = $this->additional_fields[ $field ] ?? [];
		}
		if ( $document_object && $this->contains_valid_rules( $field['hidden'] ) ) {
			return true === Validation::validate_document_object( $document_object, $field['hidden'] );
		}
		return false; // Fields cannot be registered as hidden.
	}

	/**
	 * Returns true if the field is conditionally required or rendered.
	 *
	 * @param array|string $field The field array or field key.
	 * @return bool
	 */
	public function is_conditional_field( $field ) {
		if ( is_string( $field ) ) {
			$field = $this->additional_fields[ $field ] ?? [];
		}
		return $this->contains_valid_rules( $field['required'] ) || $this->contains_valid_rules( $field['hidden'] );
	}

	/**
	 * Validates a field against the given document object and context.
	 *
	 * @param array               $field The field.
	 * @param DocumentObject|null $document_object The document object.
	 * @return bool|\WP_Error True if the field is valid, a WP_Error otherwise.
	 */
	public function is_valid_field( $field, $document_object = null ) {
		if ( $document_object && $this->contains_valid_rules( $field['validation'] ) ) {
			$field_schema = Validation::get_field_schema_with_context( $field['id'], $field['validation'], $document_object->get_context() );
			return Validation::validate_document_object( $document_object, $field_schema );
		}
		return true;
	}

	/**
	 * Returns true if the property is an array and not empty.
	 *
	 * @param mixed $property The property to check.
	 * @return bool
	 */
	protected function contains_valid_rules( $property ) {
		return is_array( $property ) && ! empty( $property );
	}

	/**
	 * Returns the validate callback for a given field.
	 *
	 * @param array               $field The field.
	 * @param DocumentObject|null $document_object The document object.
	 * @return callable The validate callback.
	 */
	public function get_validate_callback( $field, $document_object = null ) {
		if ( is_string( $field ) ) {
			$field = $this->additional_fields[ $field ] ?? [];
		}
		if ( $document_object && $this->contains_valid_rules( $field['validation'] ) ) {
			return function ( $field_value, $field ) use ( $document_object ) {
				$errors = new WP_Error();

				// Only validate if we have a field.
				if ( ! $field ) {
					return true;
				}

				// Evaluate custom validation schema rules on the field.
				$validate_result = $this->is_valid_field( $field, $document_object );

				if ( is_wp_error( $validate_result ) ) {
					/* translators: %s: is the field label */
					$error_message = sprintf( __( 'Please provide a valid %s', 'woocommerce' ), $field['label'] );
					$error_code    = 'woocommerce_invalid_checkout_field';
					$errors->add( $error_code, $error_message );
				}

				return $errors->has_errors() ? $errors : true;
			};
		}
		return $field['validate_callback'] ?? null;
	}

	/**
	 * Deregister a checkout field.
	 *
	 * @param string $field_id The field ID.
	 *
	 * @internal
	 */
	public function deregister_checkout_field( $field_id ) {
		if ( empty( $this->additional_fields[ $field_id ] ) ) {
			return;
		}

		$location = $this->get_field_location( $field_id );

		if ( ! $location ) {
			return;
		}

		// Remove the field from the fields_locations array.
		$this->fields_locations[ $location ] = array_diff( $this->fields_locations[ $location ], array( $field_id ) );

		// Remove the field from the additional_fields array.
		unset( $this->additional_fields[ $field_id ] );
	}

	/**
	 * Validates the "base" options (id, label, location) and shows warnings if they're not supplied.
	 *
	 * @param array $options The options supplied during field registration.
	 * @return bool false if an error was encountered, true otherwise.
	 */
	private function validate_options( &$options ) {
		if ( empty( $options['id'] ) ) {
			_doing_it_wrong( 'woocommerce_register_additional_checkout_field', 'A checkout field cannot be registered without an id.', '8.6.0' );
			return false;
		}

		// Having fewer than 2 after exploding around a / means there is no namespace.
		if ( count( explode( '/', $options['id'] ) ) < 2 ) {
			$message = sprintf( 'Unable to register field with id: "%s". %s', $options['id'], 'A checkout field id must consist of namespace/name.' );
			_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
			return false;
		}

		if ( empty( $options['location'] ) ) {
			$message = sprintf( 'Unable to register field with id: "%s". %s', $options['id'], 'The field location is required.' );
			_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
			return false;
		}

		if ( 'additional' === $options['location'] ) {
			wc_deprecated_argument( 'location', '8.9.0', 'The "additional" location is deprecated. Use "order" instead.' );
			$options['location'] = 'order';
		}

		if ( ! in_array( $options['location'], array_keys( $this->fields_locations ), true ) ) {
			$message = sprintf( 'Unable to register field with id: "%s". %s', $options['id'], 'The field location is invalid.' );
			_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
			return false;
		}

		// At this point, the essentials fields and its location should be set and valid.
		$location = $options['location'];
		$id       = $options['id'];

		// Check to see if field is already in the array.
		if ( ! empty( $this->additional_fields[ $id ] ) || in_array( $id, $this->fields_locations[ $location ], true ) ) {
			$message = sprintf( 'Unable to register field with id: "%s". %s', $id, 'The field is already registered.' );
			_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
			return false;
		}

		if ( ! empty( $options['type'] ) && ! isset( $this->field_types[ $options['type'] ] ) ) {
			$message = sprintf(
				'Unable to register field with id: "%s". Registering a field with type "%s" is not supported. The supported types are: %s.',
				$id,
				$options['type'],
				implode( ', ', array_keys( $this->field_types ) )
			);
			_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
			return false;
		}

		$field_type = $this->get_field_type( $options );
		if ( isset( $options['validation'] ) && is_array( $options['validation'] ) ) {
			$options['validation'] = $field_type->prepare_validation_schema( $options['validation'] );
		}

		return $field_type->validate_options( $options );
	}

	/**
	 * Processes the options for a field type and returns the new field_options array.
	 *
	 * @param array $field_data The field data array to be updated.
	 * @param array $options    The options supplied during field registration.
	 * @return array|false The updated $field_data array, or false if an error should prevent registration.
	 */
	private function process_field_options( $field_data, $options ) {
		return $this->get_field_type( $field_data )->process_options( $field_data, $options );
	}

	/**
	 * Returns the keys of all core fields.
	 *
	 * @return array An array of field keys.
	 */
	public function get_core_fields_keys() {
		return CoreCheckoutFields::get_keys();
	}

	/**
	 * Returns an array of all core fields.
	 *
	 * @return array An array of fields.
	 */
	public function get_core_fields() {
		return CoreCheckoutFields::get_fields();
	}

	/**
	 * Returns an array of all additional fields.
	 *
	 * @return array An array of fields.
	 */
	public function get_additional_fields() {
		return $this->additional_fields;
	}

	/**
	 * Gets the location of a field.
	 *
	 * @param string $field_key The key of the field to get the location for.
	 * @return string The location of the field.
	 */
	public function get_field_location( $field_key ) {
		if ( ! $this->is_field( $field_key ) ) {
			return '';
		}
		foreach ( $this->fields_locations as $location => $fields ) {
			if ( in_array( $field_key, $fields, true ) ) {
				return $location;
			}
		}
		return '';
	}

	/**
	 * Sanitize an additional field against any custom sanitization rules.
	 *
	 * @since 8.7.0

	 * @param string $field_key   The key of the field.
	 * @param mixed  $field_value The value of the field.
	 * @return mixed
	 */
	public function sanitize_field( $field_key, $field_value ) {
		try {
			$field = $this->additional_fields[ $field_key ] ?? null;

			if ( $field ) {
				$field_value = call_user_func( $field['sanitize_callback'], $field_value, $field );
			}

			/**
			 * Allow custom sanitization of an additional field.
			 *
			 * @param mixed  $field_value The value of the field being sanitized.
			 * @param string $field_key   Key of the field being sanitized.
			 *
			 * @since 8.6.0
			 * @deprecated 8.7.0 Use woocommerce_sanitize_additional_field instead.
			 */
			$field_value = apply_filters_deprecated( '__experimental_woocommerce_blocks_sanitize_additional_field', array( $field_value, $field_key ), '8.7.0', 'woocommerce_sanitize_additional_field', 'This action has been graduated, use woocommerce_sanitize_additional_field instead.' );

			/**
			 * Allow custom sanitization of an additional field.
			 *
			 * @param mixed  $field_value The value of the field being sanitized.
			 * @param string $field_key   Key of the field being sanitized.
			 *
			 * @since 8.7.0
			 */
			return apply_filters( 'woocommerce_sanitize_additional_field', $field_value, $field_key );

		} catch ( \Throwable $e ) {
			// One of the filters errored so skip it. This allows the checkout process to continue.
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
			trigger_error(
				sprintf(
					'Field sanitization for %s encountered an error. %s',
					esc_html( $field_key ),
					esc_html( $e->getMessage() )
				),
				E_USER_WARNING
			);
		}

		return $field_value;
	}

	/**
	 * Validate an additional field.
	 *
	 * @since 8.6.0
	 *
	 * @param array $field        The field.
	 * @param mixed $field_value  The value of the field.
	 * @return WP_Error
	 */
	public function validate_field( $field, $field_value ) {
		$errors = new WP_Error();

		try {
			// Only validate if we have a field.
			if ( ! $field ) {
				return $errors;
			}

			// Type level constraints run for every field regardless of the validate_callback it was
			// registered with, so they cannot be bypassed by supplying a custom callback.
			$type_error = $this->get_field_type( $field )->validate( $field_value, $field );

			if ( is_wp_error( $type_error ) ) {
				$errors->merge_from( $type_error );
			}

			if ( ! empty( $field['validate_callback'] ) && is_callable( $field['validate_callback'] ) ) {
				$validate_callback_result = call_user_func( $field['validate_callback'], $field_value, $field );

				if ( is_wp_error( $validate_callback_result ) ) {
					$errors->merge_from( $validate_callback_result );
				} elseif ( false === $validate_callback_result ) {
					/* translators: %s: is the field label */
					$error_message = sprintf( __( 'Please provide a valid %s', 'woocommerce' ), $field['label'] );
					$errors->add( 'woocommerce_invalid_checkout_field', $error_message );
				}
			}

			wc_do_deprecated_action( '__experimental_woocommerce_blocks_validate_additional_field', array( $errors, $field['id'], $field_value ), '8.7.0', 'woocommerce_validate_additional_field', 'This action has been graduated, use woocommerce_validate_additional_field instead.' );

			/**
			 * Pass an error object to allow validation of an additional field.
			 *
			 * @param WP_Error $errors      A WP_Error object that extensions may add errors to.
			 * @param string   $field_key   Key of the field being sanitized.
			 * @param mixed    $field_value The value of the field being validated.
			 *
			 * @since 8.7.0
			 */
			do_action( 'woocommerce_validate_additional_field', $errors, $field['id'], $field_value );

		} catch ( \Throwable $e ) {

			// One of the filters errored so skip them and validate the field. This allows the checkout process to continue.
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
			trigger_error(
				sprintf(
					'Field validation for %s encountered an error. %s',
					esc_html( $field['id'] ),
					esc_html( $e->getMessage() )
				),
				E_USER_WARNING
			);
		}

		return $errors;
	}

	/**
	 * Update the default locale with additional fields without country limitations.
	 *
	 * @param array $locale The locale to update.
	 * @return mixed
	 */
	public function update_default_locale_with_fields( $locale ) {
		foreach ( $this->get_fields_for_location( 'address' ) as $field_key => $field ) {
			if ( empty( $locale[ $field_key ] ) ) {
				// If the field has conditional rules, we need to set the required property to false so it can be evaluated.
				if ( $this->is_conditional_field( $field_key ) ) {
					$field['required'] = false;
				}
				$locale[ $field_key ] = $field;
			}
		}
		return $locale;
	}

	/**
	 * Returns an array of fields keys for the address location.
	 *
	 * @return array An array of fields keys.
	 */
	public function get_address_fields_keys() {
		return $this->fields_locations['address'];
	}

	/**
	 * Returns an array of fields keys for the contact location.
	 *
	 * @return array An array of fields keys.
	 */
	public function get_contact_fields_keys() {
		return $this->fields_locations['contact'];
	}

	/**
	 * Returns an array of fields keys for the additional area location.
	 *
	 * @return array An array of fields keys.
	 * @deprecated 8.9.0 Use get_order_fields_keys instead.
	 */
	public function get_additional_fields_keys() {
		wc_deprecated_function( __METHOD__, '8.9.0', 'get_order_fields_keys' );
		return $this->get_order_fields_keys();
	}

	/**
	 * Returns an array of fields keys for the additional area group.
	 *
	 * @return array An array of fields keys.
	 */
	public function get_order_fields_keys() {
		return $this->fields_locations['order'];
	}

	/**
	 * Returns an array of fields for a given location.
	 *
	 * @param string $location The location to get fields for (address|contact|order).
	 * @return array An array of fields definitions.
	 */
	public function get_fields_for_location( $location ) {
		$location = $this->prepare_location_name( $location );

		if ( in_array( $location, array_keys( $this->fields_locations ), true ) ) {
			$order_fields_keys = $this->fields_locations[ $location ];

			return array_filter(
				$this->get_additional_fields(),
				function ( $key ) use ( $order_fields_keys ) {
					return in_array( $key, $order_fields_keys, true );
				},
				ARRAY_FILTER_USE_KEY
			);
		}
		return [];
	}

	/**
	 * Returns an array of fields for a given location and uses context to evaluate hidden and required fields.
	 *
	 * @param string              $location The location to get fields for (address|contact|order).
	 * @param DocumentObject|null $document_object The document object.
	 * @return array An array of fields definitions.
	 */
	public function get_contextual_fields_for_location( $location, $document_object = null ) {
		$location_fields = $this->get_fields_for_location( $location );
		$fields          = [];
		foreach ( $location_fields as $key => $field ) {
			if ( $this->is_hidden_field( $key, $document_object ) ) {
				continue;
			}
			$field['required']          = $this->is_required_field( $field, $document_object );
			$field['validate_callback'] = $this->get_validate_callback( $field, $document_object );
			$fields[ $key ]             = $field;
		}

		return $fields;
	}

	/**
	 * Validates a set of fields for a given location against custom validation rules.
	 *
	 * @param array  $fields Array of key value pairs of field values to validate.
	 * @param string $location The location being validated (address|contact|order).
	 * @param string $group The group to get the field value for (shipping|billing|other).
	 * @return WP_Error
	 */
	public function validate_fields_for_location( $fields, $location, $group = 'other' ) {
		$errors   = new WP_Error();
		$location = $this->prepare_location_name( $location );
		$group    = $this->prepare_group_name( $group );

		try {
			wc_do_deprecated_action( '__experimental_woocommerce_blocks_validate_location_' . $location . '_fields', array( $errors, $fields, $group ), '8.9.0', 'woocommerce_blocks_validate_location_' . $location . '_fields', 'This action has been graduated, use woocommerce_blocks_validate_location_' . $location . '_fields instead.' );

			/**
			 * Pass an error object to allow validation of an additional field.
			 *
			 * @param WP_Error $errors  A WP_Error object that extensions may add errors to.
			 * @param mixed    $fields  List of fields (key value pairs) in this location.
			 * @param string   $group   The group of this location (shipping|billing|other).
			 *
			 * @since 8.7.0
			 */
			do_action( 'woocommerce_blocks_validate_location_' . $location . '_fields', $errors, $fields, $group );

		} catch ( \Throwable $e ) {

			// One of the filters errored so skip them. This allows the checkout process to continue.
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
			trigger_error(
				sprintf(
					'The action %s encountered an error. The field location %s may not have any custom validation applied to it. %s',
					esc_html( 'woocommerce_blocks_validate_' . $location . '_fields' ),
					esc_html( $location ),
					esc_html( $e->getMessage() )
				),
				E_USER_WARNING
			);
		}

		return $errors;
	}

	/**
	 * Validates a field to check it belongs to the given location and is valid according to its registration.
	 *
	 * This does not apply any custom validation rules on the value.
	 *
	 * @param string $key The field key.
	 * @param mixed  $value The field value.
	 * @param string $location The location to validate the field for (address|contact|order).
	 *
	 * @return true|WP_Error True if the field is valid, a WP_Error otherwise.
	 */
	public function validate_field_for_location( $key, $value, $location ) {
		$location = $this->prepare_location_name( $location );

		if ( ! $this->is_field( $key ) ) {
			return new WP_Error(
				'woocommerce_invalid_checkout_field',
				\sprintf(
				// translators: % is field key.
					__( 'The field %s is invalid.', 'woocommerce' ),
					$key
				)
			);
		}

		if ( ! in_array( $key, $this->fields_locations[ $location ], true ) ) {
			return new WP_Error(
				'woocommerce_invalid_checkout_field_location',
				\sprintf(
				// translators: %1$s is field key, %2$s location.
					__( 'The field %1$s is invalid for the location %2$s.', 'woocommerce' ),
					$key,
					$location
				)
			);
		}

		return true;
	}

	/**
	 * Returns all fields key for a given group.
	 *
	 * @param string $group The group to get the key for (shipping|billing|other).
	 *
	 * @return string[] Field keys.
	 */
	public function get_fields_for_group( $group = 'other' ) {
		$group = $this->prepare_group_name( $group );
		if ( 'shipping' === $group || 'billing' === $group ) {
			return $this->get_fields_for_location( 'address' );
		}
		return \array_merge(
			$this->get_fields_for_location( 'contact' ),
			$this->get_fields_for_location( 'order' )
		);
	}

	/**
	 * Returns true if the given key is a valid field.
	 *
	 * @param string $key The field key.
	 *
	 * @return bool True if the field is valid, false otherwise.
	 */
	public function is_field( $key ) {
		return array_key_exists( $key, $this->additional_fields );
	}

	/**
	 * Returns true if the given key is a valid customer field.
	 *
	 * Customer fields are fields saved to the customer data, like address and contact fields.
	 *
	 * @param string $key The field key.
	 *
	 * @return bool True if the field is valid, false otherwise.
	 */
	public function is_customer_field( $key ) {
		return in_array( $key, array_intersect( array_merge( $this->get_address_fields_keys(), $this->get_contact_fields_keys() ), array_keys( $this->additional_fields ) ), true );
	}

	/**
	 * From a set of fields, returns only the ones for a given location.
	 *
	 * @param array  $fields The fields to filter.
	 * @param string $location The location to validate the field for (address|contact|order).
	 * @return array The filtered fields.
	 */
	public function filter_fields_for_location( array $fields, string $location ) {
		$location = $this->prepare_location_name( $location );

		return array_filter(
			$fields,
			function ( $key ) use ( $location ) {
				return $this->get_field_location( $key ) === $location;
			},
			ARRAY_FILTER_USE_KEY
		);
	}

	/**
	 * Filter fields for order confirmation.
	 *
	 * @param array $fields  The fields to filter.
	 * @param array $context Additional context for the filter.
	 * @return array The filtered fields.
	 */
	public function filter_fields_for_order_confirmation( $fields, $context = array() ) {
		return array_filter(
			$fields,
			function ( $field ) use ( $fields, $context ) {
				/**
				 * Filter fields for order confirmation (thank you page, email).
				 *
				 * Used in methods:
				 * WC_Email::additional_checkout_fields
				 * WC_Email::additional_address_fields
				 * CheckoutFieldsFrontend::render_order_other_fields
				 * CheckoutFieldsFrontend::render_order_address_fields
				 * AdditionalFields::render_content
				 * BillingAddress::render_content
				 * ShippingAddress::render_content
				 *
				 * @param bool           $show_field Whether the field should be shown.
				 * @param array          $field      Field data.
				 * @param array          $fields     All fields for better context when field should be shown or hidden based on other fields values.
				 * @param array          $context    Additional context for the filter. Data depends in which method filter_fields_for_order_confirmation is called.
				 * @param CheckoutFields $instance   The CheckoutFields instance.
				 * @since 10.1.0
				 */
				return apply_filters( 'woocommerce_filter_fields_for_order_confirmation', ! empty( $field['show_in_order_confirmation'] ), $field, $fields, $context, $this );
			}
		);
	}

	/**
	 * Get additional fields for an order.
	 *
	 * @param WC_Order $order Order object.
	 * @param string   $location The location to get fields for (address|contact|order).
	 * @param string   $group The group to get the field value for (shipping|billing|other).
	 * @param string   $context The context to get the field value for (edit|view).
	 * @return array An array of fields definitions as well as their values formatted for display.
	 */
	public function get_order_additional_fields_with_values( WC_Order $order, string $location, string $group = 'other', string $context = 'edit' ) {

		// Because the Additional Checkout Fields API only applies to orders created with Store API, we should not
		// return any values unless it was created using Store API. This is mainly to prevent "empty" checkbox values
		// from being shown on the order confirmation page for orders placed using the shortcode. It's rare that this
		// will happen but not impossible.
		if ( 'store-api' !== $order->get_created_via() ) {
			return [];
		}

		$location           = $this->prepare_location_name( $location );
		$group              = $this->prepare_group_name( $group );
		$fields             = $this->get_fields_for_location( $location );
		$fields_with_values = [];

		foreach ( $fields as $field_key => $field ) {
			$value = $this->get_field_from_object( $field_key, $order, $group );

			if ( '' === $value || null === $value ) {
				continue;
			}

			if ( 'view' === $context ) {
				$value = $this->format_additional_field_value( $value, $field );
			}

			$field['value']                   = $value;
			$fields_with_values[ $field_key ] = $field;
		}

		return $fields_with_values;
	}

	/**
	 * Formats a raw field value for display based on its type definition.
	 *
	 * @param string $value Value to format.
	 * @param array  $field Additional field definition.
	 * @return string
	 */
	public function format_additional_field_value( $value, $field ) {
		return $this->get_field_type( $field )->format_value( $value, $field );
	}

	/**
	 * Applies type-specific arguments to a field before it is rendered with woocommerce_form_field().
	 *
	 * Used by the server-rendered My Account forms: maps select options, sets checkbox submit values, and
	 * resolves date min/max constraints into input attributes.
	 *
	 * @param array $form_field The woocommerce_form_field() arguments built from the field.
	 * @return array The updated arguments.
	 */
	public function prepare_form_field( array $form_field ): array {
		return $this->get_field_type( $form_field )->prepare_form_field( $form_field );
	}

	/**
	 * Applies type-specific keywords to a field's REST API value schema.
	 *
	 * @param array $field_schema The schema built for the field so far.
	 * @param array $field        The field.
	 * @return array The updated schema.
	 */
	public function prepare_field_value_schema( array $field_schema, array $field ): array {
		return $this->get_field_type( $field )->prepare_value_schema( $field_schema, $field );
	}

	/**
	 * Prepares a group name for use.
	 *
	 * @param string $group The group name to prepare.
	 * @return string The prepared group name.
	 */
	private function prepare_group_name( $group ) {
		if ( ! in_array( $group, $this->groups, true ) ) {
			$group = 'other';
		}
		return $group;
	}

	/**
	 * Prepares a location name for use.
	 *
	 * @param string $location The location name to prepare.
	 * @return string The prepared location name.
	 */
	private function prepare_location_name( $location ) {
		if ( 'additional' === $location ) {
			$location = 'order';
		}
		return $location;
	}

	/**
	 * Returns a group meta prefix based on its name.
	 *
	 * @param string $group_name The group name (billing|shipping|other).
	 * @return string The group meta prefix.
	 */
	public static function get_group_key( $group_name ) {
		if ( 'additional' === $group_name ) {
			wc_deprecated_argument( 'group_name', '8.9.0', 'The "additional" group is deprecated. Use "other" instead.' );
			$group_name = 'other';
		}
		if ( 'billing' === $group_name ) {
			return self::BILLING_FIELDS_PREFIX;
		}
		if ( 'shipping' === $group_name ) {
			return self::SHIPPING_FIELDS_PREFIX;
		}
		return self::OTHER_FIELDS_PREFIX;
	}

	/**
	 * Returns a group name based on passed group key.
	 *
	 * @param string $group_key The group name (_wc_billing|_wc_shipping|_wc_other).
	 * @return string The group meta prefix.
	 */
	public static function get_group_name( $group_key ) {
		if ( '_wc_additional' === $group_key ) {
			wc_deprecated_argument( 'group_key', '8.9.0', 'The "_wc_additional" group key is deprecated. Use "_wc_other" instead.' );
			$group_key = '_wc_other';
		}
		if ( 0 === \strpos( self::BILLING_FIELDS_PREFIX, $group_key ) ) {
			return 'billing';
		}
		if ( 0 === \strpos( self::SHIPPING_FIELDS_PREFIX, $group_key ) ) {
			return 'shipping';
		}
		return 'other';
	}
}