Commit 0aeca49fa0f for woocommerce

commit 0aeca49fa0f4d5b83679f4e36322f50619b56092
Author: Bogdan Ungureanu <bogdanungureanu21@gmail.com>
Date:   Tue Oct 6 13:54:05 2026 +0300

    Add customs fields to the products and variations REST API (#69165)

    * 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

    * 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

    ---------

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

diff --git a/docs/apis/rest-api/v3/product-variations.mdx b/docs/apis/rest-api/v3/product-variations.mdx
index 6fb7e30b385..8a1f0885211 100644
--- a/docs/apis/rest-api/v3/product-variations.mdx
+++ b/docs/apis/rest-api/v3/product-variations.mdx
@@ -23,6 +23,9 @@ The product variations API allows you to create, view, update, and delete indivi
 | `permalink`             | string          | Variation URL. `READ-ONLY`                                                            |
 | `sku`                   | string          | Unique identifier.                                                                                                  |
 | `global_unique_id`      | string          | GTIN, UPC, EAN or ISBN. A global unique identifier for the variation.                                               |
+| `customs_commodity_code` | string, null | Customs code containing 6–14 digits after spaces and punctuation are removed. The default `view` context returns the parent value when the variation has none; `context=edit` returns the stored value, `null` when inherited. |
+| `customs_country_of_origin` | string, null | Two-letter ISO country of origin code. The default `view` context returns the parent value when the variation has none; `context=edit` returns the stored value, `null` when inherited. |
+| `customs_description` | string, null | Plain-text customs description, up to 35 characters, without emoji or special symbols such as `™`. The default `view` context returns the parent value when the variation has none; `context=edit` returns the stored value, `null` when inherited. |
 | `price`                 | string          | Current variation price. `READ-ONLY`                                                  |
 | `regular_price`         | string          | Variation regular price.                                                                                            |
 | `sale_price`            | string          | Variation sale price.                                                                                               |
diff --git a/docs/apis/rest-api/v3/products.mdx b/docs/apis/rest-api/v3/products.mdx
index 7e5179509ab..99b5ee98f14 100644
--- a/docs/apis/rest-api/v3/products.mdx
+++ b/docs/apis/rest-api/v3/products.mdx
@@ -30,6 +30,9 @@ The products API allows you to create, view, update, and delete individual, or a
 | `short_description`     | string    | Product short description.                                                                                                                                                                                                             |
 | `sku`                   | string    | Unique identifier.                                                                                                                                                                                                                     |
 | `global_unique_id`      | string    | GTIN, UPC, EAN or ISBN - a unique identifier for each distinct product and service that can be purchased.                                                                                                                              |
+| `customs_commodity_code` | string, null | Optional customs code containing 6–14 digits after spaces and punctuation are removed. Letters are rejected. |
+| `customs_country_of_origin` | string, null | Optional two-letter ISO country of origin code; normalized to uppercase. |
+| `customs_description` | string, null | Optional plain-text description for customs forms, up to 35 characters, without emoji or special symbols such as `™`. |
 | `price`                 | string    | Current product price. `READ-ONLY`                                                                                                                                                                       |
 | `regular_price`         | string    | Product regular price.                                                                                                                                                                                                                 |
 | `sale_price`            | string    | Product sale price.                                                                                                                                                                                                                    |
diff --git a/plugins/woocommerce/changelog/feat-wooplug-5501-customs-2-rest b/plugins/woocommerce/changelog/feat-wooplug-5501-customs-2-rest
new file mode 100644
index 00000000000..039a222c4c9
--- /dev/null
+++ b/plugins/woocommerce/changelog/feat-wooplug-5501-customs-2-rest
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add customs fields to products and variations in the REST API.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
index cab38607503..73c3011fe68 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
@@ -12,6 +12,7 @@ use Automattic\WooCommerce\Enums\ProductTaxStatus;
 use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\Enums\ProductStockStatus;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareRestControllerTrait;
+use Automattic\WooCommerce\Internal\ProductCustoms\CustomsDataValidator;
 use Automattic\WooCommerce\Internal\VariationGallery\LegacyVariationGalleryCompatibility;
 use Automattic\WooCommerce\Internal\VariationGallery\Telemetry as VariationGalleryTelemetry;
 use Automattic\WooCommerce\Utilities\I18nUtil;
@@ -168,6 +169,12 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
 			'parent_id'             => $object->get_parent_id(),
 		);

+		if ( $object instanceof WC_Product ) {
+			$data['customs_commodity_code']    = $object->get_customs_commodity_code( $context );
+			$data['customs_country_of_origin'] = $object->get_customs_country_of_origin( $context );
+			$data['customs_description']       = $object->get_customs_description( $context );
+		}
+
 		$data = $this->add_additional_fields_to_object( $data, $request );
 		$data = $this->filter_response_by_context( $data, $context );

@@ -201,6 +208,7 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
 	 * @throws \Throwable When setting gallery_image_ids fails.
 	 */
 	protected function prepare_object_for_database( $request, $creating = false ) {
+		$customs = CustomsDataValidator::normalize_fields( $request->get_params() );
 		if ( isset( $request['id'] ) ) {
 			$variation = wc_get_product( absint( $request['id'] ) );
 		} else {
@@ -209,6 +217,10 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V

 		$variation->set_parent_id( absint( $request['product_id'] ) );

+		foreach ( $customs as $property => $value ) {
+			$variation->{ 'set_' . $property }( $value );
+		}
+
 		// Status.
 		if ( isset( $request['status'] ) ) {
 			$variation->set_status( get_post_status_object( $request['status'] ) ? $request['status'] : ProductStatus::DRAFT );
@@ -941,6 +953,24 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
 			$schema = $this->add_cogs_related_product_schema( $schema, true );
 		}

+		$schema['properties']['customs_commodity_code'] = array(
+			'description' => __( 'Customs commodity code containing 6 to 14 digits. Punctuation and spaces are removed. In view context, an empty value inherits the parent value; in edit context, only the stored value is returned (null when inherited).', 'woocommerce' ),
+			'type'        => array( 'string', 'null' ),
+			'context'     => array( 'view', 'edit' ),
+		);
+
+		$schema['properties']['customs_country_of_origin'] = array(
+			'description' => __( 'Two-letter country of origin code. In view context, an empty value inherits the parent value; in edit context, only the stored value is returned (null when inherited).', 'woocommerce' ),
+			'type'        => array( 'string', 'null' ),
+			'context'     => array( 'view', 'edit' ),
+		);
+
+		$schema['properties']['customs_description'] = array(
+			'description' => __( 'Plain-text customs description, up to 35 characters, without emoji or special symbols such as ™. In view context, an empty value inherits the parent value; in edit context, only the stored value is returned (null when inherited).', 'woocommerce' ),
+			'type'        => array( 'string', 'null' ),
+			'context'     => array( 'view', 'edit' ),
+		);
+
 		return $this->add_additional_fields_schema( $schema );
 	}

@@ -1351,12 +1381,20 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
 			return new WP_Error( 'woocommerce_rest_product_invalid_id', __( 'Invalid product ID.', 'woocommerce' ), array( 'status' => 404 ) );
 		}

+		$default_values = isset( $request['default_values'] ) ? $request['default_values'] : array();
+		if ( is_array( $default_values ) ) {
+			try {
+				$default_values = array_merge( $default_values, CustomsDataValidator::normalize_fields( $default_values ) );
+			} catch ( WC_Data_Exception $e ) {
+				return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => 400 ) );
+			}
+		}
+
 		wc_maybe_define_constant( 'WC_MAX_LINKED_VARIATIONS', 99 );
 		wc_set_time_limit( 0 );

 		$response          = array();
 		$product           = wc_get_product( $product_id );
-		$default_values    = isset( $request['default_values'] ) ? $request['default_values'] : array();
 		$meta_data         = isset( $request['meta_data'] ) ? $request['meta_data'] : array();
 		$data_store        = $product->get_data_store();
 		$response['count'] = $data_store->create_all_product_variations( $product, Constants::get_constant( 'WC_MAX_LINKED_VARIATIONS' ), $default_values, $meta_data );
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
index 8fb5da1a7b0..1f9cecfc87f 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
@@ -14,6 +14,7 @@ use Automattic\WooCommerce\Enums\ProductTaxStatus;
 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Enums\CatalogVisibility;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareRestControllerTrait;
+use Automattic\WooCommerce\Internal\ProductCustoms\CustomsDataValidator;
 use Automattic\WooCommerce\Internal\RestApi\ProductRequestPreparationTrait;
 use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
 use Automattic\WooCommerce\Utilities\I18nUtil;
@@ -747,12 +748,17 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 	 * @return WP_Error|WC_Data
 	 */
 	protected function prepare_object_for_database( $request, $creating = false ) {
+		$customs = CustomsDataValidator::normalize_fields( $request->get_params() );
 		$product = $this->get_product_for_rest_request( $request );

 		if ( is_wp_error( $product ) ) {
 			return $product;
 		}

+		foreach ( $customs as $property => $value ) {
+			$product->{ 'set_' . $property }( $value );
+		}
+
 		// Post title.
 		if ( isset( $request['name'] ) ) {
 			$product->set_name( wp_filter_post_kses( $request['name'] ) );
@@ -1848,6 +1854,24 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 			$schema = $this->add_cogs_related_product_schema( $schema, false );
 		}

+		$schema['properties']['customs_commodity_code'] = array(
+			'description' => __( 'Customs commodity code containing 6 to 14 digits. Punctuation and spaces are removed.', 'woocommerce' ),
+			'type'        => array( 'string', 'null' ),
+			'context'     => array( 'view', 'edit' ),
+		);
+
+		$schema['properties']['customs_country_of_origin'] = array(
+			'description' => __( 'Two-letter country of origin code.', 'woocommerce' ),
+			'type'        => array( 'string', 'null' ),
+			'context'     => array( 'view', 'edit' ),
+		);
+
+		$schema['properties']['customs_description'] = array(
+			'description' => __( 'Plain-text customs description, up to 35 characters, without emoji or special symbols such as ™.', 'woocommerce' ),
+			'type'        => array( 'string', 'null' ),
+			'context'     => array( 'view', 'edit' ),
+		);
+
 		return $this->add_additional_fields_schema( $schema );
 	}

@@ -2066,6 +2090,12 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 				$data['global_unique_id'] = $product->get_global_unique_id( $context );
 			}

+			foreach ( CustomsDataValidator::FIELDS as $property ) {
+				if ( in_array( $property, $fields, true ) ) {
+					$data[ $property ] = $product->{ 'get_' . $property }( $context );
+				}
+			}
+
 			$post_type_obj = get_post_type_object( $this->post_type );
 			if ( is_post_type_viewable( $post_type_obj ) && $post_type_obj->public ) {
 				$permalink_template_requested = in_array( 'permalink_template', $fields, true );
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php
index be67947956d..e2423492c87 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php
@@ -400,7 +400,7 @@ class Product_Variations_API extends WC_REST_Unit_Test_Case {
 		$data       = $response->get_data();
 		$properties = $data['schema']['properties'];

-		$this->assertEquals( 42, count( $properties ) );
+		$this->assertEquals( 45, count( $properties ) );
 		$this->assertArrayHasKey( 'id', $properties );
 		$this->assertArrayHasKey( 'date_created', $properties );
 		$this->assertArrayHasKey( 'date_modified', $properties );
@@ -439,6 +439,9 @@ class Product_Variations_API extends WC_REST_Unit_Test_Case {
 		$this->assertArrayHasKey( 'menu_order', $properties );
 		$this->assertArrayHasKey( 'meta_data', $properties );
 		$this->assertArrayHasKey( 'parent_id', $properties );
+		$this->assertArrayHasKey( 'customs_commodity_code', $properties );
+		$this->assertArrayHasKey( 'customs_country_of_origin', $properties );
+		$this->assertArrayHasKey( 'customs_description', $properties );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php
index 5461ea3631b..b6338eb04f1 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php
@@ -657,7 +657,10 @@ class WC_Tests_API_Product extends WC_REST_Unit_Test_Case {
 		$response   = $this->server->dispatch( $request );
 		$data       = $response->get_data();
 		$properties = $data['schema']['properties'];
-		$this->assertEquals( 72, count( $properties ) );
+		$this->assertEquals( 75, count( $properties ) );
+		$this->assertArrayHasKey( 'customs_commodity_code', $properties );
+		$this->assertArrayHasKey( 'customs_country_of_origin', $properties );
+		$this->assertArrayHasKey( 'customs_description', $properties );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
index d0a3cc4189d..76fa2562202 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
@@ -1067,4 +1067,205 @@ class WC_REST_Product_Variations_Controller_Tests extends WC_Unit_Test_Case {
 		}
 		$this->assertSame( 1, $parent_product_children_deletes, 'Parent variation transients should be deleted once per batch.' );
 	}
+
+	/**
+	 * @testdox Creating a variation normalizes customs values.
+	 */
+	public function test_create_variation_normalizes_customs_values(): void {
+		$route   = $this->get_customs_variations_route();
+		$request = new WP_REST_Request( 'POST', $route );
+		$request->set_body_params(
+			array(
+				'customs_commodity_code'    => '0901.21.0010',
+				'customs_country_of_origin' => ' br ',
+				'customs_description'       => ' Roasted  coffee ',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 201, $response->get_status(), 'Valid customs values should create the variation.' );
+		$data = $response->get_data();
+		$this->assertSame( '0901210010', $data['customs_commodity_code'], 'The commodity code should be stored without punctuation.' );
+		$this->assertSame( 'BR', $data['customs_country_of_origin'], 'The country of origin should be trimmed and uppercased.' );
+		$this->assertSame( 'Roasted coffee', $data['customs_description'], 'The customs description should have its whitespace normalized.' );
+	}
+
+	/**
+	 * @testdox Omitted variation customs fields are preserved while null clears them.
+	 */
+	public function test_update_variation_preserves_and_clears_customs_values(): void {
+		$route  = $this->get_customs_variations_route();
+		$create = new WP_REST_Request( 'POST', $route );
+		$create->set_body_params(
+			array(
+				'customs_commodity_code'    => '090121',
+				'customs_country_of_origin' => 'BR',
+				'customs_description'       => 'Coffee',
+			)
+		);
+		$id = $this->server->dispatch( $create )->get_data()['id'];
+
+		$request = new WP_REST_Request( 'PUT', $route . '/' . $id );
+		$request->set_body_params( array( 'menu_order' => 3 ) );
+		$response = $this->server->dispatch( $request );
+		$this->assertSame( 200, $response->get_status(), 'An update without customs fields should succeed.' );
+		$this->assertSame( '090121', $response->get_data()['customs_commodity_code'], 'Omitted customs fields should be preserved.' );
+
+		$request = new WP_REST_Request( 'PUT', $route . '/' . $id );
+		$request->set_body_params(
+			array(
+				'customs_commodity_code'    => null,
+				'customs_country_of_origin' => null,
+				'customs_description'       => null,
+			)
+		);
+		$response = $this->server->dispatch( $request );
+		$this->assertSame( 200, $response->get_status(), 'Clearing customs fields should succeed.' );
+		foreach ( array( 'customs_commodity_code', 'customs_country_of_origin', 'customs_description' ) as $field ) {
+			$this->assertNull( $response->get_data()[ $field ], "The $field field should be cleared." );
+			$this->assertFalse( metadata_exists( 'post', $id, '_' . $field ), "The $field meta should be deleted." );
+		}
+	}
+
+	/**
+	 * @testdox Invalid variation customs input rejects the request without saving other changes.
+	 * @testWith ["customs_commodity_code", "0901A10010"]
+	 * @param string $field Invalid field.
+	 * @param string $value Invalid value.
+	 */
+	public function test_update_variation_rejects_invalid_customs_values( string $field, string $value ): void {
+		$route  = $this->get_customs_variations_route();
+		$create = new WP_REST_Request( 'POST', $route );
+		$create->set_body_params( array( 'menu_order' => 2 ) );
+		$id = $this->server->dispatch( $create )->get_data()['id'];
+
+		$request = new WP_REST_Request( 'PUT', $route . '/' . $id );
+		$request->set_body_params(
+			array(
+				'menu_order'                => 7,
+				'customs_country_of_origin' => 'BR',
+				$field                      => $value,
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 400, $response->get_status(), 'Invalid customs input should return a 400 response.' );
+		$this->assertSame( 'woocommerce_product_invalid_' . $field, $response->get_data()['code'], 'The error code should name the invalid field.' );
+		$this->assertSame( 2, wc_get_product( $id )->get_menu_order(), 'Other variation changes should not be saved.' );
+		$this->assertFalse( metadata_exists( 'post', $id, '_customs_country_of_origin' ), 'Valid customs values in the same request should not be saved.' );
+	}
+
+	/**
+	 * @testdox Variation reads return inherited customs values in view context and stored values in edit context.
+	 */
+	public function test_variation_customs_values_use_request_context(): void {
+		$this->server = $this->create_rest_server_with_routes(
+			array(
+				array( $this->controller, 'register_routes' ),
+				array( new WC_REST_Variations_Controller(), 'register_routes' ),
+			),
+			true
+		);
+
+		$parent = new WC_Product_Variable();
+		$parent->set_customs_commodity_code( '090121' );
+		$parent->save();
+		$child = new WC_Product_Variation();
+		$child->set_parent_id( $parent->get_id() );
+		$child->set_customs_country_of_origin( 'BR' );
+		$child->save();
+
+		$expected = array(
+			'view' => '090121',
+			'edit' => null,
+		);
+		foreach ( $expected as $context => $commodity_code ) {
+			$request = new WP_REST_Request( 'GET', '/wc/v3/products/' . $parent->get_id() . '/variations/' . $child->get_id() );
+			$request->set_param( 'context', $context );
+			$data = $this->server->dispatch( $request )->get_data();
+			$this->assertSame( $commodity_code, $data['customs_commodity_code'], "The $context context should return the expected commodity code." );
+			$this->assertSame( 'BR', $data['customs_country_of_origin'], "The $context context should return the variation's own country of origin." );
+
+			$request = new WP_REST_Request( 'GET', '/wc/v3/variations' );
+			$request->set_param( 'include', array( $child->get_id() ) );
+			$request->set_param( 'context', $context );
+			$data = $this->server->dispatch( $request )->get_data();
+			$this->assertSame( $commodity_code, $data[0]['customs_commodity_code'], "The variations collection in $context context should return the expected commodity code." );
+		}
+	}
+
+	/**
+	 * @testdox Generating variations applies normalized customs default values.
+	 */
+	public function test_generate_variations_with_customs_default_values(): void {
+		$product = $this->create_customs_product_with_variation_attribute();
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/products/' . $product->get_id() . '/variations/generate' );
+		$request->set_body_params(
+			array(
+				'default_values' => array(
+					'customs_commodity_code'    => '0901.21',
+					'customs_country_of_origin' => 'br',
+				),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 200, $response->get_status(), 'Generating variations should succeed.' );
+		$children = wc_get_product( $product->get_id() )->get_children();
+		$this->assertCount( 2, $children, 'A variation should be generated for each attribute option.' );
+		foreach ( $children as $child_id ) {
+			$child = wc_get_product( $child_id );
+			$this->assertSame( '090121', $child->get_customs_commodity_code( 'edit' ), 'Generated variations should store the normalized commodity code.' );
+			$this->assertSame( 'BR', $child->get_customs_country_of_origin( 'edit' ), 'Generated variations should store the normalized country of origin.' );
+		}
+	}
+
+	/**
+	 * @testdox Generating variations with invalid customs default values returns a 400 error and creates nothing.
+	 */
+	public function test_generate_variations_rejects_invalid_customs_default_values(): void {
+		$product = $this->create_customs_product_with_variation_attribute();
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/products/' . $product->get_id() . '/variations/generate' );
+		$request->set_body_params(
+			array(
+				'default_values' => array( 'customs_country_of_origin' => 'ZZ' ),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 400, $response->get_status(), 'Invalid customs default values should return a 400 response.' );
+		$this->assertSame( 'woocommerce_product_invalid_customs_country_of_origin', $response->get_data()['code'], 'The error code should name the invalid field.' );
+		$this->assertSame( array(), wc_get_product( $product->get_id() )->get_children(), 'No variations should be generated.' );
+	}
+
+	/**
+	 * Create a variable product and return its variations collection route.
+	 *
+	 * @return string
+	 */
+	private function get_customs_variations_route(): string {
+		$parent = new WC_Product_Variable();
+		$parent->save();
+		return '/wc/v3/products/' . $parent->get_id() . '/variations';
+	}
+
+	/**
+	 * Create a variable product with one two-option variation attribute and no variations.
+	 *
+	 * @return WC_Product_Variable
+	 */
+	private function create_customs_product_with_variation_attribute(): WC_Product_Variable {
+		$attribute = new WC_Product_Attribute();
+		$attribute->set_name( 'Size' );
+		$attribute->set_options( array( 'Small', 'Large' ) );
+		$attribute->set_visible( true );
+		$attribute->set_variation( true );
+		$product = new WC_Product_Variable();
+		$product->set_attributes( array( $attribute ) );
+		$product->save();
+		return $product;
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
index 259905001c7..2d767f5b213 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
@@ -230,6 +230,9 @@ class WC_REST_Products_Controller_Tests extends WC_Unit_Test_Case {
 			'menu_order',
 			'meta_data',
 			'post_password',
+			'customs_commodity_code',
+			'customs_country_of_origin',
+			'customs_description',
 		);

 		if ( $with_cogs_enabled ) {
@@ -2678,4 +2681,125 @@ class WC_REST_Products_Controller_Tests extends WC_Unit_Test_Case {
 		$this->assertSame( 'Updated in batch', $data['update'][1]['name'] );
 		$this->assertSame( 'Updated in batch', wc_get_product( $product->get_id() )->get_name() );
 	}
+
+	/**
+	 * @testdox Creating a product normalizes customs values.
+	 */
+	public function test_create_product_normalizes_customs_values(): void {
+		$request = new WP_REST_Request( 'POST', '/wc/v3/products' );
+		$request->set_body_params(
+			array(
+				'name'                      => 'Coffee',
+				'customs_commodity_code'    => '0901.21.0010',
+				'customs_country_of_origin' => ' br ',
+				'customs_description'       => ' Roasted  coffee ',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 201, $response->get_status(), 'Valid customs values should create the product.' );
+		$data = $response->get_data();
+		$this->assertSame( '0901210010', $data['customs_commodity_code'], 'The commodity code should be stored without punctuation.' );
+		$this->assertSame( 'BR', $data['customs_country_of_origin'], 'The country of origin should be trimmed and uppercased.' );
+		$this->assertSame( 'Roasted coffee', $data['customs_description'], 'The customs description should have its whitespace normalized.' );
+	}
+
+	/**
+	 * @testdox Product customs values honor the request context.
+	 */
+	public function test_product_customs_values_use_request_context(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_customs_commodity_code( '090121' );
+		$product->save();
+		$filter = static function () {
+			return '999999';
+		};
+		add_filter( 'woocommerce_product_get_customs_commodity_code', $filter );
+
+		$view = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/products/' . $product->get_id() ) )->get_data();
+		$edit = new WP_REST_Request( 'GET', '/wc/v3/products/' . $product->get_id() );
+		$edit->set_param( 'context', 'edit' );
+		$edit = $this->server->dispatch( $edit )->get_data();
+
+		$this->assertSame( '999999', $view['customs_commodity_code'], 'The view context should return the filtered value.' );
+		$this->assertSame( '090121', $edit['customs_commodity_code'], 'The edit context should return the stored value.' );
+	}
+
+	/**
+	 * @testdox Omitted product customs fields are preserved while null clears them.
+	 */
+	public function test_update_product_preserves_and_clears_customs_values(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_customs_commodity_code( '090121' );
+		$product->set_customs_country_of_origin( 'BR' );
+		$product->set_customs_description( 'Coffee' );
+		$product->save();
+		$id = $product->get_id();
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/products/' . $id );
+		$request->set_body_params( array( 'menu_order' => 3 ) );
+		$response = $this->server->dispatch( $request );
+		$this->assertSame( 200, $response->get_status(), 'An update without customs fields should succeed.' );
+		$this->assertSame( '090121', $response->get_data()['customs_commodity_code'], 'Omitted customs fields should be preserved.' );
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/products/' . $id );
+		$request->set_body_params(
+			array(
+				'customs_commodity_code'    => null,
+				'customs_country_of_origin' => null,
+				'customs_description'       => null,
+			)
+		);
+		$response = $this->server->dispatch( $request );
+		$this->assertSame( 200, $response->get_status(), 'Clearing customs fields should succeed.' );
+		foreach ( array( 'customs_commodity_code', 'customs_country_of_origin', 'customs_description' ) as $field ) {
+			$this->assertNull( $response->get_data()[ $field ], "The $field field should be cleared." );
+			$this->assertFalse( metadata_exists( 'post', $id, '_' . $field ), "The $field meta should be deleted." );
+		}
+	}
+
+	/**
+	 * @testdox Invalid product customs input rejects the request without saving other changes.
+	 * @testWith ["customs_commodity_code", "0901A10010"]
+	 * @param string $field Invalid field.
+	 * @param string $value Invalid value.
+	 */
+	public function test_update_product_rejects_invalid_customs_values( string $field, string $value ): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_menu_order( 2 );
+		$product->save();
+		$id = $product->get_id();
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/products/' . $id );
+		$request->set_body_params(
+			array(
+				'menu_order'                => 7,
+				'customs_country_of_origin' => 'BR',
+				$field                      => $value,
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 400, $response->get_status(), 'Invalid customs input should return a 400 response.' );
+		$this->assertSame( 'woocommerce_product_invalid_' . $field, $response->get_data()['code'], 'The error code should name the invalid field.' );
+		$this->assertSame( 2, wc_get_product( $id )->get_menu_order(), 'Other product changes should not be saved.' );
+		$this->assertFalse( metadata_exists( 'post', $id, '_customs_country_of_origin' ), 'Valid customs values in the same request should not be saved.' );
+	}
+
+	/**
+	 * @testdox Non-string JSON customs values are rejected.
+	 */
+	public function test_update_product_rejects_non_string_customs_value(): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/products/' . $product->get_id() );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body( wp_json_encode( array( 'customs_commodity_code' => 90121000 ) ) );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 400, $response->get_status(), 'A numeric commodity code should be rejected.' );
+		$this->assertSame( 'rest_invalid_param', $response->get_data()['code'], 'Schema validation should reject the non-string value.' );
+		$this->assertFalse( metadata_exists( 'post', $product->get_id(), '_customs_commodity_code' ), 'A rejected commodity code should not be stored.' );
+	}
 }