Commit 0a0b4b2cfc4 for woocommerce
commit 0a0b4b2cfc40dfdfd7e36966e136729b05c6402d
Author: Bogdan Ungureanu <bogdanungureanu21@gmail.com>
Date: Tue Oct 6 16:47:31 2026 +0300
Add customs fields to product CSV import and export (#69167)
The product CSV exporter gets Commodity code (HS code), Country of origin and Customs description columns, and the importer maps them, including the machine-name headers. Customs values are validated by the product setters like other fields, so a row with an invalid value fails with an error. An empty cell clears the value.
diff --git a/plugins/woocommerce/changelog/feat-wooplug-5501-customs-4-csv b/plugins/woocommerce/changelog/feat-wooplug-5501-customs-4-csv
new file mode 100644
index 00000000000..0be7f18acab
--- /dev/null
+++ b/plugins/woocommerce/changelog/feat-wooplug-5501-customs-4-csv
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Import and export product customs fields with the product CSV tools.
diff --git a/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php b/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php
index 9aca4939c49..386b56e7ec4 100644
--- a/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php
+++ b/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php
@@ -818,6 +818,9 @@ class WC_Product_CSV_Importer_Controller {
__( 'External URL', 'woocommerce' ) => 'product_url',
__( 'Button text', 'woocommerce' ) => 'button_text',
__( 'Position', 'woocommerce' ) => 'menu_order',
+ __( 'Commodity code (HS code)', 'woocommerce' ) => 'customs_commodity_code',
+ __( 'Country of origin', 'woocommerce' ) => 'customs_country_of_origin',
+ __( 'Customs description', 'woocommerce' ) => 'customs_description',
);
if ( wc_get_container()->get( CostOfGoodsSoldController::class )->feature_is_enabled() ) {
@@ -1034,6 +1037,10 @@ class WC_Product_CSV_Importer_Controller {
'menu_order' => __( 'Position', 'woocommerce' ),
);
+ $options['customs_commodity_code'] = __( 'Commodity code (HS code)', 'woocommerce' );
+ $options['customs_country_of_origin'] = __( 'Country of origin', 'woocommerce' );
+ $options['customs_description'] = __( 'Customs description', 'woocommerce' );
+
if ( wc_get_container()->get( CostOfGoodsSoldController::class )->feature_is_enabled() ) {
$options['cogs_value'] = __( 'Cost of goods', 'woocommerce' );
}
diff --git a/plugins/woocommerce/includes/admin/importers/mappings/default.php b/plugins/woocommerce/includes/admin/importers/mappings/default.php
index 2443e061ab2..ba06f1f4494 100644
--- a/plugins/woocommerce/includes/admin/importers/mappings/default.php
+++ b/plugins/woocommerce/includes/admin/importers/mappings/default.php
@@ -80,6 +80,9 @@ function wc_importer_default_english_mappings( $mappings ) {
'External URL' => 'product_url',
'Button text' => 'button_text',
'Position' => 'menu_order',
+ 'Commodity code (HS code)' => 'customs_commodity_code',
+ 'Country of origin' => 'customs_country_of_origin',
+ 'Customs description' => 'customs_description',
);
if ( wc_get_container()->get( CostOfGoodsSoldController::class )->feature_is_enabled() ) {
diff --git a/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php b/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php
index 240d105e52c..40707ff6db8 100644
--- a/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php
+++ b/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php
@@ -179,6 +179,10 @@ class WC_Product_CSV_Exporter extends WC_CSV_Batch_Exporter {
'menu_order' => __( 'Position', 'woocommerce' ),
);
+ $default_columns['customs_commodity_code'] = __( 'Commodity code (HS code)', 'woocommerce' );
+ $default_columns['customs_country_of_origin'] = __( 'Country of origin', 'woocommerce' );
+ $default_columns['customs_description'] = __( 'Customs description', 'woocommerce' );
+
if ( wc_get_container()->get( CostOfGoodsSoldController::class )->feature_is_enabled() ) {
$default_columns['cogs_value'] = __( 'Cost of goods', 'woocommerce' );
}
diff --git a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
index 813c5a4b9a2..42c1b0c9d0c 100644
--- a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
+++ b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
@@ -11,6 +11,7 @@ use Automattic\WooCommerce\Enums\ProductStockStatus;
use Automattic\WooCommerce\Enums\ProductTaxStatus;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController;
+use Automattic\WooCommerce\Internal\ProductCustoms\CustomsDataValidator;
use Automattic\WooCommerce\Utilities\ArrayUtil;
if ( ! defined( 'ABSPATH' ) ) {
@@ -743,6 +744,22 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
return $value;
}
+ /**
+ * Parse a customs field.
+ *
+ * Skips wc_clean(), which would drop digits from codes such as "0901%210010" and encode a lone "<".
+ * The product setters validate the value.
+ *
+ * @since 11.3.0
+ *
+ * @param string $value Field value.
+ *
+ * @return string
+ */
+ public function parse_customs_field( $value ) {
+ return $this->unescape_data( $value );
+ }
+
/**
* Parse download file urls, we should allow shortcodes here.
*
@@ -894,6 +911,10 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
'cogs_value' => array( $this, 'parse_cogs_field' ),
);
+ foreach ( CustomsDataValidator::FIELDS as $customs_field ) {
+ $data_formatting[ $customs_field ] = array( $this, 'parse_customs_field' );
+ }
+
/**
* Match special column names by prefix.
*
diff --git a/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php b/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php
index 10f4bf190ce..165bc606a3e 100644
--- a/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php
@@ -657,4 +657,46 @@ class WC_Product_CSV_Importer_Controller_Test extends WC_Unit_Test_Case {
return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->wc_product_meta_lookup} WHERE product_id = %d", $product_id ) );
}
+
+ /**
+ * @testdox Customs CSV headers map to product properties regardless of case.
+ */
+ public function test_customs_headers_are_mapped(): void {
+ $method = new ReflectionMethod( WC_Product_CSV_Importer_Controller::class, 'auto_map_columns' );
+ $method->setAccessible( true );
+ $sut = new WC_Product_CSV_Importer_Controller();
+
+ $expected = array( 'customs_commodity_code', 'customs_country_of_origin', 'customs_description' );
+
+ $this->assertSame(
+ $expected,
+ $method->invoke( $sut, array( 'Commodity code (HS code)', 'COUNTRY OF ORIGIN', 'Customs description' ) ),
+ 'Exported customs labels should map to the customs setters.'
+ );
+ $this->assertSame(
+ $expected,
+ $method->invoke( $sut, array( 'CUSTOMS_COMMODITY_CODE', 'Customs_Country_Of_Origin', 'customs_description' ) ),
+ 'Canonical customs headers should map to the customs setters.'
+ );
+
+ // The controller has loaded the mappings; an empty list skips the en_US early return.
+ $english = wc_importer_default_english_mappings( array() );
+ foreach ( array( 'Commodity code (HS code)', 'Country of origin', 'Customs description' ) as $index => $label ) {
+ $this->assertSame( $expected[ $index ], $english[ $label ] ?? null, "Translated stores should still map the English {$label} header." );
+ }
+ }
+
+ /**
+ * @testdox Customs fields are available in the CSV mapping selector.
+ */
+ public function test_customs_fields_are_mapping_options(): void {
+ $method = new ReflectionMethod( WC_Product_CSV_Importer_Controller::class, 'get_mapping_options' );
+ $method->setAccessible( true );
+ $options = $method->invoke( new WC_Product_CSV_Importer_Controller() );
+
+ foreach ( array( 'customs_commodity_code', 'customs_country_of_origin', 'customs_description' ) as $property ) {
+ $this->assertArrayHasKey( $property, $options, 'Merchants should be able to map each customs field manually.' );
+ }
+ $this->assertSame( 'Commodity code (HS code)', $options['customs_commodity_code'], 'The commodity code option should match the export label.' );
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php b/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php
index 926bc0163e0..3d9b0c2800b 100644
--- a/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php
+++ b/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php
@@ -353,4 +353,38 @@ class WC_Product_CSV_Exporter_Test extends \WC_Unit_Test_Case {
$this->assertSame( '', $row['attributes:value1'] );
$this->assertSame( 1, $row['attributes:taxonomy1'] );
}
+
+ /**
+ * @testdox Customs CSV exports use translated labels and only a variation's own overrides.
+ */
+ public function test_customs_export_uses_labels_and_variation_overrides(): void {
+ $parent = WC_Helper_Product::create_variation_product();
+ $parent->set_props(
+ array(
+ 'customs_commodity_code' => '010121',
+ 'customs_country_of_origin' => 'RO',
+ 'customs_description' => 'Cotton shirt',
+ )
+ );
+ $parent->save();
+ $children = $parent->get_children();
+ $variation = wc_get_product( $children[0] );
+ $variation->set_customs_country_of_origin( 'US' );
+ $variation->save();
+
+ $sut = new WC_Product_CSV_Exporter();
+ $sut->set_product_ids_to_export( array( $parent->get_id() ) );
+ $sut->set_columns_to_export( array( 'id', 'type', 'name', 'customs_commodity_code', 'customs_country_of_origin', 'customs_description' ) );
+ $sut->prepare_data_to_export();
+ $rows = array_column( $this->get_exported_data( $sut ), null, 'id' );
+ $columns = $sut->get_default_column_names();
+
+ $this->assertSame( 'Commodity code (HS code)', $columns['customs_commodity_code'], 'The commodity CSV header should use the translated label.' );
+ $this->assertSame( 'Country of origin', $columns['customs_country_of_origin'], 'The origin CSV header should use the translated label.' );
+ $this->assertSame( 'Customs description', $columns['customs_description'], 'The description CSV header should use the translated label.' );
+ $this->assertSame( '010121', $rows[ $parent->get_id() ]['customs_commodity_code'], 'Export must retain leading zeros.' );
+ $this->assertNull( $rows[ $variation->get_id() ]['customs_commodity_code'], 'An inherited commodity code must export as an empty override.' );
+ $this->assertSame( 'US', $rows[ $variation->get_id() ]['customs_country_of_origin'], 'A variation origin override should export its own value.' );
+ $this->assertNull( $rows[ $variation->get_id() ]['customs_description'], 'An inherited description must export as an empty override.' );
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php b/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
index db69f47a519..a415d333ec1 100644
--- a/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
+++ b/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
@@ -39,7 +39,7 @@ class WC_Product_CSV_Importer_Test extends \WC_Unit_Test_Case {
* @testdox variations need to set the status back to published if parent product is a draft
*/
public function test_expand_data_with_draft_variable() {
- $csv_file = dirname( __FILE__ ) . '/sample.csv';
+ $csv_file = __DIR__ . '/sample.csv';
$raw_data = array(
array(
'type' => ProductType::VARIABLE,
@@ -101,7 +101,7 @@ class WC_Product_CSV_Importer_Test extends \WC_Unit_Test_Case {
* @testdox Test that the importer calculates the percent complete as 99 when it's >= 99.5% through the file.
*/
public function test_import_completion_issue_36618_lines_remaining() {
- $csv_file = dirname( __FILE__ ) . '/sample2.csv';
+ $csv_file = __DIR__ . '/sample2.csv';
$args = array(
'lines' => 200,
);
@@ -115,7 +115,7 @@ class WC_Product_CSV_Importer_Test extends \WC_Unit_Test_Case {
* @testdox Test that the importer calculates the percent complete as 100 when it's at the end of the file.
*/
public function test_import_completion_issue_36618_end_of_file() {
- $csv_file = dirname( __FILE__ ) . '/sample2.csv';
+ $csv_file = __DIR__ . '/sample2.csv';
$args = array(
'lines' => 201,
);
@@ -1808,4 +1808,180 @@ class WC_Product_CSV_Importer_Test extends \WC_Unit_Test_Case {
'Windows-1252 is converted' => array( 'Windows-1252', "Caf\xE9", 'Café' ),
);
}
+
+ /**
+ * @testdox CSV import creates products with normalized customs values.
+ */
+ public function test_import_creates_customs_values(): void {
+ $result = $this->import_customs_csv( "Type,SKU,Name,commodity_code,country_of_origin,customs_description\nsimple,customs-create,Customs product,0901%210010,ro,\"Salt & pepper, 20%Acrylic\"\n" );
+
+ $this->assertEmpty( $result['failed'], 'Valid customs values should be imported.' );
+ $this->assertCount( 1, $result['imported'], 'The customs row should create one product.' );
+ $product = wc_get_product( $result['imported'][0] );
+ $this->assertSame( '0901210010', $product->get_customs_commodity_code( 'edit' ), 'Commodity codes should keep every digit and remove punctuation.' );
+ $this->assertSame( 'RO', $product->get_customs_country_of_origin( 'edit' ), 'Origin codes should be normalized.' );
+ $this->assertSame( 'Salt & pepper, 20%Acrylic', $product->get_customs_description( 'edit' ), 'The description should not be entity-encoded or lose percent sequences.' );
+ }
+
+ /**
+ * @testdox Customs formatting callbacks can change the commodity code before it is validated.
+ */
+ public function test_customs_formatting_callback_can_change_code(): void {
+ $received_code = null;
+ add_filter(
+ 'woocommerce_product_importer_formatting_callbacks',
+ static function ( $callbacks, $importer ) use ( &$received_code ) {
+ $index = array_search( 'customs_commodity_code', $importer->get_mapped_keys(), true );
+ $callbacks[ $index ] = static function ( $value ) use ( &$received_code ) {
+ $received_code = $value;
+ return '0201.10';
+ };
+ return $callbacks;
+ },
+ 10,
+ 2
+ );
+
+ $result = $this->import_customs_csv( "Type,SKU,Name,commodity_code\nsimple,customs-filtered,Customs product,0901%210010\n" );
+
+ $this->assertSame( '0901%210010', $received_code, 'Formatting callbacks should receive the raw cell value.' );
+ $this->assertEmpty( $result['failed'], 'A valid formatting callback result should be imported.' );
+ $this->assertCount( 1, $result['imported'], 'The filtered customs row should create one product.' );
+ $this->assertSame( '020110', wc_get_product( $result['imported'][0] )->get_customs_commodity_code( 'edit' ), 'The formatting callback result should still be honored and validated.' );
+ }
+
+ /**
+ * @testdox Omitted CSV customs columns leave existing values unchanged.
+ */
+ public function test_import_preserves_omitted_customs_values(): void {
+ $product = $this->create_product_with_customs();
+
+ $result = $this->import_customs_csv( "ID,Name,country_of_origin\n{$product->get_id()},Updated name,\n", array( 'update_existing' => true ) );
+
+ $this->assertSame( array( $product->get_id() ), $result['updated'], 'The row should update the existing product.' );
+ $product = wc_get_product( $product->get_id() );
+ $this->assertSame( 'Updated name', $product->get_name(), 'The row should update the product name.' );
+ $this->assertSame( '010121', $product->get_customs_commodity_code( 'edit' ), 'An omitted commodity code column must not change the stored value.' );
+ $this->assertNull( $product->get_customs_country_of_origin( 'edit' ), 'The blank mapped origin cell should clear the stored value.' );
+ $this->assertSame( 'Cotton shirt', $product->get_customs_description( 'edit' ), 'An omitted description column must not change the stored value.' );
+ }
+
+ /**
+ * @testdox CSV customs descriptions escaped by the exporter are unescaped on import.
+ */
+ public function test_import_unescapes_customs_description(): void {
+ $result = $this->import_customs_csv( "Type,SKU,Name,customs_description\nsimple,customs-escaped,Customs product,'-Cotton shirt\n" );
+
+ $this->assertEmpty( $result['failed'], 'The escaped description should be imported.' );
+ $this->assertCount( 1, $result['imported'], 'The row should create one product.' );
+ $this->assertSame( '-Cotton shirt', wc_get_product( $result['imported'][0] )->get_customs_description( 'edit' ), 'The exporter escape prefix should be removed.' );
+ }
+
+ /**
+ * @testdox Invalid customs data rejects the entire product update.
+ * @testWith ["country_of_origin", "XX"]
+ *
+ * @param string $header Invalid customs column.
+ * @param string $value Invalid value.
+ */
+ public function test_invalid_customs_rejects_product_update( string $header, string $value ): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_name( 'Original name' );
+ $product->save();
+
+ $result = $this->import_customs_csv( "ID,Name,{$header}\n{$product->get_id()},Changed name,{$value}\n", array( 'update_existing' => true ) );
+
+ $this->assertEmpty( $result['updated'], 'An invalid customs row must not update the product.' );
+ $this->assertCount( 1, $result['failed'], 'Each invalid row should have an import error.' );
+ $this->assertNotEmpty( $result['failed'][0]->get_error_message(), 'The failed row should explain its invalid value.' );
+ $this->assertStringContainsString( (string) $product->get_id(), $result['failed'][0]->get_error_data()['row'], 'The error should identify the failed row.' );
+ $this->assertSame( 'Original name', wc_get_product( $product->get_id() )->get_name(), 'Other submitted fields must remain unchanged.' );
+ }
+
+ /**
+ * @testdox Invalid customs rows are not saved and do not stop following valid rows.
+ */
+ public function test_invalid_customs_row_does_not_stop_import(): void {
+ $result = $this->import_customs_csv( "Type,SKU,Name,commodity_code\nsimple,customs-invalid,Invalid customs,ABC123\nsimple,customs-valid,Valid customs,010121\n" );
+
+ $this->assertCount( 1, $result['failed'], 'The invalid row should be reported.' );
+ $this->assertCount( 1, $result['imported'], 'The following valid row should still import.' );
+ $this->assertSame( 0, wc_get_product_id_by_sku( 'customs-invalid' ), 'The invalid row must not be saved.' );
+ }
+
+ /**
+ * @testdox Invalid customs values added by the process item data filter reject the row.
+ */
+ public function test_filtered_invalid_customs_rejects_row(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_name( 'Original product' );
+ $product->save();
+ add_filter(
+ 'woocommerce_product_import_process_item_data',
+ static function ( $data ) {
+ $data['customs_country_of_origin'] = 'XX';
+ return $data;
+ }
+ );
+
+ $result = $this->import_customs_csv( "ID,Name,country_of_origin\n{$product->get_id()},Changed product,US\n", array( 'update_existing' => true ) );
+
+ $this->assertCount( 1, $result['failed'], 'Invalid filtered customs data should reject the row.' );
+ $this->assertSame( 'Original product', wc_get_product( $product->get_id() )->get_name(), 'The original product name should remain unchanged.' );
+ }
+
+ /**
+ * Create a simple product with every customs field set.
+ *
+ * @return WC_Product
+ */
+ private function create_product_with_customs(): WC_Product {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_props(
+ array(
+ 'customs_commodity_code' => '010121',
+ 'customs_country_of_origin' => 'RO',
+ 'customs_description' => 'Cotton shirt',
+ )
+ );
+ $product->save();
+
+ return $product;
+ }
+
+ /**
+ * Import a temporary CSV using the customs column mappings.
+ *
+ * @param string $csv CSV contents.
+ * @param array $args Import options.
+ * @return array Import results.
+ */
+ private function import_customs_csv( string $csv, array $args = array() ): array {
+ $file = get_temp_dir() . 'customs-import-' . wp_generate_uuid4() . '.csv';
+ file_put_contents( $file, $csv ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Temporary CSV fixture.
+
+ try {
+ $sut = new WC_Product_CSV_Importer(
+ $file,
+ array_merge(
+ array(
+ 'parse' => true,
+ 'mapping' => array(
+ 'ID' => 'id',
+ 'Type' => 'type',
+ 'SKU' => 'sku',
+ 'Name' => 'name',
+ 'commodity_code' => 'customs_commodity_code',
+ 'country_of_origin' => 'customs_country_of_origin',
+ 'customs_description' => 'customs_description',
+ ),
+ ),
+ $args
+ )
+ );
+ return $sut->import();
+ } finally {
+ wp_delete_file( $file );
+ }
+ }
}