Commit da16c993d60 for woocommerce

commit da16c993d60b7dbcb1cc22afea73171512916799
Author: Bogdan Ungureanu <bogdanungureanu21@gmail.com>
Date:   Tue Oct 6 13:19:56 2026 +0300

    Add customs data properties to products and variations (#69164)

    * Add customs data properties to products and variations

    * Reject angle brackets in customs descriptions instead of stripping tags

    wp_strip_all_tags() read "Size<M shirt" as markup and saved "Size".

    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_016Jr8pVtQLBBbZaHKjabedV

    ---------

    Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/feat-wooplug-5501-customs-1-data b/plugins/woocommerce/changelog/feat-wooplug-5501-customs-1-data
new file mode 100644
index 00000000000..cd3e7600cf1
--- /dev/null
+++ b/plugins/woocommerce/changelog/feat-wooplug-5501-customs-1-data
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Add customs data properties to products and variations; no merchant-facing change.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-product.php b/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
index 4580ce93415..6142b6ef5f6 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
@@ -16,6 +16,7 @@ use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Enums\CatalogVisibility;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
 use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore as ProductAttributesLookupDataStore;
+use Automattic\WooCommerce\Internal\ProductCustoms\CustomsDataValidator;

 /**
  * Legacy product contains all deprecated methods for this class and can be
@@ -61,59 +62,62 @@ class WC_Product extends WC_Abstract_Legacy_Product {
 	 * @var array
 	 */
 	protected $data = array(
-		'name'               => '',
-		'slug'               => '',
-		'date_created'       => null,
-		'date_modified'      => null,
-		'status'             => false,
-		'featured'           => false,
-		'catalog_visibility' => CatalogVisibility::VISIBLE,
-		'description'        => '',
-		'short_description'  => '',
-		'sku'                => '',
-		'global_unique_id'   => '',
-		'price'              => '',
-		'regular_price'      => '',
-		'sale_price'         => '',
-		'date_on_sale_from'  => null,
-		'date_on_sale_to'    => null,
-		'total_sales'        => '0',
-		'tax_status'         => ProductTaxStatus::TAXABLE,
-		'tax_class'          => '',
-		'manage_stock'       => false,
-		'stock_quantity'     => null,
-		'stock_status'       => ProductStockStatus::IN_STOCK,
-		'backorders'         => 'no',
-		'low_stock_amount'   => '',
-		'sold_individually'  => false,
-		'weight'             => '',
-		'length'             => '',
-		'width'              => '',
-		'height'             => '',
-		'upsell_ids'         => array(),
-		'cross_sell_ids'     => array(),
-		'parent_id'          => 0,
-		'reviews_allowed'    => true,
-		'purchase_note'      => '',
-		'attributes'         => array(),
-		'default_attributes' => array(),
-		'menu_order'         => 0,
-		'post_password'      => '',
-		'virtual'            => false,
-		'downloadable'       => false,
-		'category_ids'       => array(),
-		'tag_ids'            => array(),
-		'brand_ids'          => array(),
-		'shipping_class_id'  => 0,
-		'downloads'          => array(),
-		'image_id'           => '',
-		'gallery_image_ids'  => array(),
-		'download_limit'     => -1,
-		'download_expiry'    => -1,
-		'rating_counts'      => array(),
-		'average_rating'     => 0,
-		'review_count'       => 0,
-		'cogs_value'         => null,
+		'name'                      => '',
+		'slug'                      => '',
+		'date_created'              => null,
+		'date_modified'             => null,
+		'status'                    => false,
+		'featured'                  => false,
+		'catalog_visibility'        => CatalogVisibility::VISIBLE,
+		'description'               => '',
+		'short_description'         => '',
+		'sku'                       => '',
+		'global_unique_id'          => '',
+		'price'                     => '',
+		'regular_price'             => '',
+		'sale_price'                => '',
+		'date_on_sale_from'         => null,
+		'date_on_sale_to'           => null,
+		'total_sales'               => '0',
+		'tax_status'                => ProductTaxStatus::TAXABLE,
+		'tax_class'                 => '',
+		'manage_stock'              => false,
+		'stock_quantity'            => null,
+		'stock_status'              => ProductStockStatus::IN_STOCK,
+		'backorders'                => 'no',
+		'low_stock_amount'          => '',
+		'sold_individually'         => false,
+		'weight'                    => '',
+		'length'                    => '',
+		'width'                     => '',
+		'height'                    => '',
+		'upsell_ids'                => array(),
+		'cross_sell_ids'            => array(),
+		'parent_id'                 => 0,
+		'reviews_allowed'           => true,
+		'purchase_note'             => '',
+		'attributes'                => array(),
+		'default_attributes'        => array(),
+		'menu_order'                => 0,
+		'post_password'             => '',
+		'virtual'                   => false,
+		'downloadable'              => false,
+		'category_ids'              => array(),
+		'tag_ids'                   => array(),
+		'brand_ids'                 => array(),
+		'shipping_class_id'         => 0,
+		'downloads'                 => array(),
+		'image_id'                  => '',
+		'gallery_image_ids'         => array(),
+		'download_limit'            => -1,
+		'download_expiry'           => -1,
+		'rating_counts'             => array(),
+		'average_rating'            => 0,
+		'review_count'              => 0,
+		'cogs_value'                => null,
+		'customs_commodity_code'    => null,
+		'customs_country_of_origin' => null,
+		'customs_description'       => null,
 	);

 	/**
@@ -280,6 +284,42 @@ class WC_Product extends WC_Abstract_Legacy_Product {
 		return $this->get_prop( 'global_unique_id', $context );
 	}

+	/**
+	 * Gets the customs commodity code.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param string $context View or edit context.
+	 * @return string|null
+	 */
+	public function get_customs_commodity_code( $context = 'view' ) {
+		return $this->get_prop( 'customs_commodity_code', $context );
+	}
+
+	/**
+	 * Gets the customs country of origin.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param string $context View or edit context.
+	 * @return string|null
+	 */
+	public function get_customs_country_of_origin( $context = 'view' ) {
+		return $this->get_prop( 'customs_country_of_origin', $context );
+	}
+
+	/**
+	 * Gets the customs description.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param string $context View or edit context.
+	 * @return string|null
+	 */
+	public function get_customs_description( $context = 'view' ) {
+		return $this->get_prop( 'customs_description', $context );
+	}
+
 	/**
 	 * Returns the product's active price.
 	 *
@@ -916,6 +956,54 @@ class WC_Product extends WC_Abstract_Legacy_Product {
 		$this->set_prop( 'global_unique_id', $global_unique_id );
 	}

+	/**
+	 * Sets the customs commodity code.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param string|null $code Commodity code, or null to clear it.
+	 * @throws WC_Data_Exception When the commodity code is invalid.
+	 * @return void
+	 */
+	public function set_customs_commodity_code( $code ) {
+		$this->set_prop(
+			'customs_commodity_code',
+			$this->get_object_read() ? CustomsDataValidator::normalize_commodity_code( $code ) : CustomsDataValidator::normalize_stored_value( 'customs_commodity_code', $code )
+		);
+	}
+
+	/**
+	 * Sets the customs country of origin.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param string|null $country Country code, or null to clear it.
+	 * @throws WC_Data_Exception When the country code is invalid.
+	 * @return void
+	 */
+	public function set_customs_country_of_origin( $country ) {
+		$this->set_prop(
+			'customs_country_of_origin',
+			$this->get_object_read() ? CustomsDataValidator::normalize_country_of_origin( $country ) : CustomsDataValidator::normalize_stored_value( 'customs_country_of_origin', $country )
+		);
+	}
+
+	/**
+	 * Sets the customs description.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param string|null $description Plain text description, or null to clear it.
+	 * @throws WC_Data_Exception When the description is not valid text or exceeds thirty-five characters.
+	 * @return void
+	 */
+	public function set_customs_description( $description ) {
+		$this->set_prop(
+			'customs_description',
+			$this->get_object_read() ? CustomsDataValidator::normalize_description( $description ) : CustomsDataValidator::normalize_stored_value( 'customs_description', $description )
+		);
+	}
+
 	/**
 	 * Set the product's active price.
 	 *
diff --git a/plugins/woocommerce/includes/class-wc-product-variation.php b/plugins/woocommerce/includes/class-wc-product-variation.php
index f8282cfeb66..776014a3734 100644
--- a/plugins/woocommerce/includes/class-wc-product-variation.php
+++ b/plugins/woocommerce/includes/class-wc-product-variation.php
@@ -11,6 +11,7 @@
 use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Enums\CatalogVisibility;
+use Automattic\WooCommerce\Internal\ProductCustoms\CustomsDataValidator;

 defined( 'ABSPATH' ) || exit;

@@ -32,19 +33,22 @@ class WC_Product_Variation extends WC_Product_Simple {
 	 * @var array
 	 */
 	protected $parent_data = array(
-		'title'             => '',
-		'sku'               => '',
-		'manage_stock'      => '',
-		'backorders'        => '',
-		'stock_quantity'    => '',
-		'weight'            => '',
-		'length'            => '',
-		'width'             => '',
-		'height'            => '',
-		'tax_class'         => '',
-		'shipping_class_id' => '',
-		'image_id'          => '',
-		'purchase_note'     => '',
+		'title'                     => '',
+		'sku'                       => '',
+		'manage_stock'              => '',
+		'backorders'                => '',
+		'stock_quantity'            => '',
+		'weight'                    => '',
+		'length'                    => '',
+		'width'                     => '',
+		'height'                    => '',
+		'tax_class'                 => '',
+		'shipping_class_id'         => '',
+		'image_id'                  => '',
+		'purchase_note'             => '',
+		'customs_commodity_code'    => null,
+		'customs_country_of_origin' => null,
+		'customs_description'       => null,
 	);

 	/**
@@ -224,6 +228,69 @@ class WC_Product_Variation extends WC_Product_Simple {
 		return $value;
 	}

+	/**
+	 * Gets the commodity code, inheriting from the parent in view context.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param string $context View or edit context.
+	 * @return string|null
+	 */
+	public function get_customs_commodity_code( $context = 'view' ) {
+		return $this->get_customs_prop( 'customs_commodity_code', $context );
+	}
+
+	/**
+	 * Gets the origin country, inheriting from the parent in view context.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param string $context View or edit context.
+	 * @return string|null
+	 */
+	public function get_customs_country_of_origin( $context = 'view' ) {
+		return $this->get_customs_prop( 'customs_country_of_origin', $context );
+	}
+
+	/**
+	 * Gets the customs description, inheriting from the parent in view context.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param string $context View or edit context.
+	 * @return string|null
+	 */
+	public function get_customs_description( $context = 'view' ) {
+		return $this->get_customs_prop( 'customs_description', $context );
+	}
+
+	/**
+	 * Gets a customs value, falling back to the parent value in view context when the variation has none.
+	 *
+	 * @param string $prop    Customs prop name.
+	 * @param string $context View or edit context.
+	 * @return string|null
+	 */
+	private function get_customs_prop( string $prop, $context ) {
+		$value = $this->get_prop( $prop, $context );
+		if ( 'view' === $context && null === $value ) {
+			/**
+			 * Filters a resolved customs value: woocommerce_product_variation_get_customs_commodity_code,
+			 * woocommerce_product_variation_get_customs_country_of_origin, or woocommerce_product_variation_get_customs_description.
+			 *
+			 * The dynamic portion of the hook name, `$prop`, refers to the customs prop name. Runs for the variation's
+			 * own value via get_prop() (may be null), and again with the parent value when the variation inherits it.
+			 *
+			 * @since 11.3.0
+			 *
+			 * @param string|null          $value     Customs value.
+			 * @param WC_Product_Variation $variation Product variation.
+			 */
+			$value = apply_filters( $this->get_hook_prefix() . $prop, $this->parent_data[ $prop ], $this );
+		}
+		return $value;
+	}
+
 	/**
 	 * Returns the product's weight.
 	 *
@@ -467,21 +534,24 @@ class WC_Product_Variation extends WC_Product_Simple {
 		$parent_data = wp_parse_args(
 			$parent_data,
 			array(
-				'title'              => '',
-				'status'             => '',
-				'sku'                => '',
-				'manage_stock'       => 'no',
-				'backorders'         => 'no',
-				'stock_quantity'     => '',
-				'weight'             => '',
-				'length'             => '',
-				'width'              => '',
-				'height'             => '',
-				'tax_class'          => '',
-				'shipping_class_id'  => 0,
-				'image_id'           => 0,
-				'purchase_note'      => '',
-				'catalog_visibility' => CatalogVisibility::VISIBLE,
+				'title'                     => '',
+				'status'                    => '',
+				'sku'                       => '',
+				'manage_stock'              => 'no',
+				'backorders'                => 'no',
+				'stock_quantity'            => '',
+				'weight'                    => '',
+				'length'                    => '',
+				'width'                     => '',
+				'height'                    => '',
+				'tax_class'                 => '',
+				'shipping_class_id'         => 0,
+				'image_id'                  => 0,
+				'purchase_note'             => '',
+				'catalog_visibility'        => CatalogVisibility::VISIBLE,
+				'customs_commodity_code'    => null,
+				'customs_country_of_origin' => null,
+				'customs_description'       => null,
 			)
 		);

@@ -494,6 +564,10 @@ class WC_Product_Variation extends WC_Product_Simple {
 			$parent_data['tax_class'] = '';
 		}

+		foreach ( CustomsDataValidator::FIELDS as $field ) {
+			$parent_data[ $field ] = CustomsDataValidator::normalize_stored_value( $field, $parent_data[ $field ] );
+		}
+
 		$this->parent_data = $parent_data;
 	}

diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
index 3f58cf54324..cf114809c03 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
@@ -36,6 +36,9 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
 		'_visibility',
 		'_sku',
 		'_global_unique_id',
+		'_customs_commodity_code',
+		'_customs_country_of_origin',
+		'_customs_description',
 		'_price',
 		'_regular_price',
 		'_sale_price',
@@ -448,39 +451,42 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
 		$id                = $product->get_id();
 		$post_meta_values  = get_post_meta( $id );
 		$meta_key_to_props = array(
-			'_sku'                   => 'sku',
-			'_global_unique_id'      => 'global_unique_id',
-			'_regular_price'         => 'regular_price',
-			'_sale_price'            => 'sale_price',
-			'_price'                 => 'price',
-			'_sale_price_dates_from' => 'date_on_sale_from',
-			'_sale_price_dates_to'   => 'date_on_sale_to',
-			'total_sales'            => 'total_sales',
-			'_tax_status'            => 'tax_status',
-			'_tax_class'             => 'tax_class',
-			'_manage_stock'          => 'manage_stock',
-			'_backorders'            => 'backorders',
-			'_low_stock_amount'      => 'low_stock_amount',
-			'_sold_individually'     => 'sold_individually',
-			'_weight'                => 'weight',
-			'_length'                => 'length',
-			'_width'                 => 'width',
-			'_height'                => 'height',
-			'_upsell_ids'            => 'upsell_ids',
-			'_crosssell_ids'         => 'cross_sell_ids',
-			'_purchase_note'         => 'purchase_note',
-			'_default_attributes'    => 'default_attributes',
-			'_virtual'               => 'virtual',
-			'_downloadable'          => 'downloadable',
-			'_download_limit'        => 'download_limit',
-			'_download_expiry'       => 'download_expiry',
-			'_thumbnail_id'          => 'image_id',
-			'_stock'                 => 'stock_quantity',
-			'_stock_status'          => 'stock_status',
-			'_wc_average_rating'     => 'average_rating',
-			'_wc_rating_count'       => 'rating_counts',
-			'_wc_review_count'       => 'review_count',
-			'_product_image_gallery' => 'gallery_image_ids',
+			'_sku'                       => 'sku',
+			'_global_unique_id'          => 'global_unique_id',
+			'_regular_price'             => 'regular_price',
+			'_sale_price'                => 'sale_price',
+			'_price'                     => 'price',
+			'_sale_price_dates_from'     => 'date_on_sale_from',
+			'_sale_price_dates_to'       => 'date_on_sale_to',
+			'total_sales'                => 'total_sales',
+			'_tax_status'                => 'tax_status',
+			'_tax_class'                 => 'tax_class',
+			'_manage_stock'              => 'manage_stock',
+			'_backorders'                => 'backorders',
+			'_low_stock_amount'          => 'low_stock_amount',
+			'_sold_individually'         => 'sold_individually',
+			'_weight'                    => 'weight',
+			'_length'                    => 'length',
+			'_width'                     => 'width',
+			'_height'                    => 'height',
+			'_upsell_ids'                => 'upsell_ids',
+			'_crosssell_ids'             => 'cross_sell_ids',
+			'_purchase_note'             => 'purchase_note',
+			'_default_attributes'        => 'default_attributes',
+			'_virtual'                   => 'virtual',
+			'_downloadable'              => 'downloadable',
+			'_download_limit'            => 'download_limit',
+			'_download_expiry'           => 'download_expiry',
+			'_thumbnail_id'              => 'image_id',
+			'_stock'                     => 'stock_quantity',
+			'_stock_status'              => 'stock_status',
+			'_wc_average_rating'         => 'average_rating',
+			'_wc_rating_count'           => 'rating_counts',
+			'_wc_review_count'           => 'review_count',
+			'_product_image_gallery'     => 'gallery_image_ids',
+			'_customs_commodity_code'    => 'customs_commodity_code',
+			'_customs_country_of_origin' => 'customs_country_of_origin',
+			'_customs_description'       => 'customs_description',
 		);

 		$set_props = array();
@@ -734,38 +740,41 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
 	 */
 	protected function update_post_meta( &$product, $force = false ) {
 		$meta_key_to_props = array(
-			'_sku'                   => 'sku',
-			'_global_unique_id'      => 'global_unique_id',
-			'_regular_price'         => 'regular_price',
-			'_sale_price'            => 'sale_price',
-			'_sale_price_dates_from' => 'date_on_sale_from',
-			'_sale_price_dates_to'   => 'date_on_sale_to',
-			'total_sales'            => 'total_sales',
-			'_tax_status'            => 'tax_status',
-			'_tax_class'             => 'tax_class',
-			'_manage_stock'          => 'manage_stock',
-			'_backorders'            => 'backorders',
-			'_low_stock_amount'      => 'low_stock_amount',
-			'_sold_individually'     => 'sold_individually',
-			'_weight'                => 'weight',
-			'_length'                => 'length',
-			'_width'                 => 'width',
-			'_height'                => 'height',
-			'_upsell_ids'            => 'upsell_ids',
-			'_crosssell_ids'         => 'cross_sell_ids',
-			'_purchase_note'         => 'purchase_note',
-			'_default_attributes'    => 'default_attributes',
-			'_virtual'               => 'virtual',
-			'_downloadable'          => 'downloadable',
-			'_product_image_gallery' => 'gallery_image_ids',
-			'_download_limit'        => 'download_limit',
-			'_download_expiry'       => 'download_expiry',
-			'_thumbnail_id'          => 'image_id',
-			'_stock'                 => 'stock_quantity',
-			'_stock_status'          => 'stock_status',
-			'_wc_average_rating'     => 'average_rating',
-			'_wc_rating_count'       => 'rating_counts',
-			'_wc_review_count'       => 'review_count',
+			'_sku'                       => 'sku',
+			'_global_unique_id'          => 'global_unique_id',
+			'_regular_price'             => 'regular_price',
+			'_sale_price'                => 'sale_price',
+			'_sale_price_dates_from'     => 'date_on_sale_from',
+			'_sale_price_dates_to'       => 'date_on_sale_to',
+			'total_sales'                => 'total_sales',
+			'_tax_status'                => 'tax_status',
+			'_tax_class'                 => 'tax_class',
+			'_manage_stock'              => 'manage_stock',
+			'_backorders'                => 'backorders',
+			'_low_stock_amount'          => 'low_stock_amount',
+			'_sold_individually'         => 'sold_individually',
+			'_weight'                    => 'weight',
+			'_length'                    => 'length',
+			'_width'                     => 'width',
+			'_height'                    => 'height',
+			'_upsell_ids'                => 'upsell_ids',
+			'_crosssell_ids'             => 'cross_sell_ids',
+			'_purchase_note'             => 'purchase_note',
+			'_default_attributes'        => 'default_attributes',
+			'_virtual'                   => 'virtual',
+			'_downloadable'              => 'downloadable',
+			'_product_image_gallery'     => 'gallery_image_ids',
+			'_download_limit'            => 'download_limit',
+			'_download_expiry'           => 'download_expiry',
+			'_thumbnail_id'              => 'image_id',
+			'_stock'                     => 'stock_quantity',
+			'_stock_status'              => 'stock_status',
+			'_wc_average_rating'         => 'average_rating',
+			'_wc_rating_count'           => 'rating_counts',
+			'_wc_review_count'           => 'review_count',
+			'_customs_commodity_code'    => 'customs_commodity_code',
+			'_customs_country_of_origin' => 'customs_country_of_origin',
+			'_customs_description'       => 'customs_description',
 		);

 		// Make sure to take extra data (like product url or text for external products) into account.
@@ -781,6 +790,11 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
 			$value = $product->{"get_$prop"}( 'edit' );
 			$value = is_string( $value ) ? wp_slash( $value ) : $value;
 			switch ( $prop ) {
+				case 'customs_commodity_code':
+				case 'customs_country_of_origin':
+				case 'customs_description':
+					$value = $value ?? '';
+					break;
 				case 'virtual':
 				case 'downloadable':
 				case 'manage_stock':
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php
index ac804cf2e8d..8c4f5c2f247 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php
@@ -413,30 +413,33 @@ class WC_Product_Variation_Data_Store_CPT extends WC_Product_Data_Store_CPT impl
 		$post_meta_values = get_post_meta( $id );

 		$meta_key_to_props = array(
-			'_variation_description'  => 'description',
-			'_regular_price'          => 'regular_price',
-			'_sale_price'             => 'sale_price',
-			'_sale_price_dates_from'  => 'date_on_sale_from',
-			'_sale_price_dates_to'    => 'date_on_sale_to',
-			'_manage_stock'           => 'manage_stock',
-			'_stock_status'           => 'stock_status',
-			'_virtual'                => 'virtual',
-			'_product_image_gallery'  => 'gallery_image_ids',
-			'_download_limit'         => 'download_limit',
-			'_download_expiry'        => 'download_expiry',
-			'_downloadable'           => 'downloadable',
-			'_sku'                    => 'sku',
-			'_global_unique_id'       => 'global_unique_id',
-			'_stock'                  => 'stock_quantity',
-			'_weight'                 => 'weight',
-			'_length'                 => 'length',
-			'_width'                  => 'width',
-			'_height'                 => 'height',
-			'_low_stock_amount'       => 'low_stock_amount',
-			'_backorders'             => 'backorders',
-			'_cogs_total_value'       => 'cogs_total_value',
-			'_cogs_value_is_additive' => 'cogs_value_is_additive',
-			'_tax_class'              => 'tax_class',
+			'_variation_description'     => 'description',
+			'_regular_price'             => 'regular_price',
+			'_sale_price'                => 'sale_price',
+			'_sale_price_dates_from'     => 'date_on_sale_from',
+			'_sale_price_dates_to'       => 'date_on_sale_to',
+			'_manage_stock'              => 'manage_stock',
+			'_stock_status'              => 'stock_status',
+			'_virtual'                   => 'virtual',
+			'_product_image_gallery'     => 'gallery_image_ids',
+			'_download_limit'            => 'download_limit',
+			'_download_expiry'           => 'download_expiry',
+			'_downloadable'              => 'downloadable',
+			'_sku'                       => 'sku',
+			'_global_unique_id'          => 'global_unique_id',
+			'_stock'                     => 'stock_quantity',
+			'_weight'                    => 'weight',
+			'_length'                    => 'length',
+			'_width'                     => 'width',
+			'_height'                    => 'height',
+			'_low_stock_amount'          => 'low_stock_amount',
+			'_backorders'                => 'backorders',
+			'_cogs_total_value'          => 'cogs_total_value',
+			'_cogs_value_is_additive'    => 'cogs_value_is_additive',
+			'_tax_class'                 => 'tax_class',
+			'_customs_commodity_code'    => 'customs_commodity_code',
+			'_customs_country_of_origin' => 'customs_country_of_origin',
+			'_customs_description'       => 'customs_description',
 		);

 		$variation_data = array();
@@ -491,20 +494,23 @@ class WC_Product_Variation_Data_Store_CPT extends WC_Product_Data_Store_CPT impl
 		$parent_post_meta_values = get_post_meta( $parent_id );

 		$parent_meta_key_to_props = array(
-			'_sku'               => 'sku',
-			'_global_unique_id'  => 'global_unique_id',
-			'_manage_stock'      => 'manage_stock',
-			'_backorders'        => 'backorders',
-			'_stock'             => 'stock_quantity',
-			'_weight'            => 'weight',
-			'_length'            => 'length',
-			'_width'             => 'width',
-			'_height'            => 'height',
-			'_tax_class'         => 'tax_class',
-			'_purchase_note'     => 'purchase_note',
-			'_sold_individually' => 'sold_individually',
-			'_tax_status'        => 'tax_status',
-			'_crosssell_ids'     => '_crosssell_ids',
+			'_sku'                       => 'sku',
+			'_global_unique_id'          => 'global_unique_id',
+			'_manage_stock'              => 'manage_stock',
+			'_backorders'                => 'backorders',
+			'_stock'                     => 'stock_quantity',
+			'_weight'                    => 'weight',
+			'_length'                    => 'length',
+			'_width'                     => 'width',
+			'_height'                    => 'height',
+			'_tax_class'                 => 'tax_class',
+			'_purchase_note'             => 'purchase_note',
+			'_sold_individually'         => 'sold_individually',
+			'_tax_status'                => 'tax_status',
+			'_crosssell_ids'             => '_crosssell_ids',
+			'_customs_commodity_code'    => 'customs_commodity_code',
+			'_customs_country_of_origin' => 'customs_country_of_origin',
+			'_customs_description'       => 'customs_description',
 		);

 		$parent_data = array();
diff --git a/plugins/woocommerce/src/Internal/ProductCustoms/CustomsDataValidator.php b/plugins/woocommerce/src/Internal/ProductCustoms/CustomsDataValidator.php
new file mode 100644
index 00000000000..f5cd6a88b57
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductCustoms/CustomsDataValidator.php
@@ -0,0 +1,155 @@
+<?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;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-product-test.php b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-product-test.php
index 58d636fce7b..29c06badd54 100644
--- a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-product-test.php
+++ b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-product-test.php
@@ -589,4 +589,67 @@ class WC_Abstract_Product_Test extends WC_Unit_Test_Case {
 		wp_set_current_user( $this->admin_user );
 		$this->assertTrue( $product->is_viewable(), "A product with a $parent_kind parent is viewable by admins." );
 	}
+
+	/**
+	 * @testdox Should persist normalized customs values as internal product data and remove cleared metadata.
+	 * @testWith ["customs_commodity_code", "01.02-03", "010203"]
+	 *           ["customs_country_of_origin", " ro ", "RO"]
+	 *           ["customs_description", " Cotton  shirt ", "Cotton shirt"]
+	 * @param string $field Property name.
+	 * @param string $input Input value.
+	 * @param string $expected Stored value.
+	 */
+	public function test_customs_properties_persist_and_clear( string $field, string $input, string $expected ): void {
+		$sut = new WC_Product_Simple();
+		$this->assertNull( $sut->{"get_$field"}(), 'New products should not have customs data.' );
+		$sut->{"set_$field"}( $input );
+		$product_id = $sut->save();
+		$sut        = new WC_Product_Simple( $product_id );
+
+		$this->assertSame( $expected, $sut->{"get_$field"}( 'edit' ), 'Customs data should round-trip through the product data store.' );
+		$this->assertSame( $expected, get_post_meta( $product_id, '_' . $field, true ), 'The underscore-prefixed metadata should contain the normalized value.' );
+		$this->assertNotContains( '_' . $field, wp_list_pluck( $sut->get_meta_data(), 'key' ), 'Customs fields should be internal metadata.' );
+
+		$sut->{"set_$field"}( null );
+		$sut->save();
+		$sut = new WC_Product_Simple( $product_id );
+
+		$this->assertNull( $sut->{"get_$field"}( 'edit' ), 'Cleared customs properties should reload as null.' );
+		$this->assertFalse( metadata_exists( 'post', $product_id, '_' . $field ), 'Clearing customs data should remove its metadata row.' );
+	}
+
+	/**
+	 * @testdox Should preserve a product value when a customs setter receives invalid data.
+	 * @testWith ["customs_country_of_origin", "RO", "ZZ"]
+	 *           ["customs_description", "Cotton shirt", "Size<M shirt"]
+	 * @param string $field Property name.
+	 * @param string $valid Original value.
+	 * @param string $invalid Invalid value.
+	 */
+	public function test_customs_setters_reject_invalid_updates( string $field, string $valid, string $invalid ): void {
+		$sut = new WC_Product_Simple();
+		$sut->{"set_$field"}( $valid );
+
+		try {
+			$sut->{"set_$field"}( $invalid );
+			$this->fail( 'An invalid update should throw a data exception.' );
+		} catch ( WC_Data_Exception $exception ) {
+			$this->assertSame( $valid, $sut->{"get_$field"}( 'edit' ), 'Validation should happen before changing the product.' );
+		}
+	}
+
+	/**
+	 * @testdox Should load invalid stored customs metadata as-is.
+	 */
+	public function test_customs_invalid_stored_values_are_read_without_validation(): void {
+		$description = str_repeat( 'a', 40 );
+		$product_id  = ( new WC_Product_Simple() )->save();
+		update_post_meta( $product_id, '_customs_country_of_origin', 'ZZ' );
+		update_post_meta( $product_id, '_customs_description', $description );
+
+		$sut = new WC_Product_Simple( $product_id );
+
+		$this->assertSame( 'ZZ', $sut->get_customs_country_of_origin( 'edit' ), 'An invalid stored country should be read without throwing.' );
+		$this->assertSame( $description, $sut->get_customs_description( 'edit' ), 'An over-long stored description should be read without throwing.' );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-duplicate-product-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-duplicate-product-test.php
index a2d32055a01..c0eae335222 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-duplicate-product-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-duplicate-product-test.php
@@ -180,4 +180,33 @@ class WC_Admin_Duplicate_Product_Test extends WC_Unit_Test_Case {
 		$duplicate = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product );
 		$this->assertEquals( 'SKU-2', $duplicate->get_sku(), 'Duplicate must not collide with case-variant sku-1' );
 	}
+
+	/**
+	 * @testdox Should preserve parent customs data and variation null overrides when duplicating a variable product.
+	 */
+	public function test_duplicate_preserves_customs_data(): void {
+		$product = new WC_Product_Variable();
+		$product->set_customs_commodity_code( '010203' );
+		$product->set_customs_country_of_origin( 'RO' );
+		$product->set_customs_description( 'Cotton shirt' );
+		$product->save();
+		$variation = new WC_Product_Variation();
+		$variation->set_parent_id( $product->get_id() );
+		$variation->set_customs_country_of_origin( 'US' );
+		$variation->save();
+		$product = new WC_Product_Variable( $product->get_id() );
+
+		$duplicate = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product );
+		$child_ids = $duplicate->get_children();
+		$this->assertCount( 1, $child_ids, 'Duplicating a variable product should also copy its variation.' );
+		$child = new WC_Product_Variation( $child_ids[0] );
+
+		$this->assertSame( '010203', $duplicate->get_customs_commodity_code( 'edit' ), 'The duplicate should preserve its commodity code.' );
+		$this->assertSame( 'RO', $duplicate->get_customs_country_of_origin( 'edit' ), 'The duplicate should preserve its origin country.' );
+		$this->assertSame( 'Cotton shirt', $duplicate->get_customs_description( 'edit' ), 'The duplicate should preserve its customs description.' );
+		$this->assertSame( 'US', $child->get_customs_country_of_origin( 'edit' ), 'The duplicated variation should preserve its override.' );
+		$this->assertNull( $child->get_customs_commodity_code( 'edit' ), 'Duplication should not persist an inherited commodity code.' );
+		$this->assertNull( $child->get_customs_description( 'edit' ), 'Duplication should not persist an inherited description.' );
+		$this->assertSame( '010203', $child->get_customs_commodity_code(), 'The duplicated variation should inherit from its duplicated parent.' );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-product-variation-test.php b/plugins/woocommerce/tests/php/includes/class-wc-product-variation-test.php
index e4c5e864e2c..29c6918b560 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-product-variation-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-product-variation-test.php
@@ -141,4 +141,69 @@ class WC_Product_Variation_Test extends WC_Unit_Test_Case {
 		$this->assertTrue( $this->variation->is_viewable(), 'A variation whose parent is a draft is viewable by admins.' );
 		$this->assertFalse( $this->variation->is_publicly_viewable(), 'A variation whose parent is a draft is never publicly viewable.' );
 	}
+
+	/**
+	 * @testdox Should inherit customs values only in view context and restore inheritance after clearing an override.
+	 * @testWith ["customs_commodity_code", "010203", "040506"]
+	 *           ["customs_country_of_origin", "RO", "US"]
+	 *           ["customs_description", "Cotton shirt", "Linen shirt"]
+	 * @param string $field Property name.
+	 * @param string $inherited Parent value.
+	 * @param string $override Variation value.
+	 */
+	public function test_customs_variation_inheritance( string $field, string $inherited, string $override ): void {
+		$this->parent_product->{"set_$field"}( $inherited );
+		$this->parent_product->save();
+		$variation_id = $this->variation->get_id();
+		$sut          = new WC_Product_Variation( $variation_id );
+
+		$this->assertSame( $inherited, $sut->{"get_$field"}(), 'Unset variations should inherit the parent in view context.' );
+		$this->assertNull( $sut->{"get_$field"}( 'edit' ), 'Edit context should expose the raw variation override.' );
+
+		$sut->{"set_$field"}( $override );
+		$sut->save();
+		$sut = new WC_Product_Variation( $variation_id );
+		$this->assertSame( $override, $sut->{"get_$field"}(), 'A persisted variation override should take precedence.' );
+		$this->assertSame( $override, $sut->{"get_$field"}( 'edit' ), 'The variation override should be available in edit context.' );
+
+		$sut->{"set_$field"}( " \t" );
+		$sut->save();
+		$sut = new WC_Product_Variation( $variation_id );
+		$this->assertSame( $inherited, $sut->{"get_$field"}(), 'Clearing an override should restore inheritance.' );
+		$this->assertNull( $sut->{"get_$field"}( 'edit' ), 'A cleared override should remain null after reloading.' );
+		$this->assertFalse( metadata_exists( 'post', $variation_id, '_' . $field ), 'An inherited value should not be saved on the variation.' );
+	}
+
+	/**
+	 * @testdox Should let the resolved $field filter change the inherited parent value.
+	 * @testWith ["woocommerce_product_variation_get_customs_country_of_origin", "customs_country_of_origin", "RO", "US"]
+	 * @param string $hook Filter name.
+	 * @param string $field Customs prop name.
+	 * @param string $inherited Parent value.
+	 * @param string $filtered Value returned by the filter.
+	 */
+	public function test_customs_inherited_value_can_be_filtered( string $hook, string $field, string $inherited, string $filtered ): void {
+		$this->parent_product->{"set_$field"}( $inherited );
+		$this->parent_product->save();
+		$sut = new WC_Product_Variation( $this->variation->get_id() );
+
+		$this->assertSame( $inherited, $sut->{"get_$field"}(), 'View context should return the parent value.' );
+
+		add_filter( $hook, fn( $value ) => $inherited === $value ? $filtered : $value );
+
+		$this->assertSame( $filtered, $sut->{"get_$field"}(), 'The filter should be able to change the inherited value.' );
+		$this->assertNull( $sut->{"get_$field"}( 'edit' ), 'Edit context should not be filtered or inherited.' );
+	}
+
+	/**
+	 * @testdox Should hydrate missing parent customs data as null.
+	 */
+	public function test_customs_missing_parent_data_is_null(): void {
+		$sut = new WC_Product_Variation( $this->variation->get_id() );
+
+		foreach ( array( 'customs_commodity_code', 'customs_country_of_origin', 'customs_description' ) as $field ) {
+			$this->assertNull( $sut->{"get_$field"}(), "Missing parent $field should not become an empty string." );
+			$this->assertNull( $sut->get_parent_data()[ $field ], "Hydrated parent $field should be null." );
+		}
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductCustoms/CustomsDataValidatorTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductCustoms/CustomsDataValidatorTest.php
new file mode 100644
index 00000000000..65f754e8559
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductCustoms/CustomsDataValidatorTest.php
@@ -0,0 +1,166 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductCustoms;
+
+use Automattic\WooCommerce\Internal\ProductCustoms\CustomsDataValidator;
+use WC_Data_Exception;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for customs field validation.
+ */
+class CustomsDataValidatorTest extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox Should normalize customs fields without losing leading zeroes.
+	 * @testWith ["commodity_code", " 01.02-03 ", "010203"]
+	 *           ["commodity_code", "00123456789012", "00123456789012"]
+	 *           ["commodity_code", "01/02\t03", "010203"]
+	 *           ["country_of_origin", " us ", "US"]
+	 *           ["description", " Cotton\t shirt\n ", "Cotton shirt"]
+	 *           ["description", "Chemise été", "Chemise été"]
+	 *           ["description", "100%acrylic yarn", "100%acrylic yarn"]
+	 *           ["description", "Salt & pepper", "Salt & pepper"]
+	 *           ["description", "Men's T-shirt (cotton), 2/pk.", "Men's T-shirt (cotton), 2/pk."]
+	 *           ["description", "Café Kaffee 咖啡", "Café Kaffee 咖啡"]
+	 *           ["description", "Price $5 + tax, size = M", "Price $5 + tax, size = M"]
+	 * @param string      $field Field suffix.
+	 * @param string|null $value Input value.
+	 * @param string|null $expected Normalized value.
+	 */
+	public function test_normalizes_fields( string $field, $value, ?string $expected ): void {
+		$this->assertSame( $expected, CustomsDataValidator::{"normalize_$field"}( $value ), 'Customs normalization should preserve the meaningful value.' );
+	}
+
+	/**
+	 * @testdox Should treat null and whitespace-only fields as unset.
+	 * @testWith [null]
+	 *           [""]
+	 *           [" \t\n"]
+	 * @param string|null $value Empty value.
+	 */
+	public function test_normalizes_empty_values( $value ): void {
+		foreach ( array( 'commodity_code', 'country_of_origin', 'description' ) as $field ) {
+			$this->assertNull( CustomsDataValidator::{"normalize_$field"}( $value ), "An empty $field should be null." );
+		}
+	}
+
+	/**
+	 * @testdox Should reject malformed customs values with field-specific errors.
+	 * @testWith ["commodity_code", "12345"]
+	 *           ["commodity_code", "123456789012345"]
+	 *           ["commodity_code", "HS 123456"]
+	 *           ["commodity_code", "123456$"]
+	 *           ["commodity_code", "123456"]
+	 *           ["commodity_code", "---"]
+	 *           ["commodity_code", []]
+	 *           ["country_of_origin", "ZZ"]
+	 *           ["country_of_origin", "USA"]
+	 *           ["country_of_origin", []]
+	 *           ["description", []]
+	 *           ["description", "Cotton shirt 👕"]
+	 *           ["description", "Mug™"]
+	 *           ["description", "Size 1️⃣"]
+	 *           ["description", "Price €5"]
+	 *           ["description", "Size<M shirt"]
+	 *           ["description", "<b>Cotton</b>"]
+	 *           ["description", "Size > M"]
+	 * @param string $field Field suffix.
+	 * @param mixed  $value Invalid value.
+	 */
+	public function test_rejects_invalid_values( string $field, $value ): void {
+		try {
+			CustomsDataValidator::{"normalize_$field"}( $value );
+			$this->fail( 'Invalid customs data should throw a data exception.' );
+		} catch ( WC_Data_Exception $exception ) {
+			$this->assertSame( 'woocommerce_product_invalid_customs_' . $field, $exception->getErrorCode(), 'The error should identify the invalid field.' );
+			$this->assertSame( 400, $exception->getCode(), 'Invalid customs data should be a client error.' );
+		}
+	}
+
+	/**
+	 * @testdox Should validate origins against all countries even when selling is restricted.
+	 */
+	public function test_country_is_not_limited_to_selling_countries(): void {
+		update_option( 'woocommerce_allowed_countries', 'specific' );
+		update_option( 'woocommerce_specific_allowed_countries', array( 'US' ) );
+
+		$this->assertSame( 'RO', CustomsDataValidator::normalize_country_of_origin( 'ro' ), 'An origin country need not be a selling destination.' );
+	}
+
+	/**
+	 * @testdox Should allow thirty-five Unicode code points.
+	 */
+	public function test_description_limit_counts_unicode_code_points(): void {
+		$value = str_repeat( 'é', 35 );
+
+		$this->assertSame( $value, CustomsDataValidator::normalize_description( ' ' . $value . ' ' ), 'Multibyte characters should count once.' );
+		$this->expectException( WC_Data_Exception::class );
+		CustomsDataValidator::normalize_description( $value . 'é' );
+	}
+
+	/**
+	 * @testdox Should reject descriptions that are not valid UTF-8.
+	 */
+	public function test_description_rejects_invalid_utf8(): void {
+		$this->expectException( WC_Data_Exception::class );
+		CustomsDataValidator::normalize_description( "Cotton \xC3\x28" );
+	}
+
+	/**
+	 * @testdox Should reject an origin country that is not valid UTF-8 instead of clearing it.
+	 */
+	public function test_country_of_origin_rejects_invalid_utf8(): void {
+		$this->expectException( WC_Data_Exception::class );
+		CustomsDataValidator::normalize_country_of_origin( " \xC3\x28 " );
+	}
+
+	/**
+	 * @testdox Should lightly normalize stored values without validating them.
+	 * @testWith ["customs_commodity_code", " 12 ", "12"]
+	 *           ["customs_country_of_origin", " zz ", "ZZ"]
+	 *           ["customs_description", " <b>Anything goes, even when it is longer than 35 characters</b> ", "<b>Anything goes, even when it is longer than 35 characters</b>"]
+	 *           ["customs_description", " \t", null]
+	 *           ["customs_commodity_code", 123456, null]
+	 * @param string      $field Customs prop name.
+	 * @param mixed       $value Stored value.
+	 * @param string|null $expected Normalized value.
+	 */
+	public function test_normalize_stored_value( string $field, $value, ?string $expected ): void {
+		$this->assertSame( $expected, CustomsDataValidator::normalize_stored_value( $field, $value ), 'Stored values should only be trimmed, uppercased for countries, and blank or non-string values cleared.' );
+	}
+
+	/**
+	 * @testdox Should preserve explicit clears and omit fields outside the requested customs subset.
+	 */
+	public function test_normalizes_requested_subset(): void {
+		$this->assertSame(
+			array(
+				'customs_commodity_code' => '010203',
+				'customs_description'    => null,
+			),
+			CustomsDataValidator::normalize_fields(
+				array(
+					'customs_commodity_code' => '01.02.03',
+					'customs_description'    => '',
+					'name'                   => 'Shirt',
+				)
+			),
+			'Only supplied customs fields should be returned, including explicit nulls.'
+		);
+	}
+
+	/**
+	 * @testdox Should reject a batch when a later customs field is invalid.
+	 */
+	public function test_validates_entire_subset(): void {
+		$this->expectException( WC_Data_Exception::class );
+		CustomsDataValidator::normalize_fields(
+			array(
+				'customs_commodity_code'    => '010203',
+				'customs_country_of_origin' => 'ZZ',
+			)
+		);
+	}
+}