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