• File: CustomsDataValidator.php
  • Full Path: /home/slvgwrnz/public_html/wp-content/plugins/woocommerce/src/Internal/ProductCustoms/CustomsDataValidator.php
  • Date Modified: 10/26/2026 10:38 AM
  • File size: 5.23 KB
  • MIME-type: text/x-php
  • Charset: utf-8
<?php
declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\ProductCustoms;

use WC_Countries;
use WC_Data_Exception;

/**
 * Normalizes customs data shared by product entry points.
 */
final class CustomsDataValidator {

	/**
	 * Customs product props, in the order normalize_fields() validates them.
	 */
	public const FIELDS = array( 'customs_commodity_code', 'customs_country_of_origin', 'customs_description' );

	/**
	 * Normalizes every supplied customs field before callers apply changes.
	 *
	 * @since 11.3.0
	 *
	 * @param array $data Product properties, including any customs fields to update.
	 * @return array<string, string|null> Normalized customs fields only.
	 * @throws WC_Data_Exception When any supplied customs field is invalid.
	 */
	public static function normalize_fields( array $data ): array {
		$normalized = array();
		foreach ( self::FIELDS as $field ) {
			if ( array_key_exists( $field, $data ) ) {
				$normalized[ $field ] = self::{ str_replace( 'customs_', 'normalize_', $field ) }( $data[ $field ] );
			}
		}
		return $normalized;
	}

	/**
	 * Removes commodity code punctuation and spacing while preserving leading zeroes.
	 *
	 * @since 11.3.0
	 *
	 * @param mixed $value Commodity code, or null to clear it.
	 * @return string|null
	 * @throws WC_Data_Exception When the code does not contain six to fourteen ASCII digits.
	 */
	public static function normalize_commodity_code( $value ): ?string {
		if ( null === $value || ( is_string( $value ) && preg_match( '/\A\s*\z/u', $value ) ) ) {
			return null;
		}

		if ( is_string( $value ) && preg_match( '/\A[0-9\p{P}\s]+\z/u', $value ) ) {
			$normalized = preg_replace( '/[\p{P}\s]+/u', '', $value ) ?? '';
			if ( preg_match( '/\A[0-9]{6,14}\z/', $normalized ) ) {
				return $normalized;
			}
		}

		throw new WC_Data_Exception(
			'woocommerce_product_invalid_customs_commodity_code',
			__( 'The customs commodity code must contain 6 to 14 digits, with optional punctuation or spaces.', 'woocommerce' ), // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Escaped when output.
			400
		);
	}

	/**
	 * Normalizes an origin country against the full WooCommerce country list.
	 *
	 * @since 11.3.0
	 *
	 * @param mixed $value Country code, or null to clear it.
	 * @return string|null
	 * @throws WC_Data_Exception When the country code is not recognized.
	 */
	public static function normalize_country_of_origin( $value ): ?string {
		if ( null === $value ) {
			return null;
		}

		// Unicode-aware trim, so a pasted non-breaking space counts as empty. Null means invalid UTF-8.
		$trimmed = is_string( $value ) ? preg_replace( '/^\s+|\s+$/u', '', $value ) : null;
		if ( null !== $trimmed ) {
			$code = strtoupper( $trimmed );
			if ( '' === $code ) {
				return null;
			}
			// WC()->countries is only set once WooCommerce has initialized.
			$countries = WC()->countries instanceof WC_Countries ? WC()->countries : new WC_Countries();
			if ( $countries->country_exists( $code ) ) {
				return $code;
			}
		}

		throw new WC_Data_Exception(
			'woocommerce_product_invalid_customs_country_of_origin',
			__( 'Select a valid country of origin.', 'woocommerce' ), // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Escaped when output.
			400
		);
	}

	/**
	 * Normalizes a plain text customs description of up to thirty-five Unicode code points.
	 * Only letters, digits, spaces, punctuation and printable ASCII symbols are allowed, so emoji (including keycap and variation-selector sequences) and symbols such as ™ are rejected.
	 * Angle brackets are rejected rather than stripped as markup, so text such as "Size<M shirt" isn't silently cut.
	 *
	 * @since 11.3.0
	 *
	 * @param mixed $value Plain text description, or null to clear it.
	 * @return string|null
	 * @throws WC_Data_Exception When the description is not a string, has disallowed characters, or exceeds the length limit.
	 */
	public static function normalize_description( $value ): ?string {
		if ( null === $value || '' === $value ) {
			return null;
		}

		if ( is_string( $value ) && '' !== wp_check_invalid_utf8( $value ) ) {
			$text = trim( (string) preg_replace( '/[\r\n\t ]+/', ' ', $value ) );
			if ( '' === $text ) {
				return null;
			}
			if ( mb_strlen( $text ) <= 35 && ! preg_match( '/[<>]|[^\x20-\x7E\p{L}\p{Mn}\p{Mc}\p{N}\p{P}\s]|[\x{FE00}-\x{FE0F}\x{E0100}-\x{E01EF}]/u', $text ) ) {
				return $text;
			}
		}

		throw new WC_Data_Exception(
			'woocommerce_product_invalid_customs_description',
			__( 'The customs description must be plain text with no more than 35 characters, without emoji or special symbols.', 'woocommerce' ), // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Escaped when output.
			400
		);
	}

	/**
	 * Lightly normalizes a stored customs value read from the database, without validating it.
	 *
	 * @since 11.3.0
	 *
	 * @param string $prop  Customs prop name, such as customs_country_of_origin.
	 * @param mixed  $value Stored value.
	 * @return string|null
	 */
	public static function normalize_stored_value( string $prop, $value ): ?string {
		if ( ! is_string( $value ) ) {
			return null;
		}

		$value = trim( $value );
		if ( '' === $value ) {
			return null;
		}

		return 'customs_country_of_origin' === $prop ? strtoupper( $value ) : $value;
	}
}