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',
+ )
+ );
+ }
+}