Commit e38e868ae1e for woocommerce

commit e38e868ae1e1554633bceaa1e4b232ff9a6615b2
Author: Francesco <frosso@users.noreply.github.com>
Date:   Fri Sep 4 17:33:32 2026 +0200

    fix: return the order endpoint's item_data as a list (#68161)

diff --git a/docs/apis/store-api/resources-endpoints/order.md b/docs/apis/store-api/resources-endpoints/order.md
index 6cd77ae20f8..797c4f73b75 100644
--- a/docs/apis/store-api/resources-endpoints/order.md
+++ b/docs/apis/store-api/resources-endpoints/order.md
@@ -20,6 +20,8 @@ Returns the full order object response (see [Order Response](#order-response)).

 Order endpoints return responses in the same format as `/cart`; an order object which includes order items, applied coupons, shipping addresses and rates, and non-sensitive customer data.

+One difference worth calling out is `item_data`. Order items carry stored order item metadata; cart items carry display data from the `woocommerce_get_item_data` filter. Both are lists, but the entries hold different properties. See [Item data](#item-data).
+
 ### Order Response

 ```json
@@ -118,7 +120,15 @@ Order endpoints return responses in the same format as `/cart`; an order object
 					"sale_price": "10000000"
 				}
 			},
-			"item_data": [],
+			"item_data": [
+				{
+					"id": 1234,
+					"key": "Gift message",
+					"value": "Happy birthday",
+					"display_key": "Gift message",
+					"display_value": "<p>Happy birthday</p>\n"
+				}
+			],
 			"totals": {
 				"currency_code": "GBP",
 				"currency_symbol": "£",
@@ -223,6 +233,22 @@ Order endpoints return responses in the same format as `/cart`; an order object
 }
 ```

+### Item data
+
+Each order item carries its metadata in `item_data`, a list of entries:
+
+| Property | Type | Description |
+| --- | --- | --- |
+| `id` | integer \| null | The order item metadata row ID. `null` when an extension added the entry through `woocommerce_order_item_get_formatted_meta_data` without a stored row behind it. |
+| `key` | string | Metadata key. |
+| `value` | string | Metadata value. |
+| `display_key` | string | Key, formatted for display. |
+| `display_value` | string | Value, formatted for display. |
+
+Most stores return an empty list. Entries appear when an extension writes metadata to the line item, as Product Add-Ons, Deposits, Bundles and Gift Cards do.
+
+Extensions can add properties beyond the five above through the same filter. The endpoint passes them through.
+
 ### Error Response

 If an order action cannot be performed, an error response will be returned. This will include a reason code and an error message:
diff --git a/plugins/woocommerce/changelog/fix-store-api-order-item-data-schema b/plugins/woocommerce/changelog/fix-store-api-order-item-data-schema
new file mode 100644
index 00000000000..26b26e5d647
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-store-api-order-item-data-schema
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Store API: the order endpoint now returns item_data as a list, matching the cart endpoint and its own documented schema. Each entry carries its metadata row ID in a new `id` property, null when an extension added the entry without a stored row.
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/OrderItemSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/OrderItemSchema.php
index 56becf4ae58..270dbe13a55 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/OrderItemSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/OrderItemSchema.php
@@ -30,6 +30,108 @@ class OrderItemSchema extends ItemSchema {
 	 */
 	const IDENTIFIER = 'order-item';

+	/**
+	 * Item schema properties.
+	 *
+	 * The inherited `item_data` describes the cart's display data. Order items carry stored meta
+	 * rows, which have different properties.
+	 *
+	 * @return array
+	 *
+	 * @since 11.2.0
+	 */
+	public function get_properties() {
+		$properties = parent::get_properties();
+
+		$properties['item_data'] = [
+			'description' => __( 'Metadata related to the item.', 'woocommerce' ),
+			'type'        => 'array',
+			'context'     => [ 'view', 'edit' ],
+			'readonly'    => true,
+			'items'       => [
+				'type'       => 'object',
+				'properties' => [
+					'id'            => [
+						'description' => __( 'Order item metadata ID. Null for entries an extension added that have no stored metadata row.', 'woocommerce' ),
+						'type'        => [ 'integer', 'null' ],
+						'context'     => [ 'view', 'edit' ],
+						'readonly'    => true,
+					],
+					'key'           => [
+						'description' => __( 'Metadata key.', 'woocommerce' ),
+						'type'        => 'string',
+						'context'     => [ 'view', 'edit' ],
+						'readonly'    => true,
+					],
+					'value'         => [
+						'description' => __( 'Metadata value.', 'woocommerce' ),
+						'type'        => 'string',
+						'context'     => [ 'view', 'edit' ],
+						'readonly'    => true,
+					],
+					'display_key'   => [
+						'description' => __( 'Metadata key, formatted for display.', 'woocommerce' ),
+						'type'        => 'string',
+						'context'     => [ 'view', 'edit' ],
+						'readonly'    => true,
+					],
+					'display_value' => [
+						'description' => __( 'Metadata value, formatted for display.', 'woocommerce' ),
+						'type'        => 'string',
+						'context'     => [ 'view', 'edit' ],
+						'readonly'    => true,
+					],
+				],
+			],
+		];
+
+		return $properties;
+	}
+
+	/**
+	 * Get order item metadata as a list.
+	 *
+	 * Keyed by meta row ID at source; the Store API sends a list, so the ID moves into `id`.
+	 *
+	 * @param \WC_Order_Item $order_item Order item instance.
+	 * @return array
+	 */
+	private function get_item_data( $order_item ) {
+		$formatted_meta_data = $order_item->get_all_formatted_meta_data();
+		$item_data           = [];
+
+		// A `woocommerce_order_item_get_formatted_meta_data` callback can return anything, and a bad
+		// one must not take the endpoint down.
+		if ( ! is_array( $formatted_meta_data ) ) {
+			return $item_data;
+		}
+
+		// A callback appending with `$formatted_meta[] =` gets PHP's next integer key, not a row ID.
+		// Same source the formatted metadata is built from, so matching costs no query.
+		$meta_row_ids = array_flip( array_filter( wp_list_pluck( $order_item->get_meta_data(), 'id' ) ) );
+
+		foreach ( $formatted_meta_data as $meta_id => $meta ) {
+			// Only public fields are meant to ship. Casting an object reaches past them and
+			// publishes mangled names, so let it serialize itself first, then read what is public.
+			if ( $meta instanceof \JsonSerializable ) {
+				$meta = $meta->jsonSerialize();
+			}
+
+			if ( is_object( $meta ) ) {
+				$meta = get_object_vars( $meta );
+			}
+
+			if ( ! is_array( $meta ) ) {
+				continue;
+			}
+
+			// Union keeps the left operand, so a callback's own `id` cannot shadow the row ID.
+			$item_data[] = [ 'id' => isset( $meta_row_ids[ $meta_id ] ) ? $meta_id : null ] + $meta;
+		}
+
+		return $item_data;
+	}
+
 	/**
 	 * Get order items data.
 	 *
@@ -110,7 +212,7 @@ class OrderItemSchema extends ItemSchema {
 			'permalink'            => $product_properties['permalink'],
 			'images'               => $product_properties['images'],
 			'variation'            => $product_properties['variation'],
-			'item_data'            => $order_item->get_all_formatted_meta_data(),
+			'item_data'            => $this->get_item_data( $order_item ),
 			'prices'               => (object) $product_properties['prices'],
 			'totals'               => (object) $this->prepare_currency_response( $this->get_totals( $order_item ) ),
 			'catalog_visibility'   => $product_properties['catalog_visibility'],
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Order.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Order.php
index 2ea7d7db47f..803faeb7153 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Order.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Order.php
@@ -7,6 +7,8 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Routes;

+use Automattic\WooCommerce\Tests\Blocks\Helpers\ValidateSchema;
+
 /**
  * Order Route Tests.
  *
@@ -134,6 +136,359 @@ class Order extends ControllerTestCase {
 		return $order;
 	}

+	/**
+	 * The Order route has no other schema validation coverage.
+	 *
+	 * The item needs metadata, or the nested item_data schema is never exercised.
+	 *
+	 * @testdox Order response matches the published schema.
+	 */
+	public function test_response_matches_schema(): void {
+		$order = $this->create_guest_order();
+		$item  = current( $order->get_items() );
+		$item->add_meta_data( 'Gift message', 'Happy birthday', true );
+		$item->save();
+
+		wp_set_current_user( 0 );
+
+		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/order/' . $order->get_id() );
+		$request->set_param( 'key', $order->get_order_key() );
+		$request->set_param( 'billing_email', $order->get_billing_email() );
+
+		$response = rest_get_server()->dispatch( $request );
+		$this->assertEquals( 200, $response->get_status() );
+
+		$routes     = new \Automattic\WooCommerce\StoreApi\RoutesController( new \Automattic\WooCommerce\StoreApi\SchemaController( $this->mock_extend ) );
+		$controller = $routes->get( 'order', 'v1' );
+		$validate   = new ValidateSchema( $controller->get_item_schema() );
+
+		$data = $response->get_data();
+		$diff = $validate->get_diff_from_object( $data );
+
+		// Other mismatches on this response are out of scope (items' `type` and `extensions`,
+		// `quantity_limits`, `fees`). Filter to item_data so this does not snapshot them.
+		$item_data_diff = array_values(
+			array_filter(
+				array_merge( $diff['missing'] ?? array(), $diff['invalid_type'] ?? array(), $diff['no_schema'] ?? array() ),
+				function ( $entry ) {
+					return false !== strpos( $entry, 'item_data' );
+				}
+			)
+		);
+		// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
+		$this->assertEmpty( $item_data_diff, print_r( $item_data_diff, true ) );
+
+		$item_data = $data['items'][0]['item_data'];
+		$this->assertNotEmpty( $item_data );
+
+		// ValidateSchema only recurses into the first entry, so pin the exact public shape here.
+		$entry = $item_data[0];
+		$this->assertEqualSets( array( 'id', 'key', 'value', 'display_key', 'display_value' ), array_keys( $entry ) );
+		$this->assertSame( 'Gift message', $entry['key'] );
+		$this->assertSame( 'Happy birthday', $entry['value'] );
+	}
+
+	/**
+	 * Consumers read this as a list, the way the cart endpoint sends it.
+	 *
+	 * @testdox Order item_data serializes as a JSON list carrying the meta row ID.
+	 */
+	public function test_item_data_serializes_as_a_json_list_carrying_the_meta_id(): void {
+		$order = $this->create_guest_order();
+		$item  = current( $order->get_items() );
+		$item->add_meta_data( 'Gift message', 'Happy birthday', true );
+		$item->save();
+
+		wp_set_current_user( 0 );
+
+		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/order/' . $order->get_id() );
+		$request->set_param( 'key', $order->get_order_key() );
+		$request->set_param( 'billing_email', $order->get_billing_email() );
+
+		$item_data = rest_get_server()->dispatch( $request )->get_data()['items'][0]['item_data'];
+		$meta_id   = current( $item->get_meta_data() )->id;
+
+		$this->assertSame( array( 0 ), array_keys( $item_data ), 'item_data must be a list, not keyed by meta ID.' );
+		$this->assertStringStartsWith( '[', wp_json_encode( $item_data ), 'item_data must serialize as a JSON list, not an object.' );
+		$this->assertSame( $meta_id, $item_data[0]['id'], 'The meta row ID must survive as `id`.' );
+	}
+
+	/**
+	 * Callbacks can add their own fields; reshaping the container must not drop them.
+	 *
+	 * @testdox Order item_data keeps fields added by extensions.
+	 */
+	public function test_item_data_keeps_fields_added_by_extensions(): void {
+		$order = $this->create_guest_order();
+		$item  = current( $order->get_items() );
+		$item->add_meta_data( 'Gift message', 'Happy birthday', true );
+		$item->save();
+
+		add_filter(
+			'woocommerce_order_item_get_formatted_meta_data',
+			function ( $formatted_meta ) {
+				foreach ( $formatted_meta as $meta ) {
+					$meta->custom_field = 'from-extension';
+				}
+				return $formatted_meta;
+			}
+		);
+
+		wp_set_current_user( 0 );
+
+		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/order/' . $order->get_id() );
+		$request->set_param( 'key', $order->get_order_key() );
+		$request->set_param( 'billing_email', $order->get_billing_email() );
+
+		$entry = rest_get_server()->dispatch( $request )->get_data()['items'][0]['item_data'][0];
+
+		$this->assertSame( 'from-extension', $entry['custom_field'] ?? null, 'Extension-added fields must survive.' );
+		$this->assertSame( current( $item->get_meta_data() )->id, $entry['id'], 'The row ID must win over anything a callback sets.' );
+	}
+
+	/**
+	 * A misbehaving callback should cost the endpoint its metadata, not its response.
+	 *
+	 * @testdox Order endpoint survives a callback that returns a non-array.
+	 */
+	public function test_item_data_survives_a_hostile_filter(): void {
+		$order = $this->create_guest_order();
+		$item  = current( $order->get_items() );
+		$item->add_meta_data( 'Gift message', 'Happy birthday', true );
+		$item->save();
+
+		add_filter(
+			'woocommerce_order_item_get_formatted_meta_data',
+			function () {
+				return 'not-an-array';
+			}
+		);
+
+		wp_set_current_user( 0 );
+
+		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/order/' . $order->get_id() );
+		$request->set_param( 'key', $order->get_order_key() );
+		$request->set_param( 'billing_email', $order->get_billing_email() );
+
+		$response = rest_get_server()->dispatch( $request );
+
+		$this->assertSame( 200, $response->get_status(), 'A bad callback must not fail the request.' );
+		$this->assertSame( [], $response->get_data()['items'][0]['item_data'] );
+	}
+
+	/**
+	 * @testdox Order item_data skips metadata entries that are not objects or arrays.
+	 */
+	public function test_item_data_skips_non_object_entries(): void {
+		$order = $this->create_guest_order();
+		$item  = current( $order->get_items() );
+		$item->add_meta_data( 'Gift message', 'Happy birthday', true );
+		$item->save();
+
+		add_filter(
+			'woocommerce_order_item_get_formatted_meta_data',
+			function ( $formatted_meta ) {
+				$formatted_meta[999] = 'scalar-entry';
+				return $formatted_meta;
+			}
+		);
+
+		wp_set_current_user( 0 );
+
+		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/order/' . $order->get_id() );
+		$request->set_param( 'key', $order->get_order_key() );
+		$request->set_param( 'billing_email', $order->get_billing_email() );
+
+		$item_data = rest_get_server()->dispatch( $request )->get_data()['items'][0]['item_data'];
+
+		$this->assertCount( 1, $item_data, 'The scalar entry must be skipped, the real one kept.' );
+		$this->assertSame( 'Gift message', $item_data[0]['key'] );
+	}
+
+	/**
+	 * Still metadata the store wants shown, so it is published with a null `id` rather than dropped.
+	 *
+	 * @testdox Order item_data reports a null id for an entry not keyed by a meta row ID.
+	 */
+	public function test_item_data_reports_null_id_for_entries_not_keyed_by_a_meta_row_id(): void {
+		$order = $this->create_guest_order();
+		$item  = current( $order->get_items() );
+		$item->add_meta_data( 'Gift message', 'Happy birthday', true );
+		$item->save();
+
+		add_filter(
+			'woocommerce_order_item_get_formatted_meta_data',
+			function ( $formatted_meta ) {
+				$formatted_meta['not-a-row-id'] = (object) array(
+					'key'           => 'Injected',
+					'value'         => 'value',
+					'display_key'   => 'Injected',
+					'display_value' => 'value',
+				);
+				return $formatted_meta;
+			}
+		);
+
+		wp_set_current_user( 0 );
+
+		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/order/' . $order->get_id() );
+		$request->set_param( 'key', $order->get_order_key() );
+		$request->set_param( 'billing_email', $order->get_billing_email() );
+
+		$item_data = rest_get_server()->dispatch( $request )->get_data()['items'][0]['item_data'];
+
+		$this->assertCount( 2, $item_data, 'The entry must be kept, not dropped.' );
+		$this->assertSame( current( $item->get_meta_data() )->id, $item_data[0]['id'] );
+		$this->assertSame( 'Injected', $item_data[1]['key'] );
+		$this->assertNull( $item_data[1]['id'], 'An entry with no stored row has no ID to report.' );
+	}
+
+	/**
+	 * `WC_Meta_Data` keeps its fields protected, so casting one publishes mangled property names.
+	 * Trunk serialized it through `JsonSerializable`, and so must this.
+	 *
+	 * @testdox Order item_data serializes an appended WC_Meta_Data through JsonSerializable.
+	 */
+	public function test_item_data_serializes_an_appended_wc_meta_data(): void {
+		$order = $this->create_guest_order();
+		$item  = current( $order->get_items() );
+		$item->add_meta_data( 'Gift message', 'Happy birthday', true );
+		$item->save();
+
+		add_filter(
+			'woocommerce_order_item_get_formatted_meta_data',
+			function ( $formatted_meta ) {
+				$formatted_meta[] = new \WC_Meta_Data(
+					array(
+						'id'    => 0,
+						'key'   => 'Appended',
+						'value' => 'value',
+					)
+				);
+				return $formatted_meta;
+			}
+		);
+
+		wp_set_current_user( 0 );
+
+		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/order/' . $order->get_id() );
+		$request->set_param( 'key', $order->get_order_key() );
+		$request->set_param( 'billing_email', $order->get_billing_email() );
+
+		$item_data = rest_get_server()->dispatch( $request )->get_data()['items'][0]['item_data'];
+
+		$this->assertSame( 'Appended', $item_data[1]['key'] );
+		$this->assertSame( 'value', $item_data[1]['value'] );
+		$this->assertNull( $item_data[1]['id'], 'The wrapped id is 0, not a row on this item.' );
+		$this->assertStringNotContainsString( "\0", wp_json_encode( $item_data ), 'Protected property names must not reach the response.' );
+	}
+
+	/**
+	 * An extension's own object need not offer `JsonSerializable`, and casting one publishes its
+	 * non-public fields under mangled names.
+	 *
+	 * @testdox Order item_data publishes only the public fields of an appended plain object.
+	 */
+	public function test_item_data_publishes_only_public_fields_of_an_appended_object(): void {
+		$order = $this->create_guest_order();
+		$item  = current( $order->get_items() );
+		$item->add_meta_data( 'Gift message', 'Happy birthday', true );
+		$item->save();
+
+		add_filter(
+			'woocommerce_order_item_get_formatted_meta_data',
+			function ( $formatted_meta ) {
+				$formatted_meta[] = new class() {
+					/**
+					 * Metadata key.
+					 *
+					 * @var string
+					 */
+					public $key = 'Appended';
+
+					/**
+					 * Metadata value.
+					 *
+					 * @var string
+					 */
+					public $value = 'value';
+
+					/**
+					 * Metadata key, formatted for display.
+					 *
+					 * @var string
+					 */
+					public $display_key = 'Appended';
+
+					/**
+					 * Metadata value, formatted for display.
+					 *
+					 * @var string
+					 */
+					public $display_value = 'value';
+
+					/**
+					 * A field the extension keeps to itself.
+					 *
+					 * @var string
+					 */
+					protected $internal = 'internal';
+				};
+				return $formatted_meta;
+			}
+		);
+
+		wp_set_current_user( 0 );
+
+		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/order/' . $order->get_id() );
+		$request->set_param( 'key', $order->get_order_key() );
+		$request->set_param( 'billing_email', $order->get_billing_email() );
+
+		$item_data = rest_get_server()->dispatch( $request )->get_data()['items'][0]['item_data'];
+
+		$this->assertSame( 'Appended', $item_data[1]['key'] );
+		$this->assertSame( array( 'id', 'key', 'value', 'display_key', 'display_value' ), array_keys( $item_data[1] ), 'Only public fields belong in the response.' );
+	}
+
+	/**
+	 * Appending gets PHP's next integer key. Publishing that as `id` would point consumers at
+	 * another item's row.
+	 *
+	 * @testdox Order item_data reports a null id for an appended entry.
+	 */
+	public function test_item_data_reports_null_id_for_an_appended_entry(): void {
+		$order = $this->create_guest_order();
+		$item  = current( $order->get_items() );
+		$item->add_meta_data( 'Gift message', 'Happy birthday', true );
+		$item->save();
+
+		add_filter(
+			'woocommerce_order_item_get_formatted_meta_data',
+			function ( $formatted_meta ) {
+				$formatted_meta[] = (object) array(
+					'key'           => 'Appended',
+					'value'         => 'value',
+					'display_key'   => 'Appended',
+					'display_value' => 'value',
+				);
+				return $formatted_meta;
+			}
+		);
+
+		wp_set_current_user( 0 );
+
+		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/order/' . $order->get_id() );
+		$request->set_param( 'key', $order->get_order_key() );
+		$request->set_param( 'billing_email', $order->get_billing_email() );
+
+		$item_data = rest_get_server()->dispatch( $request )->get_data()['items'][0]['item_data'];
+
+		$this->assertCount( 2, $item_data, 'The appended entry must be kept, not dropped.' );
+		$this->assertSame( 'Appended', $item_data[1]['key'] );
+		$this->assertNull( $item_data[1]['id'], 'An appended entry has no stored row, so no ID.' );
+	}
+
+
 	/**
 	 * Test that a guest can access a guest order with valid order key and billing email.
 	 */