Commit 41010f67eee for woocommerce

commit 41010f67eee0ed04695f5203f8ca4f3b86a0fc89
Author: Bogdan Ungureanu <bogdanungureanu21@gmail.com>
Date:   Tue Oct 6 14:13:59 2026 +0300

    Add customs fields to the classic product and variation editors (#69166)

    * 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".

    * Add customs fields to the products and variations REST API

    * Update REST customs tests for angle-bracket rejection

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

    * Add customs fields to the classic product and variation editors

    * Flag over-long customs values instead of cutting them, and fix tip clipping

    A 15-digit commodity code was cut to a different valid code, and long
    descriptions were cut to 35 characters with no warning. Both are now
    flagged and left as typed. The panel overflow rules let the tab column
    background escape the box, so reserve fixed space under the customs
    section for the description tip instead.

diff --git a/plugins/woocommerce/changelog/feat-wooplug-5501-customs-3-editor b/plugins/woocommerce/changelog/feat-wooplug-5501-customs-3-editor
new file mode 100644
index 00000000000..bcb603e4103
--- /dev/null
+++ b/plugins/woocommerce/changelog/feat-wooplug-5501-customs-3-editor
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add commodity code, country of origin and customs description fields to the product and variation editors.
diff --git a/plugins/woocommerce/client/legacy/css/admin.scss b/plugins/woocommerce/client/legacy/css/admin.scss
index b02a9a6818e..06c81c6799a 100644
--- a/plugins/woocommerce/client/legacy/css/admin.scss
+++ b/plugins/woocommerce/client/legacy/css/admin.scss
@@ -7150,6 +7150,23 @@ img.help_tip {
 	}
 }

+.wc-product-customs {
+
+	.woocommerce_options_panel & > h4 {
+		padding-inline: 12px;
+	}
+
+	// Leave room for the error tip under the description, the last field in the Shipping panel, which the panel wrapper would otherwise clip.
+	#shipping_product_data & {
+		padding-block-end: 4em;
+	}
+
+	.woocommerce_variation & {
+		border-top: 1px solid #eee;
+		margin-top: 12px;
+	}
+}
+
 .woocommerce_variable_attributes {
 	background-color: #fdfdfd;
 	border-top: 1px solid #eee;
diff --git a/plugins/woocommerce/client/legacy/js/admin/woocommerce_admin.js b/plugins/woocommerce/client/legacy/js/admin/woocommerce_admin.js
index 5a1c9ef6a3f..4f5ca0a0ea9 100644
--- a/plugins/woocommerce/client/legacy/js/admin/woocommerce_admin.js
+++ b/plugins/woocommerce/client/legacy/js/admin/woocommerce_admin.js
@@ -354,6 +354,77 @@
 				}
 			)

+			// Commodity codes allow any punctuation and spacing merchants copy them with, such as 0901.21.0010,
+			// matching the backend. Codes over 14 digits are flagged rather than cut, so a different valid code
+			// isn't saved. Too-short codes are only flagged on change, so the tip doesn't show while typing.
+			.on(
+				'input',
+				'input[type=text][name*=_customs_commodity_code]',
+				function () {
+					var value = $( this ).val();
+
+					$( document.body ).triggerHandler(
+						( value.match( /[0-9]/g ) || [] ).length > 14 ||
+							new RegExp( '[^0-9\\p{P}\\s]', 'u' ).test( value )
+							? 'wc_add_error_tip'
+							: 'wc_remove_error_tip',
+						[ $( this ), 'i18n_commodity_code_error' ]
+					);
+				}
+			)
+
+			// Customs descriptions allow only letters, digits, spaces, punctuation and printable ASCII symbols other
+			// than < and >, so emoji and symbols such as ™ are dropped. RegExp() keeps the legacy ES5 parser from
+			// rejecting the u flag. Text over 35 code points is flagged rather than cut, counting like the backend's
+			// mb_strlen(); maxlength would count UTF-16 units.
+			.on(
+				'input',
+				'input[type=text][name*=_customs_description]',
+				function () {
+					var value = $( this ).val();
+					var disallowed = new RegExp(
+						'[<>]|[^\\x20-\\x7E\\p{L}\\p{Mn}\\p{Mc}\\p{N}\\p{P}\\s]|[\\uFE00-\\uFE0F\\u{E0100}-\\u{E01EF}]',
+						'gu'
+					);
+					var cleaned = value.replace( disallowed, '' );
+
+					if ( cleaned !== value ) {
+						var caret = value
+							.slice( 0, this.selectionStart )
+							.replace( disallowed, '' ).length;
+						$( this ).val( cleaned );
+						this.setSelectionRange( caret, caret );
+					}
+
+					$( document.body ).triggerHandler(
+						cleaned !== value || Array.from( cleaned ).length > 35
+							? 'wc_add_error_tip'
+							: 'wc_remove_error_tip',
+						[ $( this ), 'i18n_customs_description_error' ]
+					);
+				}
+			)
+
+			// Remove the punctuation and spacing the backend removes. Letters and symbols are kept so the
+			// code stays visibly invalid, and a code with them or without 6 to 14 digits is flagged.
+			.on(
+				'change',
+				'input[type=text][name*=_customs_commodity_code]',
+				function () {
+					var cleaned = $( this )
+						.val()
+						.replace( new RegExp( '[\\p{P}\\s]', 'gu' ), '' );
+					$( this ).val( cleaned );
+
+					$( document.body ).triggerHandler(
+						/^([0-9]{6,14})?$/.test( cleaned )
+							? 'wc_remove_error_tip'
+							: 'wc_add_error_tip',
+						[ $( this ), 'i18n_commodity_code_error' ]
+					);
+				}
+			)
+
 			.on( 'init_tooltips', function () {
 				$( '.tips, .help_tip, .woocommerce-help-tip' ).tipTip( {
 					attribute: 'data-tip',
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
index aea5fa1b8a2..3924ec1fe7a 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
@@ -499,6 +499,8 @@ if ( ! class_exists( 'WC_Admin_Assets', false ) ) :
 					'i18n_remove_personal_data_notice'  => __( 'This action cannot be reversed. Are you sure you wish to erase personal data from the selected orders?', 'woocommerce' ),
 					'i18n_confirm_delete'               => __( 'Are you sure you wish to delete this item?', 'woocommerce' ),
 					'i18n_global_unique_id_error'       => __( 'Enter only numbers and hyphens (-). The letter X is allowed only as the final ISBN-10 check digit.', 'woocommerce' ),
+					'i18n_commodity_code_error'         => __( 'Enter a commodity code of 6–14 digits. Spaces and punctuation are removed.', 'woocommerce' ),
+					'i18n_customs_description_error'    => __( 'Enter up to 35 characters, without emoji or special symbols such as ™.', 'woocommerce' ),
 					'decimal_point'                     => $decimal,
 					'mon_decimal_point'                 => wc_get_price_decimal_separator(),
 					'ajax_url'                          => admin_url( 'admin-ajax.php' ),
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php
index b59e4c71c9f..2d2055a3724 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php
@@ -373,46 +373,49 @@ class WC_Meta_Box_Product_Data {

 		$errors = $product->set_props(
 			array(
-				'sku'                => isset( $_POST['_sku'] ) ? wc_clean( wp_unslash( $_POST['_sku'] ) ) : null,
-				'global_unique_id'   => isset( $_POST['_global_unique_id'] ) ? wc_clean( wp_unslash( $_POST['_global_unique_id'] ) ) : null,
-				'purchase_note'      => isset( $_POST['_purchase_note'] ) ? wp_kses_post( wp_unslash( $_POST['_purchase_note'] ) ) : '',
-				'downloadable'       => isset( $_POST['_downloadable'] ),
-				'virtual'            => isset( $_POST['_virtual'] ),
-				'featured'           => isset( $_POST['_featured'] ),
-				'catalog_visibility' => isset( $_POST['_visibility'] ) ? wc_clean( wp_unslash( $_POST['_visibility'] ) ) : null,
-				'tax_status'         => isset( $_POST['_tax_status'] ) ? wc_clean( wp_unslash( $_POST['_tax_status'] ) ) : null,
-				'tax_class'          => isset( $_POST['_tax_class'] ) ? sanitize_title( wp_unslash( $_POST['_tax_class'] ) ) : null,
-				'weight'             => isset( $_POST['_weight'] ) ? wc_clean( wp_unslash( $_POST['_weight'] ) ) : null,
-				'length'             => isset( $_POST['_length'] ) ? wc_clean( wp_unslash( $_POST['_length'] ) ) : null,
-				'width'              => isset( $_POST['_width'] ) ? wc_clean( wp_unslash( $_POST['_width'] ) ) : null,
-				'height'             => isset( $_POST['_height'] ) ? wc_clean( wp_unslash( $_POST['_height'] ) ) : null,
-				'shipping_class_id'  => isset( $_POST['product_shipping_class'] ) ? absint( wp_unslash( $_POST['product_shipping_class'] ) ) : null,
-				'sold_individually'  => ! empty( $_POST['_sold_individually'] ),
-				'upsell_ids'         => isset( $_POST['upsell_ids'] ) ? array_map( 'intval', (array) wp_unslash( $_POST['upsell_ids'] ) ) : array(),
-				'cross_sell_ids'     => isset( $_POST['crosssell_ids'] ) ? array_map( 'intval', (array) wp_unslash( $_POST['crosssell_ids'] ) ) : array(),
-				'regular_price'      => isset( $_POST['_regular_price'] ) ? wc_clean( wp_unslash( $_POST['_regular_price'] ) ) : null,
-				'sale_price'         => isset( $_POST['_sale_price'] ) ? wc_clean( wp_unslash( $_POST['_sale_price'] ) ) : null,
-				'date_on_sale_from'  => $date_on_sale_from,
-				'date_on_sale_to'    => $date_on_sale_to,
-				'manage_stock'       => ! empty( $_POST['_manage_stock'] ),
-				'backorders'         => isset( $_POST['_backorders'] ) ? wc_clean( wp_unslash( $_POST['_backorders'] ) ) : null,
-				'stock_status'       => isset( $_POST['_stock_status'] ) ? wc_clean( wp_unslash( $_POST['_stock_status'] ) ) : null,
-				'stock_quantity'     => $stock,
-				'low_stock_amount'   => isset( $_POST['_low_stock_amount'] ) && '' !== $_POST['_low_stock_amount'] ? wc_stock_amount( wp_unslash( $_POST['_low_stock_amount'] ) ) : '',
-				'download_limit'     => isset( $_POST['_download_limit'] ) && '' !== $_POST['_download_limit'] ? absint( wp_unslash( $_POST['_download_limit'] ) ) : '',
-				'download_expiry'    => isset( $_POST['_download_expiry'] ) && '' !== $_POST['_download_expiry'] ? absint( wp_unslash( $_POST['_download_expiry'] ) ) : '',
+				'sku'                       => isset( $_POST['_sku'] ) ? wc_clean( wp_unslash( $_POST['_sku'] ) ) : null,
+				'global_unique_id'          => isset( $_POST['_global_unique_id'] ) ? wc_clean( wp_unslash( $_POST['_global_unique_id'] ) ) : null,
+				'purchase_note'             => isset( $_POST['_purchase_note'] ) ? wp_kses_post( wp_unslash( $_POST['_purchase_note'] ) ) : '',
+				'downloadable'              => isset( $_POST['_downloadable'] ),
+				'virtual'                   => isset( $_POST['_virtual'] ),
+				'featured'                  => isset( $_POST['_featured'] ),
+				'catalog_visibility'        => isset( $_POST['_visibility'] ) ? wc_clean( wp_unslash( $_POST['_visibility'] ) ) : null,
+				'tax_status'                => isset( $_POST['_tax_status'] ) ? wc_clean( wp_unslash( $_POST['_tax_status'] ) ) : null,
+				'tax_class'                 => isset( $_POST['_tax_class'] ) ? sanitize_title( wp_unslash( $_POST['_tax_class'] ) ) : null,
+				'weight'                    => isset( $_POST['_weight'] ) ? wc_clean( wp_unslash( $_POST['_weight'] ) ) : null,
+				'length'                    => isset( $_POST['_length'] ) ? wc_clean( wp_unslash( $_POST['_length'] ) ) : null,
+				'width'                     => isset( $_POST['_width'] ) ? wc_clean( wp_unslash( $_POST['_width'] ) ) : null,
+				'height'                    => isset( $_POST['_height'] ) ? wc_clean( wp_unslash( $_POST['_height'] ) ) : null,
+				'shipping_class_id'         => isset( $_POST['product_shipping_class'] ) ? absint( wp_unslash( $_POST['product_shipping_class'] ) ) : null,
+				'customs_commodity_code'    => isset( $_POST['_customs_commodity_code'] ) ? wp_unslash( $_POST['_customs_commodity_code'] ) : null, // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated by the customs setters.
+				'customs_country_of_origin' => isset( $_POST['_customs_country_of_origin'] ) ? wp_unslash( $_POST['_customs_country_of_origin'] ) : null, // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated by the customs setters.
+				'customs_description'       => isset( $_POST['_customs_description'] ) ? wp_unslash( $_POST['_customs_description'] ) : null, // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated by the customs setters.
+				'sold_individually'         => ! empty( $_POST['_sold_individually'] ),
+				'upsell_ids'                => isset( $_POST['upsell_ids'] ) ? array_map( 'intval', (array) wp_unslash( $_POST['upsell_ids'] ) ) : array(),
+				'cross_sell_ids'            => isset( $_POST['crosssell_ids'] ) ? array_map( 'intval', (array) wp_unslash( $_POST['crosssell_ids'] ) ) : array(),
+				'regular_price'             => isset( $_POST['_regular_price'] ) ? wc_clean( wp_unslash( $_POST['_regular_price'] ) ) : null,
+				'sale_price'                => isset( $_POST['_sale_price'] ) ? wc_clean( wp_unslash( $_POST['_sale_price'] ) ) : null,
+				'date_on_sale_from'         => $date_on_sale_from,
+				'date_on_sale_to'           => $date_on_sale_to,
+				'manage_stock'              => ! empty( $_POST['_manage_stock'] ),
+				'backorders'                => isset( $_POST['_backorders'] ) ? wc_clean( wp_unslash( $_POST['_backorders'] ) ) : null,
+				'stock_status'              => isset( $_POST['_stock_status'] ) ? wc_clean( wp_unslash( $_POST['_stock_status'] ) ) : null,
+				'stock_quantity'            => $stock,
+				'low_stock_amount'          => isset( $_POST['_low_stock_amount'] ) && '' !== $_POST['_low_stock_amount'] ? wc_stock_amount( wp_unslash( $_POST['_low_stock_amount'] ) ) : '',
+				'download_limit'            => isset( $_POST['_download_limit'] ) && '' !== $_POST['_download_limit'] ? absint( wp_unslash( $_POST['_download_limit'] ) ) : '',
+				'download_expiry'           => isset( $_POST['_download_expiry'] ) && '' !== $_POST['_download_expiry'] ? absint( wp_unslash( $_POST['_download_expiry'] ) ) : '',
 				// Those are sanitized inside prepare_downloads.
-				'downloads'          => self::prepare_downloads(
+				'downloads'                 => self::prepare_downloads(
 					isset( $_POST['_wc_file_names'] ) ? wp_unslash( $_POST['_wc_file_names'] ) : array(), // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
 					isset( $_POST['_wc_file_urls'] ) ? wp_unslash( $_POST['_wc_file_urls'] ) : array(), // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
 					isset( $_POST['_wc_file_hashes'] ) ? wp_unslash( $_POST['_wc_file_hashes'] ) : array() // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
 				),
-				'product_url'        => isset( $_POST['_product_url'] ) ? esc_url_raw( wp_unslash( $_POST['_product_url'] ) ) : '',
-				'button_text'        => isset( $_POST['_button_text'] ) ? wc_clean( wp_unslash( $_POST['_button_text'] ) ) : '',
-				'children'           => ProductType::GROUPED === $product_type ? self::prepare_children() : null,
-				'reviews_allowed'    => ! empty( $_POST['comment_status'] ) && 'open' === $_POST['comment_status'],
-				'attributes'         => $attributes,
-				'default_attributes' => self::prepare_set_attributes( $attributes, 'default_attribute_' ),
+				'product_url'               => isset( $_POST['_product_url'] ) ? esc_url_raw( wp_unslash( $_POST['_product_url'] ) ) : '',
+				'button_text'               => isset( $_POST['_button_text'] ) ? wc_clean( wp_unslash( $_POST['_button_text'] ) ) : '',
+				'children'                  => ProductType::GROUPED === $product_type ? self::prepare_children() : null,
+				'reviews_allowed'           => ! empty( $_POST['comment_status'] ) && 'open' === $_POST['comment_status'],
+				'attributes'                => $attributes,
+				'default_attributes'        => self::prepare_set_attributes( $attributes, 'default_attribute_' ),
 			)
 		);

@@ -572,38 +575,41 @@ class WC_Meta_Box_Product_Data {

 				$errors = $variation->set_props(
 					array(
-						'status'            => isset( $_POST['variable_enabled'][ $i ] ) ? ProductStatus::PUBLISH : ProductStatus::PRIVATE,
-						'menu_order'        => isset( $_POST['variation_menu_order'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variation_menu_order'][ $i ] ) ) : null,
-						'regular_price'     => isset( $_POST['variable_regular_price'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_regular_price'][ $i ] ) ) : null,
-						'sale_price'        => isset( $_POST['variable_sale_price'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_sale_price'][ $i ] ) ) : null,
-						'virtual'           => isset( $_POST['variable_is_virtual'][ $i ] ),
-						'downloadable'      => isset( $_POST['variable_is_downloadable'][ $i ] ),
-						'date_on_sale_from' => $date_on_sale_from,
-						'date_on_sale_to'   => $date_on_sale_to,
-						'description'       => isset( $_POST['variable_description'][ $i ] ) ? wp_kses_post( wp_unslash( $_POST['variable_description'][ $i ] ) ) : null,
-						'download_limit'    => isset( $_POST['variable_download_limit'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_download_limit'][ $i ] ) ) : null,
-						'download_expiry'   => isset( $_POST['variable_download_expiry'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_download_expiry'][ $i ] ) ) : null,
+						'status'                    => isset( $_POST['variable_enabled'][ $i ] ) ? ProductStatus::PUBLISH : ProductStatus::PRIVATE,
+						'menu_order'                => isset( $_POST['variation_menu_order'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variation_menu_order'][ $i ] ) ) : null,
+						'regular_price'             => isset( $_POST['variable_regular_price'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_regular_price'][ $i ] ) ) : null,
+						'sale_price'                => isset( $_POST['variable_sale_price'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_sale_price'][ $i ] ) ) : null,
+						'virtual'                   => isset( $_POST['variable_is_virtual'][ $i ] ),
+						'downloadable'              => isset( $_POST['variable_is_downloadable'][ $i ] ),
+						'date_on_sale_from'         => $date_on_sale_from,
+						'date_on_sale_to'           => $date_on_sale_to,
+						'description'               => isset( $_POST['variable_description'][ $i ] ) ? wp_kses_post( wp_unslash( $_POST['variable_description'][ $i ] ) ) : null,
+						'download_limit'            => isset( $_POST['variable_download_limit'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_download_limit'][ $i ] ) ) : null,
+						'download_expiry'           => isset( $_POST['variable_download_expiry'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_download_expiry'][ $i ] ) ) : null,
 						// Those are sanitized inside prepare_downloads.
-						'downloads'         => self::prepare_downloads(
+						'downloads'                 => self::prepare_downloads(
 							isset( $_POST['_wc_variation_file_names'][ $variation_id ] ) ? wp_unslash( $_POST['_wc_variation_file_names'][ $variation_id ] ) : array(), // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
 							isset( $_POST['_wc_variation_file_urls'][ $variation_id ] ) ? wp_unslash( $_POST['_wc_variation_file_urls'][ $variation_id ] ) : array(), // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
 							isset( $_POST['_wc_variation_file_hashes'][ $variation_id ] ) ? wp_unslash( $_POST['_wc_variation_file_hashes'][ $variation_id ] ) : array() // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
 						),
-						'manage_stock'      => isset( $_POST['variable_manage_stock'][ $i ] ),
-						'stock_quantity'    => $stock,
-						'low_stock_amount'  => isset( $_POST['variable_low_stock_amount'][ $i ] ) && '' !== $_POST['variable_low_stock_amount'][ $i ] ? wc_stock_amount( wp_unslash( $_POST['variable_low_stock_amount'][ $i ] ) ) : '',
-						'backorders'        => isset( $_POST['variable_backorders'], $_POST['variable_backorders'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_backorders'][ $i ] ) ) : null,
-						'stock_status'      => isset( $_POST['variable_stock_status'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_stock_status'][ $i ] ) ) : null,
-						'image_id'          => isset( $_POST['upload_image_id'][ $i ] ) ? wc_clean( wp_unslash( $_POST['upload_image_id'][ $i ] ) ) : null,
-						'attributes'        => self::prepare_set_attributes( $parent->get_attributes(), 'attribute_', $i ),
-						'sku'               => isset( $_POST['variable_sku'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_sku'][ $i ] ) ) : '',
-						'global_unique_id'  => isset( $_POST['variable_global_unique_id'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_global_unique_id'][ $i ] ) ) : '',
-						'weight'            => isset( $_POST['variable_weight'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_weight'][ $i ] ) ) : '',
-						'length'            => isset( $_POST['variable_length'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_length'][ $i ] ) ) : '',
-						'width'             => isset( $_POST['variable_width'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_width'][ $i ] ) ) : '',
-						'height'            => isset( $_POST['variable_height'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_height'][ $i ] ) ) : '',
-						'shipping_class_id' => isset( $_POST['variable_shipping_class'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_shipping_class'][ $i ] ) ) : null,
-						'tax_class'         => isset( $_POST['variable_tax_class'][ $i ] ) ? sanitize_title( wp_unslash( $_POST['variable_tax_class'][ $i ] ) ) : null,
+						'manage_stock'              => isset( $_POST['variable_manage_stock'][ $i ] ),
+						'stock_quantity'            => $stock,
+						'low_stock_amount'          => isset( $_POST['variable_low_stock_amount'][ $i ] ) && '' !== $_POST['variable_low_stock_amount'][ $i ] ? wc_stock_amount( wp_unslash( $_POST['variable_low_stock_amount'][ $i ] ) ) : '',
+						'backorders'                => isset( $_POST['variable_backorders'], $_POST['variable_backorders'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_backorders'][ $i ] ) ) : null,
+						'stock_status'              => isset( $_POST['variable_stock_status'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_stock_status'][ $i ] ) ) : null,
+						'image_id'                  => isset( $_POST['upload_image_id'][ $i ] ) ? wc_clean( wp_unslash( $_POST['upload_image_id'][ $i ] ) ) : null,
+						'attributes'                => self::prepare_set_attributes( $parent->get_attributes(), 'attribute_', $i ),
+						'sku'                       => isset( $_POST['variable_sku'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_sku'][ $i ] ) ) : '',
+						'global_unique_id'          => isset( $_POST['variable_global_unique_id'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_global_unique_id'][ $i ] ) ) : '',
+						'weight'                    => isset( $_POST['variable_weight'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_weight'][ $i ] ) ) : '',
+						'length'                    => isset( $_POST['variable_length'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_length'][ $i ] ) ) : '',
+						'width'                     => isset( $_POST['variable_width'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_width'][ $i ] ) ) : '',
+						'height'                    => isset( $_POST['variable_height'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_height'][ $i ] ) ) : '',
+						'shipping_class_id'         => isset( $_POST['variable_shipping_class'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_shipping_class'][ $i ] ) ) : null,
+						'customs_commodity_code'    => isset( $_POST['variable_customs_commodity_code'][ $i ] ) ? wp_unslash( $_POST['variable_customs_commodity_code'][ $i ] ) : null, // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated by the customs setters.
+						'customs_country_of_origin' => isset( $_POST['variable_customs_country_of_origin'][ $i ] ) ? wp_unslash( $_POST['variable_customs_country_of_origin'][ $i ] ) : null, // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated by the customs setters.
+						'customs_description'       => isset( $_POST['variable_customs_description'][ $i ] ) ? wp_unslash( $_POST['variable_customs_description'][ $i ] ) : null, // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated by the customs setters.
+						'tax_class'                 => isset( $_POST['variable_tax_class'][ $i ] ) ? sanitize_title( wp_unslash( $_POST['variable_tax_class'][ $i ] ) ) : null,
 					)
 				);

diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-shipping.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-shipping.php
index 9dd7afd74b4..2031d274803 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-shipping.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-shipping.php
@@ -1,4 +1,5 @@
 <?php
+use Automattic\WooCommerce\Internal\ProductCustoms\ClassicEditorFields;
 use Automattic\WooCommerce\Utilities\I18nUtil;

 if ( ! defined( 'ABSPATH' ) ) {
@@ -76,5 +77,12 @@ if ( ! defined( 'ABSPATH' ) ) {
 		do_action( 'woocommerce_product_options_shipping' );
 		?>
 	</div>
+	<div class="options_group">
+		<?php
+		if ( isset( $product_object ) ) {
+			wc_get_container()->get( ClassicEditorFields::class )->render_fields( $product_object );
+		}
+		?>
+	</div>
 	<?php do_action( 'woocommerce_product_options_shipping_product_data' ); ?>
 </div>
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php
index 1f125d2fbd6..03819ce8c52 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php
@@ -9,6 +9,7 @@
  * @var array $variation_data array of variation data @deprecated 4.4.0.
  */

+use Automattic\WooCommerce\Internal\ProductCustoms\ClassicEditorFields;
 use Automattic\WooCommerce\Utilities\I18nUtil;

 defined( 'ABSPATH' ) || exit;
@@ -431,6 +432,10 @@ defined( 'ABSPATH' ) || exit;
 				</p>

 				<?php
+				if ( isset( $product_object, $loop ) ) {
+					wc_get_container()->get( ClassicEditorFields::class )->render_fields( $variation_object, $product_object, $loop );
+				}
+
 				if ( wc_tax_enabled() ) {
 					woocommerce_wp_select(
 						array(
diff --git a/plugins/woocommerce/src/Internal/ProductCustoms/ClassicEditorFields.php b/plugins/woocommerce/src/Internal/ProductCustoms/ClassicEditorFields.php
new file mode 100644
index 00000000000..0c739b41dbe
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductCustoms/ClassicEditorFields.php
@@ -0,0 +1,107 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\ProductCustoms;
+
+use WC_Product;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Renders the customs fields in the classic product and variation editors.
+ */
+final class ClassicEditorFields {
+
+	/**
+	 * Outputs the customs fields for a product, or for a variation when a parent and loop index are given.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param WC_Product      $product        Product being edited.
+	 * @param WC_Product|null $parent_product Parent product whose values a variation inherits.
+	 * @param int|null        $loop           Variation index, or null for the parent product.
+	 * @return void
+	 */
+	public function render_fields( WC_Product $product, ?WC_Product $parent_product = null, ?int $loop = null ): void {
+		$countries      = WC()->countries->get_countries();
+		$parent_code    = $parent_product ? $parent_product->get_customs_commodity_code( 'edit' ) : null;
+		$parent_country = $parent_product ? $parent_product->get_customs_country_of_origin( 'edit' ) : null;
+		$parent_desc    = $parent_product ? $parent_product->get_customs_description( 'edit' ) : null;
+		?>
+		<div class="wc-product-customs <?php echo null !== $loop ? 'hide_if_variation_virtual' : ''; ?>">
+			<h4><?php esc_html_e( 'Customs', 'woocommerce' ); ?></h4>
+			<?php
+			woocommerce_wp_text_input(
+				$this->get_field_args( 'customs_commodity_code', $loop ) + array(
+					'value'       => $product->get_customs_commodity_code( 'edit' ) ?? '',
+					'label'       => __( 'Commodity code', 'woocommerce' ),
+					'description' => $this->get_help_text( __( 'The HS (Harmonized System) code used to classify this product for customs. Enter 6–14 digits; spaces and punctuation are removed.', 'woocommerce' ), $parent_code ),
+					'placeholder' => $parent_code ?? '',
+				),
+				$product
+			);
+
+			woocommerce_wp_select(
+				$this->get_field_args( 'customs_country_of_origin', $loop ) + array(
+					'value'       => $product->get_customs_country_of_origin( 'edit' ) ?? '',
+					'label'       => __( 'Country of origin', 'woocommerce' ),
+					'description' => $this->get_help_text( __( 'The country where this product was manufactured or produced.', 'woocommerce' ), null === $parent_country ? null : ( $countries[ $parent_country ] ?? $parent_country ) ),
+					'options'     => array( '' => null !== $parent_country ? __( 'Same as parent', 'woocommerce' ) : __( 'Select a country', 'woocommerce' ) ) + $countries,
+				),
+				$product
+			);
+
+			woocommerce_wp_text_input(
+				$this->get_field_args( 'customs_description', $loop ) + array(
+					'value'       => $product->get_customs_description( 'edit' ) ?? '',
+					'label'       => __( 'Customs description', 'woocommerce' ),
+					'description' => $this->get_help_text( __( 'A plain-text description for customs forms, up to 35 characters. Emoji and special symbols are not allowed.', 'woocommerce' ), $parent_desc ),
+					'placeholder' => $parent_desc ?? '',
+				),
+				$product
+			);
+			?>
+		</div>
+		<?php
+	}
+
+	/**
+	 * Field arguments that differ between the product form and a variation row.
+	 *
+	 * @param string   $property Customs prop name.
+	 * @param int|null $loop     Variation index, or null for the parent product.
+	 * @return array
+	 */
+	private function get_field_args( string $property, ?int $loop ): array {
+		if ( null === $loop ) {
+			return array(
+				'id'       => '_' . $property,
+				'name'     => '_' . $property,
+				'desc_tip' => true,
+			);
+		}
+
+		return array(
+			'id'            => 'variable_' . $property . $loop,
+			'name'          => 'variable_' . $property . '[' . $loop . ']',
+			'wrapper_class' => 'form-row form-row-full',
+			'desc_tip'      => true,
+		);
+	}
+
+	/**
+	 * Appends the inherited parent value to a field's help text, when there is one.
+	 *
+	 * @param string      $description Field help text.
+	 * @param string|null $inherited   Parent value shown to the merchant, or null.
+	 * @return string
+	 */
+	private function get_help_text( string $description, ?string $inherited ): string {
+		if ( null === $inherited ) {
+			return $description;
+		}
+
+		/* translators: %s: Customs value inherited from the parent product. */
+		return $description . ' ' . sprintf( __( 'Leave blank to inherit from the product: %s.', 'woocommerce' ), $inherited );
+	}
+}
diff --git a/plugins/woocommerce/tests/e2e/tests/product/product-customs.spec.ts b/plugins/woocommerce/tests/e2e/tests/product/product-customs.spec.ts
new file mode 100644
index 00000000000..0341183ac42
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/tests/product/product-customs.spec.ts
@@ -0,0 +1,193 @@
+/**
+ * External dependencies
+ */
+// eslint-disable-next-line import/no-unresolved -- The E2E runner resolves this workspace package through its wc-source export.
+import { WC_API_PATH } from '@woocommerce/e2e-utils-playwright';
+
+/**
+ * Internal dependencies
+ */
+import { test, expect, tags } from '../../fixtures/fixtures';
+import { ADMIN_STATE_PATH } from '../../playwright.config';
+
+test.describe( 'Product customs fields', { tag: [ tags.GUTENBERG ] }, () => {
+	test.use( { storageState: ADMIN_STATE_PATH } );
+
+	test( 'saves, validates and clears customs fields in the classic editor', async ( {
+		page,
+		restApi,
+	} ) => {
+		const { data: product } = await restApi.post(
+			`${ WC_API_PATH }/products`,
+			{
+				name: 'Customs editor product',
+				type: 'simple',
+				regular_price: '10',
+			}
+		);
+		try {
+			await page.goto(
+				`wp-admin/post.php?post=${ product.id }&action=edit`
+			);
+			await page.locator( '.shipping_tab a' ).click();
+			const code = page.locator( '#_customs_commodity_code' );
+			const country = page.locator( '#_customs_country_of_origin' );
+			const description = page.locator( '#_customs_description' );
+			// Each 𝐀 is one code point but two UTF-16 units, so this checks the limit counts like the backend.
+			await description.fill( '𝐀'.repeat( 35 ) );
+			await expect(
+				page.locator( '.wc_error_tip.i18n_customs_description_error' )
+			).toBeHidden();
+			await description.fill( '𝐀'.repeat( 36 ) );
+			await expect( description ).toHaveValue( '𝐀'.repeat( 36 ) );
+			await expect(
+				page.locator( '.wc_error_tip.i18n_customs_description_error' )
+			).toBeVisible();
+			await description.clear();
+			await expect(
+				page.locator( '.wc_error_tip.i18n_customs_description_error' )
+			).toBeHidden();
+			await code.pressSequentially( '12AB' );
+			await expect(
+				page.locator( '.wc_error_tip.i18n_commodity_code_error' )
+			).toBeVisible();
+			await code.blur();
+			await expect( code ).toHaveValue( '12AB' );
+			await expect(
+				page.locator( '.wc_error_tip.i18n_commodity_code_error' )
+			).toBeVisible();
+
+			await code.clear();
+			await code.pressSequentially( '123456' );
+			await expect(
+				page.locator( '.wc_error_tip.i18n_commodity_code_error' )
+			).toBeHidden();
+			await code.pressSequentially( '789012345' );
+			await expect( code ).toHaveValue( '123456789012345' );
+			await expect(
+				page.locator( '.wc_error_tip.i18n_commodity_code_error' )
+			).toBeVisible();
+			await code.blur();
+			await expect( code ).toHaveValue( '123456789012345' );
+			await expect(
+				page.locator( '.wc_error_tip.i18n_commodity_code_error' )
+			).toBeVisible();
+
+			await code.fill( '0901/21,0010' );
+			await expect(
+				page.locator( '.wc_error_tip.i18n_commodity_code_error' )
+			).toBeHidden();
+			await code.blur();
+			await expect( code ).toHaveValue( '0901210010' );
+			await expect(
+				page.locator( '.wc_error_tip.i18n_commodity_code_error' )
+			).toBeHidden();
+
+			await description.pressSequentially( 'Cotton 👕' );
+			await expect( description ).toHaveValue( 'Cotton ' );
+			await expect(
+				page.locator( '.wc_error_tip.i18n_customs_description_error' )
+			).toBeVisible();
+			await description.fill( 'Size<M' );
+			await expect( description ).toHaveValue( 'SizeM' );
+			await expect(
+				page.locator( '.wc_error_tip.i18n_customs_description_error' )
+			).toBeVisible();
+
+			await country.selectOption( 'RO' );
+			await description.fill( 'Cotton shirt' );
+			await code.fill( '0012.34' );
+			await code.blur();
+			await expect( code ).toHaveValue( '001234' );
+			await page.locator( '#publish' ).click();
+			await expect( page.getByText( 'Product updated.' ) ).toBeVisible();
+			await page.reload();
+			await page.locator( '.shipping_tab a' ).click();
+			await expect( code ).toHaveValue( '001234' );
+			await expect( country ).toHaveValue( 'RO' );
+			await expect( description ).toHaveValue( 'Cotton shirt' );
+
+			await code.fill( '' );
+			await country.selectOption( '' );
+			await description.fill( '' );
+			await page.locator( '#publish' ).click();
+			await expect( page.getByText( 'Product updated.' ) ).toBeVisible();
+			const { data: saved } = await restApi.get(
+				`${ WC_API_PATH }/products/${ product.id }`
+			);
+			expect( saved.customs_commodity_code ).toBeNull();
+			expect( saved.customs_country_of_origin ).toBeNull();
+			expect( saved.customs_description ).toBeNull();
+		} finally {
+			await restApi.delete( `${ WC_API_PATH }/products/${ product.id }`, {
+				force: true,
+			} );
+		}
+	} );
+
+	test( 'shows inherited values and saves variation overrides', async ( {
+		page,
+		restApi,
+	} ) => {
+		const { data: product } = await restApi.post(
+			`${ WC_API_PATH }/products`,
+			{
+				name: 'Customs variation product',
+				type: 'variable',
+				customs_commodity_code: '001234',
+				customs_country_of_origin: 'RO',
+				customs_description: 'Cotton shirt',
+				attributes: [
+					{ name: 'Size', variation: true, options: [ 'Small' ] },
+				],
+			}
+		);
+		try {
+			const { data: variation } = await restApi.post(
+				`${ WC_API_PATH }/products/${ product.id }/variations`,
+				{
+					regular_price: '10',
+					attributes: [ { name: 'Size', option: 'Small' } ],
+				}
+			);
+			await page.goto(
+				`wp-admin/post.php?post=${ product.id }&action=edit`
+			);
+			await page.locator( '.variations_tab a' ).click();
+			const row = page.locator( '.woocommerce_variation' ).first();
+			await row.locator( 'h3' ).click();
+			const code = row.locator(
+				'[name^="variable_customs_commodity_code"]'
+			);
+			const country = row.locator(
+				'[name^="variable_customs_country_of_origin"]'
+			);
+			const description = row.locator(
+				'[name^="variable_customs_description"]'
+			);
+			await expect( code ).toHaveValue( '' );
+			await expect( code ).toHaveAttribute( 'placeholder', '001234' );
+			await expect( country.locator( 'option:checked' ) ).toHaveText(
+				'Same as parent'
+			);
+
+			await code.fill( '654321' );
+			await country.selectOption( 'US' );
+			await description.fill( 'Variation shirt' );
+			await description.blur();
+			await page.locator( '.save-variation-changes' ).click();
+			await expect( row ).not.toHaveClass( /variation-needs-update/ );
+			await expect( code ).toHaveValue( '654321' );
+			const { data: saved } = await restApi.get(
+				`${ WC_API_PATH }/products/${ product.id }/variations/${ variation.id }?context=edit`
+			);
+			expect( saved.customs_commodity_code ).toBe( '654321' );
+			expect( saved.customs_country_of_origin ).toBe( 'US' );
+			expect( saved.customs_description ).toBe( 'Variation shirt' );
+		} finally {
+			await restApi.delete( `${ WC_API_PATH }/products/${ product.id }`, {
+				force: true,
+			} );
+		}
+	} );
+} );
diff --git a/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-product-data-customs-test.php b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-product-data-customs-test.php
new file mode 100644
index 00000000000..e81466b12a0
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-product-data-customs-test.php
@@ -0,0 +1,221 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\MetaBoxes;
+
+use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\ProductCustoms\ClassicEditorFields;
+use WC_Admin_Meta_Boxes;
+use WC_Meta_Box_Product_Data;
+use WC_Product;
+use WC_Product_Simple;
+use WC_Product_Variable;
+use WC_Product_Variation;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests customs fields in the classic product editor.
+ */
+class WC_Meta_Box_Product_Data_Customs_Test extends WC_Unit_Test_Case {
+	/**
+	 * Original request data.
+	 *
+	 * @var array
+	 */
+	private $original_post;
+
+	/**
+	 * Original admin errors.
+	 *
+	 * @var array
+	 */
+	private $original_errors;
+
+	/**
+	 * Sets up request state.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->original_post                  = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Saves test request state.
+		$this->original_errors                = WC_Admin_Meta_Boxes::$meta_box_errors;
+		WC_Admin_Meta_Boxes::$meta_box_errors = array();
+	}
+
+	/**
+	 * Restores request state.
+	 */
+	public function tearDown(): void {
+		try {
+			$_POST                                = $this->original_post;
+			WC_Admin_Meta_Boxes::$meta_box_errors = $this->original_errors;
+		} finally {
+			parent::tearDown();
+		}
+	}
+
+	/**
+	 * @testdox Saves normalized customs fields and clears them explicitly.
+	 * @testWith [false]
+	 *           [true]
+	 * @param bool $variation Whether to save a variation.
+	 */
+	public function test_save_and_clear_customs( bool $variation ): void {
+		$product = $this->create_product( $variation );
+		$this->save_fields(
+			$product,
+			array(
+				'commodity_code'    => '0012.34',
+				'country_of_origin' => ' ro ',
+				'description'       => ' Cotton  shirt ',
+			)
+		);
+		$saved = wc_get_product( $product->get_id() );
+		$this->assertSame( '001234', $saved->get_customs_commodity_code( 'edit' ), 'Leading zeros must survive normalization.' );
+		$this->assertSame( 'RO', $saved->get_customs_country_of_origin( 'edit' ) );
+		$this->assertSame( 'Cotton shirt', $saved->get_customs_description( 'edit' ) );
+
+		$this->save_fields(
+			$product,
+			array(
+				'commodity_code'    => '',
+				'country_of_origin' => '',
+				'description'       => '',
+			)
+		);
+		$saved = wc_get_product( $product->get_id() );
+		$this->assertNull( $saved->get_customs_commodity_code( 'edit' ), 'Blank fields must clear the raw value.' );
+		$this->assertNull( $saved->get_customs_country_of_origin( 'edit' ) );
+		$this->assertNull( $saved->get_customs_description( 'edit' ) );
+	}
+
+	/**
+	 * @testdox Keeps an invalid customs field unchanged and reports it, while other fields still save.
+	 * @testWith [false, "commodity_code", "12AB34"]
+	 *           [true, "country_of_origin", "ZZ"]
+	 * @param bool   $variation Whether to save a variation.
+	 * @param string $field Invalid field.
+	 * @param string $value Invalid value.
+	 */
+	public function test_invalid_customs_field_is_reported( bool $variation, string $field, string $value ): void {
+		$original = array(
+			'commodity_code'    => '001234',
+			'country_of_origin' => 'RO',
+			'description'       => 'Original',
+		);
+		$product  = $this->create_product( $variation );
+		foreach ( $original as $key => $original_value ) {
+			$product->{"set_customs_$key"}( $original_value );
+		}
+		$product->save();
+		$fields           = array(
+			'commodity_code'    => '654321',
+			'country_of_origin' => 'US',
+			'description'       => 'Changed',
+		);
+		$fields[ $field ] = $value;
+		$this->save_fields( $product, $fields );
+
+		$saved = wc_get_product( $product->get_id() );
+		foreach ( $fields as $key => $posted ) {
+			$expected = $key === $field ? $original[ $key ] : $posted;
+			$this->assertSame( $expected, $saved->{"get_customs_$key"}( 'edit' ), "Only the invalid $field should keep its old value." );
+		}
+		$this->assertSame( '29', $saved->get_regular_price( 'edit' ), 'Unrelated product fields must still save.' );
+		$this->assertCount( 1, WC_Admin_Meta_Boxes::$meta_box_errors, 'The editor must report the validation failure.' );
+	}
+
+	/**
+	 * @testdox Leaves customs values unchanged when their controls are not submitted.
+	 * @testWith [false]
+	 *           [true]
+	 * @param bool $variation Whether to save a variation.
+	 */
+	public function test_preserves_missing_fields( bool $variation ): void {
+		$product = $this->create_product( $variation );
+		$product->set_customs_description( 'Original' );
+		$product->save();
+		$this->save_fields( $product, array() );
+		$this->assertSame( 'Original', wc_get_product( $product->get_id() )->get_customs_description( 'edit' ) );
+	}
+
+	/**
+	 * @testdox Renders raw variation overrides separately from inherited parent placeholders.
+	 */
+	public function test_variation_fields_show_inheritance(): void {
+		$product = $this->create_product( true );
+		$parent  = wc_get_product( $product->get_parent_id() );
+		$parent->set_customs_commodity_code( '001234' );
+		$parent->set_customs_country_of_origin( 'RO' );
+		$parent->set_customs_description( 'Parent description' );
+		$parent->save();
+		$html = $this->render_customs_fields( $product, $parent );
+		$this->assertStringContainsString( 'value="" placeholder="001234"', $html, 'The inherited code must not become an explicit override.' );
+		$this->assertStringContainsString( 'value="" placeholder="Parent description"', $html );
+		$this->assertStringContainsString( 'Same as parent</option>', $html, 'The inherited country option must reuse the variation wording.' );
+		$this->assertDoesNotMatchRegularExpression( '/<select[^>]*placeholder=/', $html, 'The country select must not carry a placeholder attribute.' );
+		$this->assertStringContainsString( 'Leave blank to inherit from the product: 001234.', $html, 'The help tip should name the inherited value.' );
+
+		$product->set_customs_commodity_code( '654321' );
+		$this->assertStringContainsString( 'value="654321" placeholder="001234"', $this->render_customs_fields( $product, $parent ) );
+	}
+
+	/**
+	 * Renders the shared customs controls.
+	 *
+	 * @param WC_Product      $product Product being edited.
+	 * @param WC_Product|null $parent_product Parent product.
+	 * @return string
+	 */
+	private function render_customs_fields( WC_Product $product, ?WC_Product $parent_product = null ): string {
+		ob_start();
+		wc_get_container()->get( ClassicEditorFields::class )->render_fields( $product, $parent_product, 0 );
+		return ob_get_clean();
+	}
+
+	/**
+	 * Creates a persisted product or variation.
+	 *
+	 * @param bool $variation Whether to create a variation.
+	 * @return WC_Product
+	 */
+	private function create_product( bool $variation ): WC_Product {
+		if ( $variation ) {
+			$parent = new WC_Product_Variable();
+			$parent->set_name( 'Customs product' );
+			$parent->save();
+			$product = new WC_Product_Variation();
+			$product->set_parent_id( $parent->get_id() );
+		} else {
+			$product = new WC_Product_Simple();
+		}
+		$product->set_name( 'Customs product' );
+		$product->save();
+		return $product;
+	}
+
+	/**
+	 * Submits customs controls through the product editor save handler.
+	 *
+	 * @param WC_Product $product Product being edited.
+	 * @param array      $fields Submitted customs fields.
+	 */
+	private function save_fields( WC_Product $product, array $fields ): void {
+		$_POST = array();
+		if ( $product instanceof WC_Product_Variation ) {
+			$_POST['variable_post_id']       = array( $product->get_id() );
+			$_POST['variable_regular_price'] = array( '29' );
+			$_POST['variable_enabled']       = array( 'yes' );
+			foreach ( $fields as $field => $value ) {
+				$_POST[ 'variable_customs_' . $field ] = array( wp_slash( $value ) );
+			}
+			WC_Meta_Box_Product_Data::save_variations( $product->get_parent_id(), get_post( $product->get_parent_id() ) );
+		} else {
+			$_POST['product-type']   = ProductType::SIMPLE;
+			$_POST['_regular_price'] = '29';
+			foreach ( $fields as $field => $value ) {
+				$_POST[ '_customs_' . $field ] = wp_slash( $value );
+			}
+			WC_Meta_Box_Product_Data::save( $product->get_id(), get_post( $product->get_id() ) );
+		}
+	}
+}