Commit 79c22e8a226 for woocommerce

commit 79c22e8a226405422bd341767daa4f7b21a434f8
Author: Luigi Teschio <gigitux@gmail.com>
Date:   Mon May 18 17:08:16 2026 +0200

    Add cost of goods to product edit form (#65129)

    * Add cost of goods to product edit form

    * Add changelog entry for cost of goods field

diff --git a/packages/js/experimental-products-app/changelog/add-cost-of-goods-field b/packages/js/experimental-products-app/changelog/add-cost-of-goods-field
new file mode 100644
index 00000000000..1e152d89da0
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/add-cost-of-goods-field
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the cost of goods field to the experimental products edit form.
diff --git a/packages/js/experimental-products-app/src/fields/components/currency-input.tsx b/packages/js/experimental-products-app/src/fields/components/currency-input.tsx
index dbf2abfb7c0..f35b8ae508c 100644
--- a/packages/js/experimental-products-app/src/fields/components/currency-input.tsx
+++ b/packages/js/experimental-products-app/src/fields/components/currency-input.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable @wordpress/no-unsafe-wp-apis -- Currency inputs use wrappers for the private ValidatedInputControl API. */
 /**
  * External dependencies
  */
@@ -26,10 +27,56 @@ const isCurrencyLeft =
 // Step matches store decimal precision (e.g. precision=2 → step=0.01).
 const step = Math.pow( 10, -precision );

-type PriceField = 'regular_price' | 'sale_price';
+type CurrencyField = 'regular_price' | 'sale_price';
+
+type CurrencyControlProps = {
+	id: string;
+	label: string;
+	value: string | number;
+	onChange: ( newValue: string ) => void;
+	customValidity?: NonNullable<
+		DataFormControlProps< ProductEntityRecord >[ 'validity' ]
+	>[ 'custom' ];
+};
+
+export function CurrencyControl( {
+	id,
+	label,
+	value,
+	onChange,
+	customValidity,
+}: CurrencyControlProps ) {
+	return (
+		// eslint-disable-next-line @typescript-eslint/no-unsafe-call -- ValidatedInputControl is a private API
+		<ValidatedInputControl
+			id={ id }
+			label={ label }
+			value={ value }
+			onChange={ onChange }
+			type="number"
+			min={ 0 }
+			step={ step }
+			customValidity={ customValidity }
+			prefix={
+				isCurrencyLeft ? (
+					<InputControlPrefixWrapper>
+						{ symbol }
+					</InputControlPrefixWrapper>
+				) : undefined
+			}
+			suffix={
+				! isCurrencyLeft ? (
+					<InputControlSuffixWrapper>
+						{ symbol }
+					</InputControlSuffixWrapper>
+				) : undefined
+			}
+		/>
+	);
+}

 /**
- * Shared Edit component for currency price fields (regular_price, sale_price).
+ * Shared Edit component for currency fields.
  * Renders a number input with min=0 and currency prefix/suffix.
  *
  * @param root0          Props from DataForm.
@@ -44,36 +91,17 @@ export function CurrencyInput( {
 	onChange,
 	validity,
 }: DataFormControlProps< ProductEntityRecord > ) {
-	const fieldId = field.id as PriceField;
-	const value = data[ fieldId ] ?? '';
+	const fieldId = field.id as CurrencyField;

 	return (
-		// eslint-disable-next-line @typescript-eslint/no-unsafe-call -- ValidatedInputControl is a private API
-		<ValidatedInputControl
+		<CurrencyControl
 			id={ `currency-input-${ fieldId }` }
 			label={ field.label }
-			value={ value }
+			value={ data[ fieldId ] ?? '' }
 			onChange={ ( newValue: string ) => {
 				onChange( { [ fieldId ]: newValue } );
 			} }
-			type="number"
-			min={ 0 }
-			step={ step }
 			customValidity={ validity?.custom }
-			prefix={
-				isCurrencyLeft ? (
-					<InputControlPrefixWrapper>
-						{ symbol }
-					</InputControlPrefixWrapper>
-				) : undefined
-			}
-			suffix={
-				! isCurrencyLeft ? (
-					<InputControlSuffixWrapper>
-						{ symbol }
-					</InputControlSuffixWrapper>
-				) : undefined
-			}
 		/>
 	);
 }
diff --git a/packages/js/experimental-products-app/src/fields/cost_of_goods_sold/field.tsx b/packages/js/experimental-products-app/src/fields/cost_of_goods_sold/field.tsx
new file mode 100644
index 00000000000..9bb737c36fd
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/cost_of_goods_sold/field.tsx
@@ -0,0 +1,89 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import type { DataFormControlProps, Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import { validatePrice, toNumberOrNaN } from '../price/utils';
+import { formatCurrency, getCurrencyObject } from '../utils/currency';
+import { CurrencyControl } from '../components/currency-input';
+
+import type { ProductEntityRecord } from '../types';
+
+const fieldDefinition = {
+	type: 'text',
+	label: __( 'Cost of goods', 'woocommerce' ),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+	isVisible: ( item: ProductEntityRecord ) =>
+		item.cost_of_goods_sold !== undefined,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+function getDefinedCostValue( item: ProductEntityRecord ) {
+	return item.cost_of_goods_sold?.values?.[ 0 ]?.defined_value;
+}
+
+function CostOfGoodsSoldInput( {
+	data,
+	field,
+	onChange,
+	validity,
+}: DataFormControlProps< ProductEntityRecord > ) {
+	const costOfGoodsSold = data.cost_of_goods_sold ?? {};
+	const [ firstValue = {}, ...remainingValues ] =
+		costOfGoodsSold.values ?? [];
+
+	return (
+		<CurrencyControl
+			id={ `currency-input-${ field.id }` }
+			label={ field.label }
+			value={ getDefinedCostValue( data ) ?? '' }
+			onChange={ ( newValue: string ) => {
+				onChange( {
+					cost_of_goods_sold: {
+						...costOfGoodsSold,
+						values:
+							newValue === ''
+								? []
+								: [
+										{
+											...firstValue,
+											defined_value: newValue,
+										},
+										...remainingValues,
+								  ],
+					},
+				} );
+			} }
+			customValidity={ validity?.custom }
+		/>
+	);
+}
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	getValue: ( { item } ) => getDefinedCostValue( item ),
+	getValueFormatted: ( { item } ) => {
+		const value = getDefinedCostValue( item );
+		const numberValue = toNumberOrNaN( value );
+
+		if (
+			value === undefined ||
+			value === null ||
+			Number.isNaN( numberValue )
+		) {
+			return '\u2014';
+		}
+
+		return formatCurrency( numberValue, getCurrencyObject().code );
+	},
+	isValid: {
+		custom: ( item ) => validatePrice( getDefinedCostValue( item ) ),
+	},
+	Edit: CostOfGoodsSoldInput,
+};
diff --git a/packages/js/experimental-products-app/src/fields/registry.tsx b/packages/js/experimental-products-app/src/fields/registry.tsx
index cd3782d0ad6..da831517110 100644
--- a/packages/js/experimental-products-app/src/fields/registry.tsx
+++ b/packages/js/experimental-products-app/src/fields/registry.tsx
@@ -10,6 +10,7 @@ import { fieldExtensions as brandsFieldExtensions } from './brands/field';
 import { fieldExtensions as buttonTextFieldExtensions } from './button_text/field';
 import { fieldExtensions as catalogVisibilityFieldExtensions } from './catalog_visibility/field';
 import { fieldExtensions as categoriesFieldExtensions } from './categories/field';
+import { fieldExtensions as costOfGoodsSoldFieldExtensions } from './cost_of_goods_sold/field';
 import { fieldExtensions as crossSellIdsFieldExtensions } from './cross_sell_ids/field';
 import { fieldExtensions as dateFieldExtensions } from './date/field';
 import { fieldExtensions as dateOnSaleFromFieldExtensions } from './date_on_sale_from/field';
@@ -69,6 +70,7 @@ export const PRODUCT_FIELD_IDS = [
 	'schedule_sale',
 	'date_on_sale_from',
 	'date_on_sale_to',
+	'cost_of_goods_sold',
 	'price_summary',
 	'stock',
 	'stock_quantity',
@@ -119,6 +121,7 @@ const PRODUCT_FIELD_EXTENSIONS: Record<
 	schedule_sale: scheduleSaleFieldExtensions,
 	date_on_sale_from: dateOnSaleFromFieldExtensions,
 	date_on_sale_to: dateOnSaleToFieldExtensions,
+	cost_of_goods_sold: costOfGoodsSoldFieldExtensions,
 	on_sale: onSaleFieldExtensions,
 	price_summary: priceSummaryFieldExtensions,
 	stock: stockFieldExtensions,
diff --git a/packages/js/experimental-products-app/src/fields/types.ts b/packages/js/experimental-products-app/src/fields/types.ts
index 7dbdad1ad5b..2356e484226 100644
--- a/packages/js/experimental-products-app/src/fields/types.ts
+++ b/packages/js/experimental-products-app/src/fields/types.ts
@@ -7,6 +7,14 @@ import type {
 } from '@woocommerce/data';

 export type ProductEntityRecord = Omit< Product, 'categories' | 'tags' > & {
+	cost_of_goods_sold?: {
+		values?: Array< {
+			defined_value?: number | string | null;
+			effective_value?: number | string | null;
+		} >;
+		defined_value_is_additive?: boolean;
+		total_value?: number | string | null;
+	};
 	categories: Array< {
 		id: number;
 		name?: string;
diff --git a/packages/js/experimental-products-app/src/product-edit/utils.test.ts b/packages/js/experimental-products-app/src/product-edit/utils.test.ts
index bb0cf414165..d0dc9219ffc 100644
--- a/packages/js/experimental-products-app/src/product-edit/utils.test.ts
+++ b/packages/js/experimental-products-app/src/product-edit/utils.test.ts
@@ -2,6 +2,7 @@
  * External dependencies
  */
 import type { Field } from '@wordpress/dataviews';
+import { getSetting } from '@woocommerce/settings';

 /**
  * Internal dependencies
@@ -41,9 +42,24 @@ jest.mock( '@woocommerce/settings', () => ( {
 		symbolPosition: 'left',
 		precision: 2,
 	},
+	getSetting: jest.fn(),
 } ) );

 describe( 'product edit utils', () => {
+	const getSettingMock = getSetting as jest.Mock;
+	const mockCostOfGoodsSoldFeatureEnabled = ( isEnabled: boolean ) => {
+		getSettingMock.mockImplementation( ( name, fallback ) =>
+			name === 'admin'
+				? {
+						features: {
+							cost_of_goods_sold: {
+								is_enabled: isEnabled,
+							},
+						},
+				  }
+				: fallback
+		);
+	};
 	const buildProduct = (
 		overrides: Partial< ProductEntityRecord > = {}
 	): ProductEntityRecord =>
@@ -60,6 +76,21 @@ describe( 'product edit utils', () => {
 			images: [],
 			...overrides,
 		} as unknown as ProductEntityRecord );
+	const buildCostOfGoodsSold = (
+		definedValue: number | string | null = 5
+	): ProductEntityRecord[ 'cost_of_goods_sold' ] => ( {
+		values: [
+			{
+				defined_value: definedValue,
+				effective_value: definedValue,
+			},
+		],
+		total_value: definedValue,
+	} );
+
+	beforeEach( () => {
+		mockCostOfGoodsSoldFeatureEnabled( true );
+	} );

 	it( 'returns the original values for a single selected product', () => {
 		const product = buildProduct( {
@@ -311,6 +342,7 @@ describe( 'product edit utils', () => {
 			'schedule_sale',
 			'date_on_sale_from',
 			'date_on_sale_to',
+			'cost_of_goods_sold',
 		];
 		const basePriceFieldIds = [ 'regular_price', 'sale_price' ];
 		const managedStockFieldIds = [ 'manage_stock', 'stock_quantity' ];
@@ -340,6 +372,7 @@ describe( 'product edit utils', () => {
 					on_sale: true,
 					sale_price: '12',
 					date_on_sale_from: '2026-05-06T00:00:00',
+					cost_of_goods_sold: buildCostOfGoodsSold(),
 				} ),
 			] );

@@ -352,6 +385,7 @@ describe( 'product edit utils', () => {
 				'schedule_sale',
 				'date_on_sale_from',
 				'date_on_sale_to',
+				'cost_of_goods_sold',
 				'images',
 				'sku',
 				'stock',
@@ -396,10 +430,41 @@ describe( 'product edit utils', () => {
 					on_sale: true,
 					sale_price: '12',
 					date_on_sale_from: '2026-05-06T00:00:00',
+					cost_of_goods_sold: buildCostOfGoodsSold(),
+				} ),
+			] );
+
+			expectFieldOrder( fieldIds, [
+				'regular_price',
+				'sale_price',
+				'schedule_sale',
+				'date_on_sale_from',
+				'date_on_sale_to',
+				'cost_of_goods_sold',
+			] );
+		} );
+
+		it( 'hides cost of goods when the API data is unavailable', () => {
+			const fieldIds = getVisibleFieldIds( [
+				buildProduct( {
+					type: 'simple',
+				} ),
+			] );
+
+			expectFieldsHidden( fieldIds, [ 'cost_of_goods_sold' ] );
+		} );
+
+		it( 'hides cost of goods when the feature is disabled', () => {
+			mockCostOfGoodsSoldFeatureEnabled( false );
+
+			const fieldIds = getVisibleFieldIds( [
+				buildProduct( {
+					type: 'simple',
+					cost_of_goods_sold: buildCostOfGoodsSold(),
 				} ),
 			] );

-			expectFieldOrder( fieldIds, [ 'regular_price', 'sale_price' ] );
+			expectFieldsHidden( fieldIds, [ 'cost_of_goods_sold' ] );
 		} );

 		it( 'hides shipping fields for virtual simple products', () => {
@@ -655,6 +720,7 @@ describe( 'product edit utils', () => {
 					on_sale: true,
 					sale_price: '12',
 					date_on_sale_from: '2026-05-06T00:00:00',
+					cost_of_goods_sold: buildCostOfGoodsSold(),
 				} ),
 			] );

@@ -666,6 +732,7 @@ describe( 'product edit utils', () => {
 					'schedule_sale',
 					'date_on_sale_from',
 					'date_on_sale_to',
+					'cost_of_goods_sold',
 					'images',
 					'sku',
 					'manage_stock',
@@ -695,6 +762,7 @@ describe( 'product edit utils', () => {
 					on_sale: true,
 					sale_price: '12',
 					date_on_sale_from: '2026-05-06T00:00:00',
+					cost_of_goods_sold: buildCostOfGoodsSold(),
 				} ),
 			] );

@@ -707,6 +775,7 @@ describe( 'product edit utils', () => {
 					'schedule_sale',
 					'date_on_sale_from',
 					'date_on_sale_to',
+					'cost_of_goods_sold',
 					'stock',
 					'manage_stock',
 					'product_status',
@@ -766,6 +835,7 @@ describe( 'product edit utils', () => {
 					on_sale: true,
 					sale_price: '12',
 					date_on_sale_from: '2026-05-06T00:00:00',
+					cost_of_goods_sold: buildCostOfGoodsSold(),
 				} ),
 				buildProduct( {
 					id: 34,
@@ -775,6 +845,7 @@ describe( 'product edit utils', () => {
 					on_sale: true,
 					sale_price: '12',
 					date_on_sale_from: '2026-05-06T00:00:00',
+					cost_of_goods_sold: buildCostOfGoodsSold(),
 				} ),
 			] );

@@ -785,6 +856,7 @@ describe( 'product edit utils', () => {
 					'schedule_sale',
 					'date_on_sale_from',
 					'date_on_sale_to',
+					'cost_of_goods_sold',
 					...bulkSellableInstanceFieldIds,
 				] )
 			);
@@ -990,6 +1062,7 @@ describe( 'product edit utils', () => {
 				virtual: false,
 				downloadable: true,
 				manage_stock: true,
+				cost_of_goods_sold: buildCostOfGoodsSold(),
 			} );

 			expect( getFormFields( [ product ] ) ).toEqual( [
@@ -1009,6 +1082,7 @@ describe( 'product edit utils', () => {
 						'regular_price',
 						'sale_price',
 						'schedule_sale',
+						'cost_of_goods_sold',
 					],
 				},
 				{
@@ -1056,6 +1130,7 @@ describe( 'product edit utils', () => {
 				const product = buildProduct( {
 					type: productType,
 					date_on_sale_from: '2026-05-06T00:00:00',
+					cost_of_goods_sold: buildCostOfGoodsSold(),
 				} );

 				const priceGroup = getFormFields( [ product ] ).find(
@@ -1079,6 +1154,7 @@ describe( 'product edit utils', () => {
 								'date_on_sale_to',
 							],
 						},
+						'cost_of_goods_sold',
 					],
 				} );
 			}
diff --git a/packages/js/experimental-products-app/src/product-edit/utils.ts b/packages/js/experimental-products-app/src/product-edit/utils.ts
index 8f2d9f2522d..129cff13a88 100644
--- a/packages/js/experimental-products-app/src/product-edit/utils.ts
+++ b/packages/js/experimental-products-app/src/product-edit/utils.ts
@@ -3,6 +3,7 @@
  */
 import type { Field, FormField } from '@wordpress/dataviews';
 import { __ } from '@wordpress/i18n';
+import { getSetting } from '@woocommerce/settings';

 /**
  * Internal dependencies
@@ -31,6 +32,12 @@ type ProductType = 'simple' | 'variation' | 'variable' | 'grouped' | 'external';
 type ProductVariationEntityRecord = ProductEntityRecord & {
 	parent_id: number;
 };
+type Feature = {
+	is_enabled?: boolean;
+};
+type AdminSettings = {
+	features?: Record< string, Feature >;
+};

 const PRODUCT_EDIT_FIELD_IDS = [
 	'name',
@@ -47,6 +54,7 @@ const PRODUCT_EDIT_FIELD_IDS = [
 	'schedule_sale',
 	'date_on_sale_from',
 	'date_on_sale_to',
+	'cost_of_goods_sold',
 	'price_summary',
 	'stock',
 	'stock_quantity',
@@ -124,6 +132,7 @@ const SIMPLE_PRODUCT_EDIT_FORM_FIELDS = [
 			layout: { type: 'row' as const },
 			children: [ 'date_on_sale_from', 'date_on_sale_to' ],
 		},
+		'cost_of_goods_sold',
 	] ),
 	createProductEditFormGroup( 'image-fields', __( 'Images', 'woocommerce' ), [
 		'images',
@@ -161,6 +170,7 @@ const VARIATION_PRODUCT_EDIT_FORM_FIELDS = [
 			layout: { type: 'row' as const },
 			children: [ 'date_on_sale_from', 'date_on_sale_to' ],
 		},
+		'cost_of_goods_sold',
 	] ),
 	createProductEditFormGroup( 'image-fields', __( 'Images', 'woocommerce' ), [
 		'images',
@@ -293,11 +303,17 @@ const SELLABLE_PRODUCT_EDIT_FIELD_ID_SET = new Set< ProductEditFieldId >( [
 	'schedule_sale',
 	'date_on_sale_from',
 	'date_on_sale_to',
+	'cost_of_goods_sold',
 ] );

 const BULK_UNSUPPORTED_PRODUCT_EDIT_FIELD_ID_SET =
 	new Set< ProductEditFieldId >( [ 'sku' ] );

+function isCostOfGoodsSoldFeatureEnabled() {
+	const adminSettings = getSetting< AdminSettings >( 'admin', {} );
+	return Boolean( adminSettings.features?.cost_of_goods_sold?.is_enabled );
+}
+
 function normalizeValue( value: unknown ) {
 	if ( value === undefined ) {
 		return '__undefined__';
@@ -560,6 +576,13 @@ export function getVisibleProductEditFields(
 				return visibleFields;
 			}

+			if (
+				field.id === 'cost_of_goods_sold' &&
+				! isCostOfGoodsSoldFeatureEnabled()
+			) {
+				return visibleFields;
+			}
+
 			if (
 				isBulkEdit &&
 				BULK_UNSUPPORTED_PRODUCT_EDIT_FIELD_ID_SET.has(