Commit e18d7ec39f0 for woocommerce

commit e18d7ec39f044ed2bad9cd6aefe1b67f6887ea6b
Author: Christopher Hertel <mail@christopher-hertel.de>
Date:   Thu Nov 5 23:41:55 2026 +0100

    Let product abilities set terms, images and the remaining catalog fields (#70548)

    * Let product writes set terms, images and the remaining catalog fields

    product-create and product-update now accept categories, tags, brands,
    images, catalog_visibility and featured for every product type, sale
    dates and tax_status for priced products, sold_individually for simple
    products, and weight, dimensions and shipping_class for physical ones.
    products-query returns the fields it did not have yet, so every write
    reads back.

    Both writes now format the stored product. WC_Data::apply_changes()
    merges with array_replace_recursive(), so after emptying a list the
    saved object still held the old items, and it never saw the default
    category the data store assigns.

    * Refuse out-of-range sale times and name the unknown shipping class

    The sale date pattern now limits hours to 0-23 and minutes and seconds
    to 0-59, so 2030-01-31T24:00:00 is refused instead of rolling over to
    the next day. checkdate() still catches impossible dates such as
    2030-02-30.

    An unknown shipping class now names the slug that was sent and says
    that an empty string removes the class, so an agent can correct it.

diff --git a/plugins/woocommerce/changelog/product-writes-catalog-fields b/plugins/woocommerce/changelog/product-writes-catalog-fields
new file mode 100644
index 00000000000..9cb93287649
--- /dev/null
+++ b/plugins/woocommerce/changelog/product-writes-catalog-fields
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Let the product create and update abilities set categories, tags, brands, images, sale dates, shipping, tax and catalog visibility.
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
index 9101b651cc7..c9e6012f8c4 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
@@ -276,67 +276,67 @@ trait ProductAbilityTrait {
 	 */
 	private static function get_product_mutation_field_schemas( bool $for_variation = false ): array {
 		return array(
-			'name'              => array(
+			'name'               => array(
 				'type'        => 'string',
 				'description' => __( 'Required, except on a variation, which takes its name from its parent.', 'woocommerce' ),
 			),
-			'parent_id'         => array(
+			'parent_id'          => array(
 				'type'        => 'integer',
 				'description' => __( 'ID of the variable product this variation belongs to.', 'woocommerce' ),
 				'minimum'     => 1,
 			),
-			'attributes'        => self::get_product_attributes_schema( $for_variation ),
-			'variations'        => array( 'type' => 'array' ),
-			'sku'               => array( 'type' => 'string' ),
-			'regular_price'     => array(
+			'attributes'         => self::get_product_attributes_schema( $for_variation ),
+			'variations'         => array( 'type' => 'array' ),
+			'sku'                => array( 'type' => 'string' ),
+			'regular_price'      => array(
 				'type'        => 'string',
 				'description' => __( 'Decimal price as a string, without a currency symbol or thousand separators.', 'woocommerce' ),
 				'pattern'     => self::get_product_price_input_pattern(),
 			),
-			'sale_price'        => array(
+			'sale_price'         => array(
 				'type'        => 'string',
 				'description' => __( 'Decimal price as a string, without a currency symbol or thousand separators.', 'woocommerce' ),
 				'pattern'     => self::get_product_price_input_pattern(),
 			),
-			'description'       => array(
+			'description'        => array(
 				'type'        => 'string',
 				'description' => __( 'Product description content. Safe HTML is allowed.', 'woocommerce' ),
 			),
-			'short_description' => array(
+			'short_description'  => array(
 				'type'        => 'string',
 				'description' => __( 'Short product description content. Safe HTML is allowed.', 'woocommerce' ),
 			),
-			'status'            => array(
+			'status'             => array(
 				'type' => 'string',
 				'enum' => $for_variation ? self::get_variation_status_slugs() : self::get_product_mutation_status_slugs(),
 			),
-			'virtual'           => array(
+			'virtual'            => array(
 				'type'        => 'boolean',
 				'description' => __( 'Whether the product is not shipped.', 'woocommerce' ),
 			),
-			'downloadable'      => array(
+			'downloadable'       => array(
 				'type'        => 'boolean',
 				'description' => __( 'Whether the product gives the customer files to download.', 'woocommerce' ),
 			),
-			'manage_stock'      => array( 'type' => 'boolean' ),
-			'stock_quantity'    => array(
+			'manage_stock'       => array( 'type' => 'boolean' ),
+			'stock_quantity'     => array(
 				'type'        => self::get_product_stock_quantity_schema_type(),
 				'description' => __( 'Available stock quantity when product-level stock management is used.', 'woocommerce' ),
 			),
-			'stock_status'      => array(
+			'stock_status'       => array(
 				'type' => 'string',
 				'enum' => array_keys( wc_get_product_stock_status_options() ),
 			),
-			'external_url'      => array(
+			'external_url'       => array(
 				'type'        => 'string',
 				'description' => __( 'External destination URL for external products.', 'woocommerce' ),
 				'format'      => 'uri',
 			),
-			'button_text'       => array(
+			'button_text'        => array(
 				'type'        => 'string',
 				'description' => __( 'Button text for external products.', 'woocommerce' ),
 			),
-			'grouped_products'  => array(
+			'grouped_products'   => array(
 				'type'        => 'array',
 				'description' => __( 'Product IDs to include as children of a grouped product.', 'woocommerce' ),
 				'items'       => array(
@@ -344,6 +344,79 @@ trait ProductAbilityTrait {
 					'minimum' => 1,
 				),
 			),
+			'categories'         => self::get_product_id_list_input_schema( __( 'Category IDs, replacing the current ones. Empty means the default category.', 'woocommerce' ) ),
+			'tags'               => self::get_product_id_list_input_schema( __( 'Tag IDs, replacing the current ones.', 'woocommerce' ) ),
+			'brands'             => self::get_product_id_list_input_schema( __( 'Brand IDs, replacing the current ones.', 'woocommerce' ) ),
+			'images'             => self::get_product_id_list_input_schema( __( 'Image attachment IDs, replacing the current ones. The first is the featured image.', 'woocommerce' ) ),
+			'catalog_visibility' => array(
+				'type' => 'string',
+				'enum' => array_keys( wc_get_product_visibility_options() ),
+			),
+			'featured'           => array( 'type' => 'boolean' ),
+			'date_on_sale_from'  => array(
+				'type'        => 'string',
+				'description' => __( 'Store time, YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS. Empty removes it.', 'woocommerce' ),
+				'pattern'     => self::get_product_date_input_pattern(),
+			),
+			'date_on_sale_to'    => array(
+				'type'        => 'string',
+				'description' => __( 'Store time, YYYY-MM-DD to end with that day or YYYY-MM-DDTHH:MM:SS. Empty removes it.', 'woocommerce' ),
+				'pattern'     => self::get_product_date_input_pattern(),
+			),
+			'tax_status'         => array(
+				'type' => 'string',
+				'enum' => array( ProductTaxStatus::TAXABLE, ProductTaxStatus::SHIPPING, ProductTaxStatus::NONE ),
+			),
+			'sold_individually'  => array(
+				'type'        => 'boolean',
+				'description' => __( 'Limit to one per order.', 'woocommerce' ),
+			),
+			'weight'             => array(
+				'type'        => 'string',
+				'description' => sprintf(
+					/* translators: %s: weight unit, such as kg. */
+					__( 'Decimal in %s. Empty removes it.', 'woocommerce' ),
+					get_option( 'woocommerce_weight_unit' )
+				),
+				'pattern'     => self::get_product_measurement_input_pattern(),
+			),
+			'dimensions'         => array(
+				'type'                 => 'object',
+				'description'          => sprintf(
+					/* translators: %s: dimension unit, such as cm. */
+					__( 'Decimals in %s. Only the keys sent change.', 'woocommerce' ),
+					get_option( 'woocommerce_dimension_unit' )
+				),
+				'properties'           => array_fill_keys(
+					array( 'length', 'width', 'height' ),
+					array(
+						'type'    => 'string',
+						'pattern' => self::get_product_measurement_input_pattern(),
+					)
+				),
+				'additionalProperties' => false,
+			),
+			'shipping_class'     => array(
+				'type'        => 'string',
+				'description' => __( 'Shipping class slug. Empty removes it.', 'woocommerce' ),
+			),
+		);
+	}
+
+	/**
+	 * Schema of an input that takes positive IDs.
+	 *
+	 * @param string $description Field description.
+	 * @return array
+	 */
+	private static function get_product_id_list_input_schema( string $description ): array {
+		return array(
+			'type'        => 'array',
+			'description' => $description,
+			'items'       => array(
+				'type'    => 'integer',
+				'minimum' => 1,
+			),
 		);
 	}

@@ -428,7 +501,7 @@ trait ProductAbilityTrait {

 		$fields        = self::get_common_product_mutation_fields();
 		$product_props = array();
-		$price_fields  = array( 'regular_price', 'sale_price' );
+		$price_fields  = array( 'regular_price', 'sale_price', 'date_on_sale_from', 'date_on_sale_to', 'tax_status' );
 		$stock_fields  = array( 'manage_stock', 'stock_quantity', 'stock_status' );

 		if ( is_a( $classname, \WC_Product_Variation::class, true ) ) {
@@ -439,7 +512,7 @@ trait ProductAbilityTrait {
 				$fields = array_values( array_diff( $fields, array( 'attributes' ) ) );
 			}
 		} elseif ( is_a( $classname, \WC_Product_Variable::class, true ) ) {
-			$fields = array_merge( $fields, array( 'attributes', 'variations' ) );
+			$fields = array_merge( $fields, array( 'attributes', 'variations', 'tax_status', 'sold_individually', 'weight', 'dimensions', 'shipping_class' ) );
 		} elseif ( is_a( $classname, \WC_Product_External::class, true ) ) {
 			$fields        = array_merge( $fields, $price_fields, array( 'external_url', 'button_text' ) );
 			$product_props = array(
@@ -456,10 +529,10 @@ trait ProductAbilityTrait {
 				'stock_quantity' => '',
 			);
 		} else {
-			$fields = array_merge( $fields, array( 'virtual', 'downloadable' ) );
+			$fields = array_merge( $fields, array( 'virtual', 'downloadable', 'weight', 'dimensions', 'shipping_class' ) );

 			if ( is_a( $classname, \WC_Product_Simple::class, true ) ) {
-				$fields = array_merge( $fields, $price_fields, $stock_fields );
+				$fields = array_merge( $fields, $price_fields, $stock_fields, array( 'sold_individually' ) );
 			}
 		}

@@ -544,7 +617,7 @@ trait ProductAbilityTrait {
 	 * @return array<int, string>
 	 */
 	private static function get_common_product_mutation_fields(): array {
-		return array( 'name', 'sku', 'description', 'short_description', 'status' );
+		return array( 'name', 'sku', 'description', 'short_description', 'status', 'categories', 'tags', 'brands', 'images', 'catalog_visibility', 'featured' );
 	}

 	/**
@@ -634,23 +707,34 @@ trait ProductAbilityTrait {
 		}

 		$setters = array(
-			'parent_id'         => 'set_parent_id',
-			'name'              => 'set_name',
-			'sku'               => 'set_sku',
-			'regular_price'     => 'set_regular_price',
-			'sale_price'        => 'set_sale_price',
-			'description'       => 'set_description',
-			'short_description' => 'set_short_description',
-			'status'            => 'set_status',
-			'virtual'           => 'set_virtual',
-			'downloadable'      => 'set_downloadable',
-			'manage_stock'      => 'set_manage_stock',
-			'stock_quantity'    => 'set_stock_quantity',
-			'stock_status'      => 'set_stock_status',
-			'external_url'      => 'set_product_url',
-			'button_text'       => 'set_button_text',
-			'grouped_products'  => 'set_children',
-			'attributes'        => 'set_attributes',
+			'parent_id'          => 'set_parent_id',
+			'name'               => 'set_name',
+			'sku'                => 'set_sku',
+			'regular_price'      => 'set_regular_price',
+			'sale_price'         => 'set_sale_price',
+			'description'        => 'set_description',
+			'short_description'  => 'set_short_description',
+			'status'             => 'set_status',
+			'virtual'            => 'set_virtual',
+			'downloadable'       => 'set_downloadable',
+			'manage_stock'       => 'set_manage_stock',
+			'stock_quantity'     => 'set_stock_quantity',
+			'stock_status'       => 'set_stock_status',
+			'external_url'       => 'set_product_url',
+			'button_text'        => 'set_button_text',
+			'grouped_products'   => 'set_children',
+			'attributes'         => 'set_attributes',
+			'categories'         => 'set_category_ids',
+			'tags'               => 'set_tag_ids',
+			'brands'             => 'set_brand_ids',
+			'catalog_visibility' => 'set_catalog_visibility',
+			'featured'           => 'set_featured',
+			'date_on_sale_from'  => 'set_date_on_sale_from',
+			'date_on_sale_to'    => 'set_date_on_sale_to',
+			'tax_status'         => 'set_tax_status',
+			'sold_individually'  => 'set_sold_individually',
+			'weight'             => 'set_weight',
+			'shipping_class'     => 'set_shipping_class_id',
 		);

 		foreach ( $setters as $field => $setter ) {
@@ -672,6 +756,23 @@ trait ProductAbilityTrait {
 			$product->{$setter}( $prepared_value );
 		}

+		foreach ( array( 'images', 'dimensions' ) as $field ) {
+			if ( ! in_array( $field, $product_config['fields'], true ) || ! array_key_exists( $field, $input ) ) {
+				continue;
+			}
+
+			$prepared_value = self::prepare_product_field_value( $field, $input[ $field ] );
+			if ( is_wp_error( $prepared_value ) ) {
+				return $prepared_value;
+			}
+
+			if ( 'images' === $field ) {
+				self::set_product_images( $product, $prepared_value );
+			} else {
+				self::set_product_dimensions( $product, $prepared_value );
+			}
+		}
+
 		return null;
 	}

@@ -1247,6 +1348,35 @@ trait ProductAbilityTrait {
 		);
 	}

+	/**
+	 * Set the featured image from the first ID and the gallery from the rest.
+	 *
+	 * @param \WC_Product     $product   Product object.
+	 * @param array<int, int> $image_ids Attachment IDs.
+	 */
+	private static function set_product_images( \WC_Product $product, array $image_ids ): void {
+		$product->set_image_id( $image_ids[0] ?? '' );
+		$product->set_gallery_image_ids( array_slice( $image_ids, 1 ) );
+	}
+
+	/**
+	 * Set only the dimensions in the input, so the others keep their value.
+	 *
+	 * @param \WC_Product           $product    Product object.
+	 * @param array<string, string> $dimensions Dimensions keyed by length, width or height.
+	 */
+	private static function set_product_dimensions( \WC_Product $product, array $dimensions ): void {
+		$setters = array(
+			'length' => 'set_length',
+			'width'  => 'set_width',
+			'height' => 'set_height',
+		);
+
+		foreach ( array_intersect_key( $dimensions, $setters ) as $dimension => $value ) {
+			$product->{$setters[ $dimension ]}( $value );
+		}
+	}
+
 	/**
 	 * Apply product type configuration to a product object.
 	 *
@@ -1305,7 +1435,7 @@ trait ProductAbilityTrait {
 	 *
 	 * @param string $field Field name.
 	 * @param mixed  $value Field value.
-	 * @return mixed
+	 * @return mixed|\WP_Error
 	 */
 	private static function prepare_product_field_value( string $field, $value ) {
 		if ( in_array( $field, array( 'name', 'description', 'short_description' ), true ) ) {
@@ -1320,6 +1450,35 @@ trait ProductAbilityTrait {
 			return wc_format_decimal( $value );
 		}

+		$term_taxonomies = array(
+			'categories' => 'product_cat',
+			'tags'       => 'product_tag',
+			'brands'     => 'product_brand',
+		);
+		if ( isset( $term_taxonomies[ $field ] ) ) {
+			return self::prepare_product_term_ids( $field, $value, $term_taxonomies[ $field ] );
+		}
+
+		if ( 'images' === $field ) {
+			return self::prepare_product_image_ids( $value );
+		}
+
+		if ( in_array( $field, array( 'date_on_sale_from', 'date_on_sale_to' ), true ) ) {
+			return self::prepare_product_sale_date( $field, $value );
+		}
+
+		if ( 'weight' === $field ) {
+			return wc_format_decimal( $value );
+		}
+
+		if ( 'dimensions' === $field ) {
+			return array_map( 'wc_format_decimal', $value );
+		}
+
+		if ( 'shipping_class' === $field ) {
+			return self::prepare_product_shipping_class_id( $value );
+		}
+
 		if ( is_string( $value ) ) {
 			return wc_clean( $value );
 		}
@@ -1327,6 +1486,134 @@ trait ProductAbilityTrait {
 		return $value;
 	}

+	/**
+	 * Check that every ID is a term of the taxonomy.
+	 *
+	 * @param string $field    Input field, for the error message.
+	 * @param int[]  $value    Term IDs.
+	 * @param string $taxonomy Taxonomy.
+	 * @return array<int, int>|\WP_Error
+	 */
+	private static function prepare_product_term_ids( string $field, array $value, string $taxonomy ) {
+		$term_ids = array_values( array_unique( $value ) );
+
+		$missing_ids = array_filter( $term_ids, static fn( int $term_id ): bool => ! get_term( $term_id, $taxonomy ) instanceof \WP_Term );
+
+		if ( ! empty( $missing_ids ) ) {
+			return new \WP_Error(
+				'woocommerce_ability_product_term_not_found',
+				sprintf(
+					/* translators: 1: input field, such as categories. 2: comma-separated term IDs. */
+					__( 'These %1$s do not exist: %2$s.', 'woocommerce' ),
+					$field,
+					implode( ', ', $missing_ids )
+				),
+				array( 'status' => 400 )
+			);
+		}
+
+		return $term_ids;
+	}
+
+	/**
+	 * Check that every ID is an image attachment.
+	 *
+	 * @param int[] $value Attachment IDs.
+	 * @return array<int, int>|\WP_Error
+	 */
+	private static function prepare_product_image_ids( array $value ) {
+		$image_ids = array_values( array_unique( $value ) );
+
+		$invalid_ids = array_filter( $image_ids, static fn( int $image_id ): bool => ! wp_attachment_is_image( $image_id ) );
+
+		if ( ! empty( $invalid_ids ) ) {
+			return new \WP_Error(
+				'woocommerce_ability_product_image_invalid',
+				sprintf(
+					/* translators: %s: comma-separated attachment IDs. */
+					__( 'These IDs are not images in the media library: %s.', 'woocommerce' ),
+					implode( ', ', $invalid_ids )
+				),
+				array( 'status' => 400 )
+			);
+		}
+
+		return $image_ids;
+	}
+
+	/**
+	 * A sale date in store time. A date-only end keeps the sale running until the end of that day.
+	 *
+	 * @param string $field Input field.
+	 * @param string $value Date string, or empty to remove the date.
+	 * @return string|\WP_Error
+	 */
+	private static function prepare_product_sale_date( string $field, string $value ) {
+		$is_real_date = checkdate( (int) substr( $value, 5, 2 ), (int) substr( $value, 8, 2 ), (int) substr( $value, 0, 4 ) );
+
+		if ( '' !== $value && ! $is_real_date ) {
+			return new \WP_Error(
+				'woocommerce_ability_product_date_invalid',
+				__( 'Sale dates must be real dates and times, such as 2030-01-31 or 2030-01-31T18:30:00.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		if ( 'date_on_sale_to' === $field && 10 === strlen( $value ) ) {
+			return $value . ' 23:59:59';
+		}
+
+		return $value;
+	}
+
+	/**
+	 * Get a schema-compatible pattern for sale date inputs.
+	 *
+	 * @return string
+	 */
+	private static function get_product_date_input_pattern(): string {
+		return '^(?:[0-9]{4}-[0-9]{2}-[0-9]{2}(?:[T ](?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?)?)?$';
+	}
+
+	/**
+	 * Get the term ID of a shipping class slug, or 0 for an empty slug.
+	 *
+	 * @param string $value Shipping class slug.
+	 * @return int|\WP_Error
+	 */
+	private static function prepare_product_shipping_class_id( string $value ) {
+		if ( '' === $value ) {
+			return 0;
+		}
+
+		$term = get_term_by( 'slug', sanitize_title( $value ), 'product_shipping_class' );
+
+		if ( ! $term instanceof \WP_Term ) {
+			return new \WP_Error(
+				'woocommerce_ability_product_shipping_class_not_found',
+				sprintf(
+					/* translators: %s: shipping class slug sent in the input. */
+					__( 'Shipping class "%s" not found. Send the slug of an existing shipping class, or an empty string to remove it.', 'woocommerce' ),
+					$value
+				),
+				array( 'status' => 400 )
+			);
+		}
+
+		return $term->term_id;
+	}
+
+	/**
+	 * Get a schema-compatible pattern for non-negative weight and dimension inputs.
+	 *
+	 * @return string
+	 */
+	private static function get_product_measurement_input_pattern(): string {
+		$decimal_separators = preg_quote( implode( '', self::get_product_price_decimal_separators() ), '/' );
+
+		return '^(?:[0-9]+(?:[' . $decimal_separators . '][0-9]+)?|[' . $decimal_separators . '][0-9]+|)$';
+	}
+
 	/**
 	 * Get a schema-compatible pattern for product price inputs.
 	 *
@@ -1468,47 +1755,50 @@ trait ProductAbilityTrait {
 		$permalink        = $product->get_permalink();

 		$data = array(
-			'id'                => $product->get_id(),
-			'name'              => $product->get_name(),
-			'slug'              => $product->get_slug(),
-			'type'              => $product->get_type(),
-			'status'            => $product->get_status(),
-			'sku'               => $product->get_sku(),
-			'currency'          => get_woocommerce_currency(),
-			'currency_symbol'   => html_entity_decode(
+			'id'                 => $product->get_id(),
+			'name'               => $product->get_name(),
+			'slug'               => $product->get_slug(),
+			'type'               => $product->get_type(),
+			'status'             => $product->get_status(),
+			'sku'                => $product->get_sku(),
+			'currency'           => get_woocommerce_currency(),
+			'currency_symbol'    => html_entity_decode(
 				get_woocommerce_currency_symbol(),
 				ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
 			),
-			'price'             => self::format_amount_for_response( $product->get_price() ),
-			'regular_price'     => self::format_amount_for_response( $product->get_regular_price() ),
-			'sale_price'        => self::format_amount_for_response( $product->get_sale_price() ),
-			'on_sale'           => (bool) $product->is_on_sale(),
-			'stock_status'      => $product->get_stock_status(),
-			'manage_stock'      => (bool) $product->get_manage_stock(),
-			'virtual'           => (bool) $product->get_virtual(),
-			'downloadable'      => (bool) $product->get_downloadable(),
-			'grouped_products'  => $product instanceof \WC_Product_Grouped ? array_map( 'absint', $product->get_children( 'edit' ) ) : array(),
-			'categories'        => self::format_product_terms_for_response( $product->get_category_ids( 'edit' ), 'product_cat' ),
-			'tags'              => self::format_product_terms_for_response( $product->get_tag_ids( 'edit' ), 'product_tag' ),
-			'brands'            => self::format_product_terms_for_response( $product->get_brand_ids( 'edit' ), 'product_brand' ),
-			'description'       => $product->get_description(),
-			'short_description' => $product->get_short_description(),
-			'images'            => self::format_product_images_for_response( $product ),
-			'weight'            => $product->get_weight(),
-			'dimensions'        => array(
+			'price'              => self::format_amount_for_response( $product->get_price() ),
+			'regular_price'      => self::format_amount_for_response( $product->get_regular_price() ),
+			'sale_price'         => self::format_amount_for_response( $product->get_sale_price() ),
+			'on_sale'            => (bool) $product->is_on_sale(),
+			'stock_status'       => $product->get_stock_status(),
+			'manage_stock'       => (bool) $product->get_manage_stock(),
+			'sold_individually'  => (bool) $product->get_sold_individually(),
+			'virtual'            => (bool) $product->get_virtual(),
+			'downloadable'       => (bool) $product->get_downloadable(),
+			'grouped_products'   => $product instanceof \WC_Product_Grouped ? array_map( 'absint', $product->get_children( 'edit' ) ) : array(),
+			'categories'         => self::format_product_terms_for_response( $product->get_category_ids( 'edit' ), 'product_cat' ),
+			'tags'               => self::format_product_terms_for_response( $product->get_tag_ids( 'edit' ), 'product_tag' ),
+			'brands'             => self::format_product_terms_for_response( $product->get_brand_ids( 'edit' ), 'product_brand' ),
+			'catalog_visibility' => $product->get_catalog_visibility(),
+			'featured'           => (bool) $product->get_featured(),
+			'description'        => $product->get_description(),
+			'short_description'  => $product->get_short_description(),
+			'images'             => self::format_product_images_for_response( $product ),
+			'weight'             => $product->get_weight(),
+			'dimensions'         => array(
 				'length' => $product->get_length(),
 				'width'  => $product->get_width(),
 				'height' => $product->get_height(),
 			),
-			'shipping_required' => (bool) $product->needs_shipping(),
-			'shipping_class'    => $product->get_shipping_class(),
-			'shipping_class_id' => $product->get_shipping_class_id(),
-			'tax_status'        => $product->get_tax_status(),
-			'tax_class'         => $product->get_tax_class(),
-			'backorders'        => $product->get_backorders(),
-			'parent_id'         => (int) $product->get_parent_id(),
-			'attributes'        => self::format_product_attributes( $product ),
-			'variation_ids'     => $product instanceof \WC_Product_Variable ? self::read_variation_children( $product, false )['all'] : array(),
+			'shipping_required'  => (bool) $product->needs_shipping(),
+			'shipping_class'     => $product->get_shipping_class(),
+			'shipping_class_id'  => $product->get_shipping_class_id(),
+			'tax_status'         => $product->get_tax_status(),
+			'tax_class'          => $product->get_tax_class(),
+			'backorders'         => $product->get_backorders(),
+			'parent_id'          => (int) $product->get_parent_id(),
+			'attributes'         => self::format_product_attributes( $product ),
+			'variation_ids'      => $product instanceof \WC_Product_Variable ? self::read_variation_children( $product, false )['all'] : array(),
 		);

 		if ( false !== $permalink ) {
@@ -1533,6 +1823,8 @@ trait ProductAbilityTrait {

 		$data += array_filter(
 			array(
+				'date_on_sale_from' => self::format_date_for_response( $product->get_date_on_sale_from( 'edit' ), false ),
+				'date_on_sale_to'   => self::format_date_for_response( $product->get_date_on_sale_to( 'edit' ), false ),
 				'date_created'      => self::format_date_for_response( $product->get_date_created(), false ),
 				'date_created_gmt'  => self::format_date_for_response( $product->get_date_created() ),
 				'date_modified'     => self::format_date_for_response( $product->get_date_modified(), false ),
@@ -1729,74 +2021,85 @@ trait ProductAbilityTrait {
 		return array(
 			'type'                 => 'object',
 			'properties'           => array(
-				'id'                => array( 'type' => 'integer' ),
-				'name'              => array( 'type' => 'string' ),
-				'slug'              => array( 'type' => 'string' ),
-				'permalink'         => array(
+				'id'                 => array( 'type' => 'integer' ),
+				'name'               => array( 'type' => 'string' ),
+				'slug'               => array( 'type' => 'string' ),
+				'permalink'          => array(
 					'type'        => 'string',
 					'description' => __( 'Product permalink. Omitted when no public permalink is available.', 'woocommerce' ),
 					'format'      => 'uri',
 				),
-				'type'              => array(
+				'type'               => array(
 					'type'        => 'string',
 					'description' => __( 'Internal product type slug, such as simple, external, grouped, variable or variation.', 'woocommerce' ),
 				),
-				'status'            => array(
+				'status'             => array(
 					'type' => 'string',
 					'enum' => self::get_product_output_status_slugs(),
 				),
-				'sku'               => array( 'type' => 'string' ),
-				'currency'          => array(
+				'sku'                => array( 'type' => 'string' ),
+				'currency'           => array(
 					'type' => 'string',
 					'enum' => array_keys( get_woocommerce_currencies() ),
 				),
-				'currency_symbol'   => array( 'type' => 'string' ),
-				'price'             => array(
+				'currency_symbol'    => array( 'type' => 'string' ),
+				'price'              => array(
 					'type'        => 'string',
 					'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
 				),
-				'regular_price'     => array(
+				'regular_price'      => array(
 					'type'        => 'string',
 					'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
 				),
-				'sale_price'        => array(
+				'sale_price'         => array(
 					'type'        => 'string',
 					'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
 				),
-				'on_sale'           => array(
+				'on_sale'            => array(
 					'type'        => 'boolean',
 					'description' => __( 'Whether the sale price applies now, taking the sale dates into account.', 'woocommerce' ),
 				),
-				'stock_status'      => array(
+				'date_on_sale_from'  => array(
+					'type'        => 'string',
+					'description' => __( 'Start of the sale in the store timezone. Omitted when the sale has no start date.', 'woocommerce' ),
+					'format'      => 'date-time',
+				),
+				'date_on_sale_to'    => array(
+					'type'        => 'string',
+					'description' => __( 'End of the sale in the store timezone. Omitted when the sale has no end date.', 'woocommerce' ),
+					'format'      => 'date-time',
+				),
+				'stock_status'       => array(
 					'type' => 'string',
 					'enum' => array_keys( wc_get_product_stock_status_options() ),
 				),
-				'stock_quantity'    => array(
+				'stock_quantity'     => array(
 					'type'        => self::get_product_stock_quantity_schema_type(),
 					'description' => __( 'Current stock quantity. Omitted when no stock quantity is set.', 'woocommerce' ),
 				),
-				'manage_stock'      => array( 'type' => 'boolean' ),
-				'virtual'           => array( 'type' => 'boolean' ),
-				'downloadable'      => array( 'type' => 'boolean' ),
-				'external_url'      => array(
+				'manage_stock'       => array( 'type' => 'boolean' ),
+				'sold_individually'  => array( 'type' => 'boolean' ),
+				'virtual'            => array( 'type' => 'boolean' ),
+				'downloadable'       => array( 'type' => 'boolean' ),
+				'external_url'       => array(
 					'type'        => 'string',
 					'description' => __( 'External product URL. Omitted when the product is not external or no URL is set.', 'woocommerce' ),
 					'format'      => 'uri',
 				),
-				'button_text'       => array(
+				'button_text'        => array(
 					'type'        => 'string',
 					'description' => __( 'Button text. Omitted when the product is not external or no button text is set.', 'woocommerce' ),
 				),
-				'grouped_products'  => array(
+				'grouped_products'   => array(
 					'type'        => 'array',
 					'description' => __( 'Product IDs included as children of a grouped product.', 'woocommerce' ),
 					'items'       => array( 'type' => 'integer' ),
 				),
-				'parent_id'         => array(
+				'parent_id'          => array(
 					'type'        => 'integer',
 					'description' => __( 'ID of the variable product of a variation, or 0.', 'woocommerce' ),
 				),
-				'attributes'        => array(
+				'attributes'         => array(
 					'type'        => 'array',
 					'description' => __( 'Attributes with their options on a product, or the option each attribute picks on a variation. For a global attribute the options are term slugs.', 'woocommerce' ),
 					'items'       => array(
@@ -1825,12 +2128,17 @@ trait ProductAbilityTrait {
 						'additionalProperties' => false,
 					),
 				),
-				'categories'        => self::get_product_terms_output_schema(),
-				'tags'              => self::get_product_terms_output_schema(),
-				'brands'            => self::get_product_terms_output_schema(),
-				'description'       => array( 'type' => 'string' ),
-				'short_description' => array( 'type' => 'string' ),
-				'images'            => array(
+				'categories'         => self::get_product_terms_output_schema(),
+				'tags'               => self::get_product_terms_output_schema(),
+				'brands'             => self::get_product_terms_output_schema(),
+				'catalog_visibility' => array(
+					'type' => 'string',
+					'enum' => array_keys( wc_get_product_visibility_options() ),
+				),
+				'featured'           => array( 'type' => 'boolean' ),
+				'description'        => array( 'type' => 'string' ),
+				'short_description'  => array( 'type' => 'string' ),
+				'images'             => array(
 					'type'        => 'array',
 					'description' => __( 'Featured image first, then the gallery.', 'woocommerce' ),
 					'items'       => array(
@@ -1847,7 +2155,7 @@ trait ProductAbilityTrait {
 						'additionalProperties' => false,
 					),
 				),
-				'weight'            => array(
+				'weight'             => array(
 					'type'        => 'string',
 					'description' => sprintf(
 						/* translators: %s: weight unit, such as kg. */
@@ -1855,7 +2163,7 @@ trait ProductAbilityTrait {
 						get_option( 'woocommerce_weight_unit' )
 					),
 				),
-				'dimensions'        => array(
+				'dimensions'         => array(
 					'type'                 => 'object',
 					'description'          => sprintf(
 						/* translators: %s: dimension unit, such as cm. */
@@ -1869,52 +2177,52 @@ trait ProductAbilityTrait {
 					),
 					'additionalProperties' => false,
 				),
-				'shipping_required' => array( 'type' => 'boolean' ),
-				'shipping_class'    => array(
+				'shipping_required'  => array( 'type' => 'boolean' ),
+				'shipping_class'     => array(
 					'type'        => 'string',
 					'description' => __( 'Shipping class slug, or empty when the product has none.', 'woocommerce' ),
 				),
-				'shipping_class_id' => array(
+				'shipping_class_id'  => array(
 					'type'        => 'integer',
 					'description' => __( 'Shipping class term ID, or 0 when the product has none.', 'woocommerce' ),
 				),
-				'tax_status'        => array(
+				'tax_status'         => array(
 					'type' => 'string',
 					'enum' => array( ProductTaxStatus::TAXABLE, ProductTaxStatus::SHIPPING, ProductTaxStatus::NONE ),
 				),
-				'tax_class'         => array(
+				'tax_class'          => array(
 					'type'        => 'string',
 					'description' => __( 'Tax class slug, or empty for the standard rate.', 'woocommerce' ),
 				),
-				'backorders'        => array(
+				'backorders'         => array(
 					'type' => 'string',
 					'enum' => array_keys( wc_get_product_backorder_options() ),
 				),
-				'low_stock_amount'  => array(
+				'low_stock_amount'   => array(
 					'type'        => self::get_product_stock_quantity_schema_type(),
 					'description' => __( 'Low stock threshold of the product. Omitted when the store-wide threshold applies.', 'woocommerce' ),
 				),
-				'variation_ids'     => array(
+				'variation_ids'      => array(
 					'type'        => 'array',
 					'description' => __( 'IDs of the variations of a variable product.', 'woocommerce' ),
 					'items'       => array( 'type' => 'integer' ),
 				),
-				'date_created'      => array(
+				'date_created'       => array(
 					'type'        => 'string',
 					'format'      => 'date-time',
 					'description' => __( 'Product creation date in the store timezone. Omitted when no creation date is available.', 'woocommerce' ),
 				),
-				'date_created_gmt'  => array(
+				'date_created_gmt'   => array(
 					'type'        => 'string',
 					'format'      => 'date-time',
 					'description' => __( 'Product creation date in UTC. Omitted when no creation date is available.', 'woocommerce' ),
 				),
-				'date_modified'     => array(
+				'date_modified'      => array(
 					'type'        => 'string',
 					'format'      => 'date-time',
 					'description' => __( 'Product modification date in the store timezone. Omitted when no modification date is available.', 'woocommerce' ),
 				),
-				'date_modified_gmt' => array(
+				'date_modified_gmt'  => array(
 					'type'        => 'string',
 					'format'      => 'date-time',
 					'description' => __( 'Product modification date in UTC. Omitted when no modification date is available.', 'woocommerce' ),
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index b4a870cc3b9..0c846de9aaf 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -427,8 +427,8 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 			}
 			$description = $schema['properties']['type']['description'];
 			$this->assertStringContainsString( 'Fields each type takes.', $description );
-			$this->assertStringContainsString( 'external: name, sku, description, short_description, status, regular_price, sale_price, external_url, button_text', $description );
-			$this->assertStringContainsString( 'grouped: name, sku, description, short_description, status, grouped_products', $description );
+			$this->assertStringContainsString( 'external: name, sku, description, short_description, status, categories, tags, brands, images, catalog_visibility, featured, regular_price, sale_price, date_on_sale_from, date_on_sale_to, tax_status, external_url, button_text', $description );
+			$this->assertStringContainsString( 'grouped: name, sku, description, short_description, status, categories, tags, brands, images, catalog_visibility, featured, grouped_products', $description );
 			$this->assertStringEndsWith( 'Applies to: grouped.', $schema['properties']['grouped_products']['description'] );
 			$this->assertStringContainsString( 'Applies to: ', $schema['properties']['regular_price']['description'] );
 			$this->assertStringNotContainsString( 'Applies to: ', $schema['properties']['sku']['description'] ?? '' );
@@ -1874,7 +1874,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		$this->assertSame( '9.00', $subscription['product']['regular_price'] );
 		$this->assertWPError( $bundle_price );
 		$this->assertSame( 'woocommerce_product_field_unsupported', $bundle_price->get_error_code() );
-		$this->assertSame( 'The test-bundle product type does not take regular_price. It takes name, sku, description, short_description, status, virtual, downloadable.', $bundle_price->get_error_message() );
+		$this->assertSame( 'The test-bundle product type does not take regular_price. It takes name, sku, description, short_description, status, categories, tags, brands, images, catalog_visibility, featured, virtual, downloadable, weight, dimensions, shipping_class.', $bundle_price->get_error_message() );
 	}

 	/**
@@ -3194,7 +3194,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {

 		$this->assertWPError( $result );
 		$this->assertSame( 'woocommerce_product_field_unsupported', $result->get_error_code() );
-		$this->assertSame( 'The grouped product type does not take regular_price. It takes name, sku, description, short_description, status, grouped_products.', $result->get_error_message() );
+		$this->assertSame( 'The grouped product type does not take regular_price. It takes name, sku, description, short_description, status, categories, tags, brands, images, catalog_visibility, featured, grouped_products.', $result->get_error_message() );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductWritesTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductWritesTest.php
new file mode 100644
index 00000000000..490cf3fa8ea
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductWritesTest.php
@@ -0,0 +1,379 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Enums\ProductTaxStatus;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the catalog fields of the product-create and product-update abilities.
+ */
+class ProductWritesTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Log in as an administrator, who may create, edit and publish products.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+	}
+
+	/**
+	 * @testdox Should read back every catalog field that product-create writes.
+	 */
+	public function test_create_round_trips_catalog_fields(): void {
+		$category       = wp_insert_term( 'Hoodies', 'product_cat' )['term_id'];
+		$tag            = wp_insert_term( 'Winter', 'product_tag' )['term_id'];
+		$brand          = wp_insert_term( 'Acme', 'product_brand' )['term_id'];
+		$shipping_class = wp_insert_term( 'Bulky', 'product_shipping_class' )['term_id'];
+		$featured_image = $this->create_image( 'front.jpg' );
+		$gallery_image  = $this->create_image( 'back.jpg' );
+
+		$created = $this->execute(
+			'woocommerce/product-create',
+			array(
+				'name'               => 'Hoodie',
+				'regular_price'      => '60',
+				'sale_price'         => '40',
+				'categories'         => array( $category ),
+				'tags'               => array( $tag ),
+				'brands'             => array( $brand ),
+				'images'             => array( $featured_image, $gallery_image ),
+				'catalog_visibility' => 'search',
+				'featured'           => true,
+				'date_on_sale_from'  => '2030-01-01',
+				'date_on_sale_to'    => '2030-01-31T18:30:00',
+				'tax_status'         => ProductTaxStatus::SHIPPING,
+				'sold_individually'  => true,
+				'weight'             => '0.8',
+				'dimensions'         => array(
+					'length' => '30',
+					'width'  => '20',
+					'height' => '5',
+				),
+				'shipping_class'     => 'bulky',
+			)
+		);
+
+		$product = $this->query_product( $created['product']['id'] );
+
+		$this->assertSame( array( $category ), array_column( $product['categories'], 'id' ) );
+		$this->assertSame( array( $tag ), array_column( $product['tags'], 'id' ) );
+		$this->assertSame( array( $brand ), array_column( $product['brands'], 'id' ) );
+		$this->assertSame( array( $featured_image, $gallery_image ), array_column( $product['images'], 'id' ) );
+		$this->assertSame( 'search', $product['catalog_visibility'] );
+		$this->assertTrue( $product['featured'] );
+		$this->assertSame( '2030-01-01T00:00:00+00:00', $product['date_on_sale_from'] );
+		$this->assertSame( '2030-01-31T18:30:00+00:00', $product['date_on_sale_to'] );
+		$this->assertFalse( $product['on_sale'], 'The sale has not started yet.' );
+		$this->assertSame( ProductTaxStatus::SHIPPING, $product['tax_status'] );
+		$this->assertTrue( $product['sold_individually'] );
+		$this->assertSame( '0.8', $product['weight'] );
+		$this->assertSame( '30', $product['dimensions']['length'] );
+		$this->assertSame( '20', $product['dimensions']['width'] );
+		$this->assertSame( '5', $product['dimensions']['height'] );
+		$this->assertSame( 'bulky', $product['shipping_class'] );
+		$this->assertSame( $shipping_class, $product['shipping_class_id'] );
+	}
+
+	/**
+	 * @testdox Should set terms, images, visibility and featured when updating an external product.
+	 */
+	public function test_update_sets_common_catalog_fields_on_external_product(): void {
+		$product  = WC_Helper_Product::create_external_product();
+		$category = wp_insert_term( 'Gadgets', 'product_cat' )['term_id'];
+		$image    = $this->create_image( 'gadget.jpg' );
+
+		$updated = $this->execute(
+			'woocommerce/product-update',
+			array(
+				'id'                 => $product->get_id(),
+				'categories'         => array( $category ),
+				'images'             => array( $image ),
+				'catalog_visibility' => 'hidden',
+				'featured'           => true,
+			)
+		)['product'];
+
+		$this->assertSame( array( $category ), array_column( $updated['categories'], 'id' ) );
+		$this->assertSame( array( $image ), array_column( $updated['images'], 'id' ) );
+		$this->assertSame( 'hidden', $updated['catalog_visibility'] );
+		$this->assertTrue( $updated['featured'] );
+	}
+
+	/**
+	 * @testdox Should remove all images and fall back to the default category when sent empty lists.
+	 */
+	public function test_update_with_empty_lists_clears_images_and_categories(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_category_ids( array( wp_insert_term( 'Old', 'product_cat' )['term_id'] ) );
+		$product->set_image_id( $this->create_image( 'old.jpg' ) );
+		$product->set_gallery_image_ids( array( $this->create_image( 'older.jpg' ) ) );
+		$product->save();
+
+		$updated = $this->execute(
+			'woocommerce/product-update',
+			array(
+				'id'         => $product->get_id(),
+				'categories' => array(),
+				'images'     => array(),
+			)
+		)['product'];
+
+		$this->assertSame( array(), $updated['images'] );
+		$this->assertSame( array( (int) get_option( 'default_product_cat' ) ), array_column( $updated['categories'], 'id' ) );
+	}
+
+	/**
+	 * @testdox Should change only the dimensions sent and remove a shipping class sent as an empty slug.
+	 */
+	public function test_update_changes_only_sent_dimensions(): void {
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'length'            => '10',
+				'width'             => '20',
+				'height'            => '30',
+				'shipping_class_id' => wp_insert_term( 'Small', 'product_shipping_class' )['term_id'],
+			)
+		);
+
+		$updated = $this->execute(
+			'woocommerce/product-update',
+			array(
+				'id'             => $product->get_id(),
+				'dimensions'     => array(
+					'width'  => '25',
+					'height' => '',
+				),
+				'shipping_class' => '',
+			)
+		)['product'];
+
+		$this->assertSame( '10', $updated['dimensions']['length'] );
+		$this->assertSame( '25', $updated['dimensions']['width'] );
+		$this->assertSame( '', $updated['dimensions']['height'] );
+		$this->assertSame( '', $updated['shipping_class'] );
+	}
+
+	/**
+	 * @testdox Should end a sale at the end of the day in store time when the end date has no time.
+	 */
+	public function test_date_only_sale_end_lasts_the_whole_day(): void {
+		update_option( 'timezone_string', 'Europe/Berlin' );
+
+		$created = $this->execute(
+			'woocommerce/product-create',
+			array(
+				'name'            => 'Sale tee',
+				'regular_price'   => '20',
+				'sale_price'      => '15',
+				'date_on_sale_to' => '2030-06-30',
+			)
+		);
+
+		$this->assertSame( '2030-06-30T23:59:59+02:00', $created['product']['date_on_sale_to'] );
+	}
+
+	/**
+	 * @testdox Should set catalog fields on a variable product and the fields its variations inherit.
+	 */
+	public function test_variable_product_fields(): void {
+		$category       = wp_insert_term( 'Shirts', 'product_cat' )['term_id'];
+		$shipping_class = wp_insert_term( 'Bulky', 'product_shipping_class' )['term_id'];
+		$image          = $this->create_image( 'shirt.jpg' );
+
+		$created = $this->execute(
+			'woocommerce/product-create',
+			array(
+				'name'              => 'Shirt',
+				'type'              => 'variable',
+				'categories'        => array( $category ),
+				'images'            => array( $image ),
+				'featured'          => true,
+				'tax_status'        => ProductTaxStatus::NONE,
+				'sold_individually' => true,
+				'weight'            => '0.3',
+				'shipping_class'    => 'bulky',
+				'attributes'        => array(
+					array(
+						'name'    => 'Size',
+						'options' => array( 'S', 'M' ),
+					),
+				),
+				'variations'        => array(
+					array(
+						'attributes'    => array(
+							array(
+								'name'   => 'Size',
+								'option' => 'S',
+							),
+						),
+						'regular_price' => '10',
+					),
+				),
+			)
+		)['product'];
+
+		$this->assertSame( array( $category ), array_column( $created['categories'], 'id' ) );
+		$this->assertSame( array( $image ), array_column( $created['images'], 'id' ) );
+		$this->assertTrue( $created['featured'] );
+		$this->assertSame( ProductTaxStatus::NONE, $created['tax_status'] );
+
+		$variation = wc_get_product( $created['variation_ids'][0] );
+
+		$this->assertSame( ProductTaxStatus::NONE, $variation->get_tax_status(), 'A variation reads its tax status from the parent.' );
+		$this->assertTrue( $variation->get_sold_individually() );
+		$this->assertSame( '0.3', $variation->get_weight() );
+		$this->assertSame( $shipping_class, $variation->get_shipping_class_id() );
+	}
+
+	/**
+	 * @testdox Should accept sale dates and the tax status on external products, but not sold individually.
+	 */
+	public function test_external_product_fields(): void {
+		$created = $this->execute(
+			'woocommerce/product-create',
+			array(
+				'name'              => 'Affiliate tee',
+				'type'              => 'external',
+				'external_url'      => 'https://example.com/tee',
+				'date_on_sale_from' => '2030-01-01',
+				'tax_status'        => ProductTaxStatus::NONE,
+			)
+		);
+
+		$this->assertSame( '2030-01-01T00:00:00+00:00', $created['product']['date_on_sale_from'] );
+		$this->assertArrayNotHasKey( 'date_on_sale_to', $created['product'], 'A sale without an end date should omit it.' );
+		$this->assertSame( ProductTaxStatus::NONE, $created['product']['tax_status'] );
+
+		$rejected = wp_get_ability( 'woocommerce/product-create' )->execute(
+			array(
+				'name'              => 'Affiliate tee',
+				'type'              => 'external',
+				'sold_individually' => true,
+			)
+		);
+
+		$this->assertWPError( $rejected );
+		$this->assertSame( 'woocommerce_product_field_unsupported', $rejected->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should reject shipping fields on an external product.
+	 *
+	 * @testWith ["weight", "1"]
+	 *           ["shipping_class", "bulky"]
+	 *
+	 * @param string $field Shipping field.
+	 * @param string $value Field value.
+	 */
+	public function test_external_product_rejects_shipping_fields( string $field, string $value ): void {
+		$result = wp_get_ability( 'woocommerce/product-create' )->execute(
+			array(
+				'name' => 'Affiliate tee',
+				'type' => 'external',
+				$field => $value,
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_product_field_unsupported', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should reject unknown terms, non-image attachments, unknown shipping classes and impossible dates without saving.
+	 */
+	public function test_rejects_unknown_references(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$image   = $this->create_image( 'kept.jpg' );
+		$product->set_image_id( $image );
+		$product->save();
+		$document = self::factory()->attachment->create_object(
+			array(
+				'file'           => 'manual.pdf',
+				'post_mime_type' => 'application/pdf',
+			)
+		);
+
+		$cases = array(
+			'woocommerce_ability_product_term_not_found' => array( 'tags' => array( 999999 ) ),
+			'woocommerce_ability_product_image_invalid'  => array( 'images' => array( $document ) ),
+			'woocommerce_ability_product_shipping_class_not_found' => array( 'shipping_class' => 'missing' ),
+			'woocommerce_ability_product_date_invalid'   => array( 'date_on_sale_from' => '2030-02-30' ),
+		);
+
+		foreach ( $cases as $error_code => $input ) {
+			$result = wp_get_ability( 'woocommerce/product-update' )->execute( array( 'id' => $product->get_id() ) + $input );
+
+			$this->assertWPError( $result );
+			$this->assertSame( $error_code, $result->get_error_code() );
+		}
+
+		$unknown_class = wp_get_ability( 'woocommerce/product-update' )->execute(
+			array(
+				'id'             => $product->get_id(),
+				'shipping_class' => 'missing',
+			)
+		);
+		$this->assertStringContainsString( '"missing"', $unknown_class->get_error_message() );
+
+		$out_of_range_time = wp_get_ability( 'woocommerce/product-update' )->execute(
+			array(
+				'id'              => $product->get_id(),
+				'date_on_sale_to' => '2030-01-31T24:00:00',
+			)
+		);
+		$this->assertWPError( $out_of_range_time );
+
+		$stored = wc_get_product( $product->get_id() );
+
+		$this->assertSame( array(), $stored->get_tag_ids() );
+		$this->assertSame( $image, (int) $stored->get_image_id() );
+		$this->assertSame( 0, $stored->get_shipping_class_id() );
+		$this->assertNull( $stored->get_date_on_sale_from() );
+	}
+
+	/**
+	 * Execute an ability and check it succeeded.
+	 *
+	 * @param string $ability Ability name.
+	 * @param array  $input   Ability input.
+	 * @return array
+	 */
+	private function execute( string $ability, array $input ): array {
+		$result = wp_get_ability( $ability )->execute( $input );
+
+		$this->assertIsArray( $result, is_wp_error( $result ) ? $result->get_error_message() : '' );
+
+		return $result;
+	}
+
+	/**
+	 * Read a product through products-query, as an agent would after a write.
+	 *
+	 * @param int $product_id Product ID.
+	 * @return array
+	 */
+	private function query_product( int $product_id ): array {
+		return $this->execute( 'woocommerce/products-query', array( 'id' => $product_id ) )['products'][0];
+	}
+
+	/**
+	 * Create an image attachment.
+	 *
+	 * @param string $file File name.
+	 * @return int
+	 */
+	private function create_image( string $file ): int {
+		return self::factory()->attachment->create_object(
+			array(
+				'file'           => $file,
+				'post_mime_type' => 'image/jpeg',
+			)
+		);
+	}
+}