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
* Service class managing checkout fields and its related extensibility points.
* Additional checkout fields.
private $additional_fields = [];
private $fields_locations;
private $supported_field_types = [ 'text', 'select', 'checkbox' ];
* Groups of fields to be saved.
private $groups = [ 'billing', 'shipping', 'other' ];
* Instance of the asset data registry.
private $asset_data_registry;
* Billing fields meta key.
const BILLING_FIELDS_PREFIX = '_wc_billing/';
* Shipping fields meta key.
const SHIPPING_FIELDS_PREFIX = '_wc_shipping/';
* Additional fields meta key.
* @deprecated 8.9.0 Use OTHER_FIELDS_PREFIX instead.
const ADDITIONAL_FIELDS_PREFIX = '_wc_additional/';
const OTHER_FIELDS_PREFIX = '_wc_other/';
* @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->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' ),
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 );
* This is an allow-list of meta data keys which we want to store in session.
* @param array $keys Session meta keys.
public function add_session_meta_keys( $keys ) {
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;
$meta_keys[] = self::OTHER_FIELDS_PREFIX . $field_key;
} catch ( \Throwable $e ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
'Error adding session meta keys for checkout fields. %s',
esc_attr( $e->getMessage() )
return array_merge( $keys, $meta_keys );
* If a field does not declare a sanitization callback, this is the default sanitization callback.
* @param mixed $value Value to sanitize.
* @param array $field Field data.
public function default_sanitize_callback( $value, $field ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
* If a field does not declare a validation callback, this is the default validation callback.
* @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 ) {
if ( true === $field['required'] && empty( $value ) ) {
'woocommerce_required_checkout_field',
// translators: %s is field key.
__( 'The field %s is required.', 'woocommerce' ),
* 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 ) {
// 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 ) ) {
// 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(
/* translators: %s Field label. */
'optionalLabel' => sprintf( __( '%s (optional)', 'woocommerce' ), $options['label'] ),
'show_in_order_confirmation' => true,
'sanitize_callback' => array( $this, 'default_sanitize_callback' ),
'validate_callback' => array( $this, 'default_validate_callback' ),
$field_data['attributes'] = $this->register_field_attributes( $field_data['id'], $field_data['attributes'] );
$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 ) {
// 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.
public function is_required_field( $field, $document_object = null ) {
if ( is_string( $field ) ) {
$field = $this->additional_fields[ $field ] ?? [];
if ( $document_object ) {
// Hidden fields cannot be required.
if ( $this->is_hidden_field( $field, $document_object ) ) {
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.
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.
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 );
* Returns true if the property is an array and not empty.
* @param mixed $property The property to check.
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.
// 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.
public function deregister_checkout_field( $field_id ) {
if ( empty( $this->additional_fields[ $field_id ] ) ) {
$location = $this->get_field_location( $field_id );
// 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' );
// 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' );
if ( empty( $options['label'] ) ) {
$message = sprintf( 'Unable to register field with id: "%s". %s', $options['id'], 'The field label is required.' );
_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
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' );
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' );
// At this point, the essentials fields and its location should be set and valid.
$location = $options['location'];
// 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' );
if ( ! empty( $options['type'] ) ) {
if ( ! in_array( $options['type'], $this->supported_field_types, true ) ) {
'Unable to register field with id: "%s". Registering a field with type "%s" is not supported. The supported types are: %s.',
implode( ', ', $this->supported_field_types )
_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
if ( ! empty( $options['sanitize_callback'] ) && ! is_callable( $options['sanitize_callback'] ) ) {
$message = sprintf( 'Unable to register field with id: "%s". %s', $id, 'The sanitize_callback must be a valid callback.' );
_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
if ( ! empty( $options['validate_callback'] ) && ! is_callable( $options['validate_callback'] ) ) {
$message = sprintf( 'Unable to register field with id: "%s". %s', $id, 'The validate_callback must be a valid callback.' );
_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
if ( ! empty( $options['hidden'] ) && true === $options['hidden'] ) {
// Hidden fields are not supported right now. They will be registered with hidden => false.
$message = sprintf( 'Registering a field with hidden set to true is not supported. The field "%s" will be registered as visible.', $id );
_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
// Don't return here unlike the other fields because this is not an issue that will prevent registration.
$rule_fields = [ 'required', 'hidden', 'validation' ];
$allow_bool = [ 'required', 'hidden' ];
foreach ( $rule_fields as $rule_field ) {
if ( ! empty( $options[ $rule_field ] ) ) {
if ( in_array( $rule_field, $allow_bool, true ) && is_bool( $options[ $rule_field ] ) ) {
$valid = Validation::is_valid_schema( $options[ $rule_field ] );
if ( is_wp_error( $valid ) ) {
$message = sprintf( 'Unable to register field with id: "%s". %s', $options['id'], $rule_field . ': ' . $valid->get_error_message() );
_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
* 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 The updated $field_data array.
private function process_field_options( $field_data, $options ) {
if ( 'checkbox' === $field_data['type'] ) {
$field_data = $this->process_checkbox_field( $field_data, $options );
} elseif ( 'select' === $field_data['type'] ) {
$field_data = $this->process_select_field( $field_data, $options );
* Processes the options for a select field and returns the new field_options array.
* @param array $field_data The field data array to be updated.