Commit 1b2e2b03ef7 for woocommerce

commit 1b2e2b03ef74fcb33ca5a734bf5b47a06edff16a
Author: Christopher Hertel <mail@christopher-hertel.de>
Date:   Thu Nov 12 10:57:53 2026 +0100

    Return the full order record from the orders-query ability (#70791)

    * Return the full order record from the orders-query ability

    * Add changelog entry for the full order record in orders-query

    * Describe paid and completed dates as kept after later status changes

    * Prime product caches before formatting order line items

    * Shorten the repeated date and order line descriptions

diff --git a/plugins/woocommerce/changelog/wooai-1457-full-order b/plugins/woocommerce/changelog/wooai-1457-full-order
new file mode 100644
index 00000000000..5f623eb8ed6
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooai-1457-full-order
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Let AI agents read order numbers, tax and discount totals, and shipping, fee, coupon and tax lines with the woocommerce/orders-query ability.
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdate.php
index b0e6b4eec1a..555713e8e0c 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdate.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdate.php
@@ -261,7 +261,7 @@ class OrderUpdate extends ActionableAbility {
 	 * @return array|\WP_Error
 	 */
 	public function prepare_response( $subject ) {
-		$order = self::format_order_for_response( $subject, true );
+		$order = self::format_order_for_response( $subject, array( 'line_items', 'lines' ) );
 		if ( is_wp_error( $order ) ) {
 			return $order;
 		}
@@ -596,7 +596,7 @@ class OrderUpdate extends ActionableAbility {
 		return new \WP_Error(
 			'woocommerce_ability_order_line_not_found',
 			/* translators: %d: order item ID. */
-			sprintf( __( 'Item %d is not a line of this order that this ability can change. Use the woocommerce/orders-query ability with include_line_items to read the IDs of the product and shipping lines.', 'woocommerce' ), $item_id ),
+			sprintf( __( 'Item %d is not a line of this order that this ability can change. Use the woocommerce/orders-query ability with include_line_items and include_lines to read the IDs of the product and shipping lines.', 'woocommerce' ), $item_id ),
 			array( 'status' => 400 )
 		);
 	}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
index 3b8a5aa62ff..a88e92c64f6 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
@@ -76,7 +76,14 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
 	 * @since 10.9.0
 	 */
 	public static function execute( array $input ) {
-		$include_line_items = $input['include_line_items'] ?? false;
+		$includes = array_keys(
+			array_filter(
+				array(
+					'line_items' => $input['include_line_items'] ?? false,
+					'lines'      => $input['include_lines'] ?? false,
+				)
+			)
+		);

 		if ( ! empty( $input['id'] ) && ! empty( $input['filters'] ) ) {
 			return new \WP_Error(
@@ -120,7 +127,7 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
 				return $order;
 			}

-			$orders = self::format_orders_for_response( array( $order ), $include_line_items );
+			$orders = self::format_orders_for_response( array( $order ), $includes );
 			if ( is_wp_error( $orders ) ) {
 				return $orders;
 			}
@@ -228,7 +235,7 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
 		);
 		$pages   = is_object( $results ) && isset( $results->max_num_pages ) ? (int) $results->max_num_pages : ( count( $orders ) > 0 ? 1 : 0 );

-		$orders = self::format_orders_for_response( $orders, $include_line_items );
+		$orders = self::format_orders_for_response( $orders, $includes );
 		if ( is_wp_error( $orders ) ) {
 			return $orders;
 		}
@@ -365,11 +372,16 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
 				'include_line_items' => array(
 					'type'        => 'boolean',
 					'description' => __(
-						'Whether to include order line items and shipping lines in each returned order. Defaults to false.',
+						'Whether to include order line items in each returned order. Defaults to false.',
 						'woocommerce'
 					),
 					'default'     => false,
 				),
+				'include_lines'      => array(
+					'type'        => 'boolean',
+					'description' => __( 'Whether to include the shipping, fee, coupon and tax lines in each returned order. Defaults to false.', 'woocommerce' ),
+					'default'     => false,
+				),
 				'page'               => array(
 					'type'    => 'integer',
 					'default' => 1,
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
index adfaf20604b..7025fed7a3d 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
@@ -130,6 +130,18 @@ trait OrderAbilityTrait {
 			'type'                 => 'object',
 			'properties'           => array(
 				'id'                   => array( 'type' => 'integer' ),
+				'number'               => array(
+					'type'        => 'string',
+					'description' => __( 'Order number shown to the customer. Extensions can make it differ from id.', 'woocommerce' ),
+				),
+				'type'                 => array(
+					'type' => 'string',
+					'enum' => self::get_order_type_slugs(),
+				),
+				'parent_id'            => array(
+					'type'        => 'integer',
+					'description' => __( 'ID of the parent order, or 0 when the order has none.', 'woocommerce' ),
+				),
 				'status'               => array(
 					'type' => 'string',
 					'enum' => self::get_order_output_status_slugs(),
@@ -140,6 +152,9 @@ trait OrderAbilityTrait {
 				),
 				'currency_symbol'      => array( 'type' => 'string' ),
 				'total'                => array( 'type' => 'string' ),
+				'discount_total'       => array( 'type' => 'string' ),
+				'shipping_total'       => array( 'type' => 'string' ),
+				'total_tax'            => array( 'type' => 'string' ),
 				'customer_id'          => array( 'type' => 'integer' ),
 				'billing_email'        => array(
 					'type'        => 'string',
@@ -168,6 +183,26 @@ trait OrderAbilityTrait {
 					'format'      => 'date-time',
 					'description' => __( 'Order modification date in UTC. Omitted when no modification date is available.', 'woocommerce' ),
 				),
+				'date_paid'            => array(
+					'type'        => 'string',
+					'format'      => 'date-time',
+					'description' => __( 'Payment date in the store timezone. Kept after a later status change, such as a refund. Omitted when never paid.', 'woocommerce' ),
+				),
+				'date_paid_gmt'        => array(
+					'type'        => 'string',
+					'format'      => 'date-time',
+					'description' => __( 'date_paid in UTC.', 'woocommerce' ),
+				),
+				'date_completed'       => array(
+					'type'        => 'string',
+					'format'      => 'date-time',
+					'description' => __( 'Completion date in the store timezone. Kept after a later status change, such as a refund. Omitted when never completed.', 'woocommerce' ),
+				),
+				'date_completed_gmt'   => array(
+					'type'        => 'string',
+					'format'      => 'date-time',
+					'description' => __( 'date_completed in UTC.', 'woocommerce' ),
+				),
 				'line_items'           => array(
 					'type'        => 'array',
 					'description' => __( 'Order line items. Only present when include_line_items is true.', 'woocommerce' ),
@@ -179,9 +214,20 @@ trait OrderAbilityTrait {
 								'name'         => array( 'type' => 'string' ),
 								'product_id'   => array( 'type' => 'integer' ),
 								'variation_id' => array( 'type' => 'integer' ),
+								'sku'          => array(
+									'type'        => 'string',
+									'description' => __( 'Omitted when the product no longer exists.', 'woocommerce' ),
+								),
 								'quantity'     => array( 'type' => 'number' ),
+								'price'        => array(
+									'type'        => 'string',
+									'description' => __( 'Line total divided by quantity, after discounts and before tax.', 'woocommerce' ),
+								),
+								'tax_class'    => array( 'type' => 'string' ),
 								'subtotal'     => array( 'type' => 'string' ),
+								'subtotal_tax' => array( 'type' => 'string' ),
 								'total'        => array( 'type' => 'string' ),
+								'total_tax'    => array( 'type' => 'string' ),
 							),
 							'additionalProperties' => false,
 						),
@@ -194,19 +240,47 @@ trait OrderAbilityTrait {
 					'properties'           => array_fill_keys( self::shipping_address_fields(), array( 'type' => 'string' ) ),
 					'additionalProperties' => false,
 				),
-				'shipping_lines'       => array(
-					'type'        => 'array',
-					'description' => __( 'Order shipping lines. Present together with line_items.', 'woocommerce' ),
-					'items'       => array(
-						'type'                 => 'object',
-						'properties'           => array(
-							'id'           => array( 'type' => 'integer' ),
-							'method_id'    => array( 'type' => 'string' ),
-							'method_title' => array( 'type' => 'string' ),
-							'total'        => array( 'type' => 'string' ),
+				'shipping_lines'       => self::get_order_lines_schema(
+					array(
+						'id'           => array( 'type' => 'integer' ),
+						'method_title' => array( 'type' => 'string' ),
+						'method_id'    => array( 'type' => 'string' ),
+						'instance_id'  => array( 'type' => 'string' ),
+						'total'        => array( 'type' => 'string' ),
+						'total_tax'    => array( 'type' => 'string' ),
+					)
+				),
+				'fee_lines'            => self::get_order_lines_schema(
+					array(
+						'id'         => array( 'type' => 'integer' ),
+						'name'       => array( 'type' => 'string' ),
+						'tax_class'  => array( 'type' => 'string' ),
+						'tax_status' => array(
+							'type' => 'string',
+							'enum' => array( 'taxable', 'none' ),
 						),
-						'additionalProperties' => false,
-					),
+						'total'      => array( 'type' => 'string' ),
+						'total_tax'  => array( 'type' => 'string' ),
+					)
+				),
+				'coupon_lines'         => self::get_order_lines_schema(
+					array(
+						'id'           => array( 'type' => 'integer' ),
+						'code'         => array( 'type' => 'string' ),
+						'discount'     => array( 'type' => 'string' ),
+						'discount_tax' => array( 'type' => 'string' ),
+					)
+				),
+				'tax_lines'            => self::get_order_lines_schema(
+					array(
+						'id'                 => array( 'type' => 'integer' ),
+						'rate_code'          => array( 'type' => 'string' ),
+						'rate_id'            => array( 'type' => 'integer' ),
+						'label'              => array( 'type' => 'string' ),
+						'compound'           => array( 'type' => 'boolean' ),
+						'tax_total'          => array( 'type' => 'string' ),
+						'shipping_tax_total' => array( 'type' => 'string' ),
+					)
 				),
 			),
 			'additionalProperties' => false,
@@ -214,6 +288,23 @@ trait OrderAbilityTrait {
 		return ExtensibleAbilities::add_fields_schema( $schema, 'order' );
 	}

+	/**
+	 * Schema of one kind of order line other than line items. The include_lines input describes when they are present.
+	 *
+	 * @param array $properties Properties of one line.
+	 * @return array
+	 */
+	private static function get_order_lines_schema( array $properties ): array {
+		return array(
+			'type'  => 'array',
+			'items' => array(
+				'type'                 => 'object',
+				'properties'           => $properties,
+				'additionalProperties' => false,
+			),
+		);
+	}
+
 	/**
 	 * Get an order from ability input.
 	 *
@@ -295,20 +386,37 @@ trait OrderAbilityTrait {
 	protected static function format_saved_order_for_response( \WC_Order $order ) {
 		$stored = wc_get_order( $order->get_id() );

-		return self::format_order_for_response( $stored instanceof \WC_Order ? $stored : $order, false );
+		return self::format_order_for_response( $stored instanceof \WC_Order ? $stored : $order, array() );
 	}

 	/**
 	 * Format orders for ability output. One order that fails returns its error.
 	 *
-	 * @param \WC_Order[] $orders             Order objects.
-	 * @param bool        $include_line_items Whether to include line items.
+	 * @param \WC_Order[] $orders   Order objects.
+	 * @param string[]    $includes Optional parts, as for format_order_for_response().
 	 * @return array|\WP_Error
 	 */
-	protected static function format_orders_for_response( array $orders, bool $include_line_items ) {
+	protected static function format_orders_for_response( array $orders, array $includes ) {
+		if ( in_array( 'line_items', $includes, true ) ) {
+			$product_ids = array();
+			foreach ( $orders as $order ) {
+				foreach ( $order->get_items() as $item ) {
+					if ( $item instanceof \WC_Order_Item_Product ) {
+						$product_ids[] = $item->get_product_id();
+						$product_ids[] = $item->get_variation_id();
+					}
+				}
+			}
+			$product_ids = array_unique( array_filter( $product_ids ) );
+			if ( ! empty( $product_ids ) ) {
+				// Prime caches to reduce future queries.
+				_prime_post_caches( $product_ids );
+			}
+		}
+
 		$formatted = array();
 		foreach ( $orders as $order ) {
-			$data = self::format_order_for_response( $order, $include_line_items );
+			$data = self::format_order_for_response( $order, $includes );
 			if ( is_wp_error( $data ) ) {
 				return $data;
 			}
@@ -330,15 +438,18 @@ trait OrderAbilityTrait {
 	/**
 	 * Format an order for ability output.
 	 *
-	 * @param \WC_Order $order              Order object.
-	 * @param bool      $include_line_items Whether to include line items and shipping lines.
+	 * @param \WC_Order $order    Order object.
+	 * @param string[]  $includes Optional parts: `line_items`, and `lines` for the shipping, fee, coupon and tax lines.
 	 * @return array|\WP_Error A WP_Error when an extension field value does not match its schema.
 	 */
-	protected static function format_order_for_response( \WC_Order $order, bool $include_line_items ) {
+	protected static function format_order_for_response( \WC_Order $order, array $includes ) {
 		$billing_email = $order->get_billing_email();

 		$data = array(
 			'id'                   => $order->get_id(),
+			'number'               => (string) $order->get_order_number(),
+			'type'                 => $order->get_type(),
+			'parent_id'            => $order->get_parent_id(),
 			'status'               => $order->get_status(),
 			'currency'             => $order->get_currency(),
 			'currency_symbol'      => html_entity_decode(
@@ -346,13 +457,16 @@ trait OrderAbilityTrait {
 				ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
 			),
 			'total'                => self::format_amount_for_response( $order->get_total() ),
+			'discount_total'       => self::format_amount_for_response( $order->get_discount_total() ),
+			'shipping_total'       => self::format_amount_for_response( $order->get_shipping_total() ),
+			'total_tax'            => self::format_amount_for_response( $order->get_total_tax() ),
 			'customer_id'          => $order->get_customer_id(),
 			'payment_method'       => $order->get_payment_method(),
 			'payment_method_title' => $order->get_payment_method_title(),
 			'shipping'             => array_intersect_key( $order->get_address( 'shipping' ), array_flip( self::shipping_address_fields() ) ),
 		);

-		if ( $include_line_items ) {
+		if ( in_array( 'line_items', $includes, true ) ) {
 			$data['line_items'] = array();

 			foreach ( $order->get_items() as $item ) {
@@ -361,15 +475,7 @@ trait OrderAbilityTrait {
 				}

 				$line_item = ExtensibleAbilities::add_fields_to_object(
-					array(
-						'id'           => $item->get_id(),
-						'name'         => $item->get_name(),
-						'product_id'   => $item->get_product_id(),
-						'variation_id' => $item->get_variation_id(),
-						'quantity'     => $item->get_quantity(),
-						'subtotal'     => self::format_amount_for_response( $item->get_subtotal() ),
-						'total'        => self::format_amount_for_response( $item->get_total() ),
-					),
+					self::format_line_item_for_response( $item ),
 					'order_item',
 					$item
 				);
@@ -378,20 +484,10 @@ trait OrderAbilityTrait {
 				}
 				$data['line_items'][] = $line_item;
 			}
+		}

-			$data['shipping_lines'] = array_values(
-				array_map(
-					static function ( \WC_Order_Item_Shipping $item ): array {
-						return array(
-							'id'           => $item->get_id(),
-							'method_id'    => $item->get_method_id(),
-							'method_title' => $item->get_method_title(),
-							'total'        => self::format_amount_for_response( $item->get_total() ),
-						);
-					},
-					$order->get_shipping_methods()
-				)
-			);
+		if ( in_array( 'lines', $includes, true ) ) {
+			$data += self::format_order_lines_for_response( $order );
 		}

 		if ( '' !== $billing_email ) {
@@ -400,13 +496,118 @@ trait OrderAbilityTrait {

 		$data += array_filter(
 			array(
-				'date_created'      => self::format_date_for_response( $order->get_date_created(), false ),
-				'date_created_gmt'  => self::format_date_for_response( $order->get_date_created() ),
-				'date_modified'     => self::format_date_for_response( $order->get_date_modified(), false ),
-				'date_modified_gmt' => self::format_date_for_response( $order->get_date_modified() ),
+				'date_created'       => self::format_date_for_response( $order->get_date_created(), false ),
+				'date_created_gmt'   => self::format_date_for_response( $order->get_date_created() ),
+				'date_modified'      => self::format_date_for_response( $order->get_date_modified(), false ),
+				'date_modified_gmt'  => self::format_date_for_response( $order->get_date_modified() ),
+				'date_paid'          => self::format_date_for_response( $order->get_date_paid(), false ),
+				'date_paid_gmt'      => self::format_date_for_response( $order->get_date_paid() ),
+				'date_completed'     => self::format_date_for_response( $order->get_date_completed(), false ),
+				'date_completed_gmt' => self::format_date_for_response( $order->get_date_completed() ),
 			)
 		);

 		return ExtensibleAbilities::add_fields_to_object( $data, 'order', $order );
 	}
+
+	/**
+	 * Format a product line of an order, before extension fields are added.
+	 *
+	 * @param \WC_Order_Item_Product $item Product line.
+	 * @return array
+	 */
+	private static function format_line_item_for_response( \WC_Order_Item_Product $item ): array {
+		$quantity = $item->get_quantity();
+		$product  = $item->get_product();
+
+		$line_item = array(
+			'id'           => $item->get_id(),
+			'name'         => $item->get_name(),
+			'product_id'   => $item->get_product_id(),
+			'variation_id' => $item->get_variation_id(),
+			'quantity'     => $quantity,
+			'price'        => self::format_amount_for_response( $quantity ? (float) $item->get_total() / $quantity : 0 ),
+			'tax_class'    => $item->get_tax_class(),
+			'subtotal'     => self::format_amount_for_response( $item->get_subtotal() ),
+			'subtotal_tax' => self::format_amount_for_response( $item->get_subtotal_tax() ),
+			'total'        => self::format_amount_for_response( $item->get_total() ),
+			'total_tax'    => self::format_amount_for_response( $item->get_total_tax() ),
+		);
+
+		if ( $product instanceof \WC_Product ) {
+			$line_item['sku'] = $product->get_sku();
+		}
+
+		return $line_item;
+	}
+
+	/**
+	 * Format the shipping, fee, coupon and tax lines of an order, with the keys of wc/v3/orders.
+	 *
+	 * @param \WC_Order $order Order object.
+	 * @return array
+	 */
+	private static function format_order_lines_for_response( \WC_Order $order ): array {
+		return array(
+			'shipping_lines' => array_values(
+				array_map(
+					static function ( \WC_Order_Item_Shipping $item ): array {
+						return array(
+							'id'           => $item->get_id(),
+							'method_title' => $item->get_method_title(),
+							'method_id'    => $item->get_method_id(),
+							'instance_id'  => (string) $item->get_instance_id(),
+							'total'        => self::format_amount_for_response( $item->get_total() ),
+							'total_tax'    => self::format_amount_for_response( $item->get_total_tax() ),
+						);
+					},
+					$order->get_shipping_methods()
+				)
+			),
+			'fee_lines'      => array_values(
+				array_map(
+					static function ( \WC_Order_Item_Fee $item ): array {
+						return array(
+							'id'         => $item->get_id(),
+							'name'       => $item->get_name(),
+							'tax_class'  => $item->get_tax_class(),
+							'tax_status' => $item->get_tax_status(),
+							'total'      => self::format_amount_for_response( $item->get_total() ),
+							'total_tax'  => self::format_amount_for_response( $item->get_total_tax() ),
+						);
+					},
+					$order->get_fees()
+				)
+			),
+			'coupon_lines'   => array_values(
+				array_map(
+					static function ( \WC_Order_Item_Coupon $item ): array {
+						return array(
+							'id'           => $item->get_id(),
+							'code'         => $item->get_code(),
+							'discount'     => self::format_amount_for_response( $item->get_discount() ),
+							'discount_tax' => self::format_amount_for_response( $item->get_discount_tax() ),
+						);
+					},
+					$order->get_coupons()
+				)
+			),
+			'tax_lines'      => array_values(
+				array_map(
+					static function ( \WC_Order_Item_Tax $item ): array {
+						return array(
+							'id'                 => $item->get_id(),
+							'rate_code'          => $item->get_rate_code(),
+							'rate_id'            => $item->get_rate_id(),
+							'label'              => $item->get_label(),
+							'compound'           => $item->is_compound(),
+							'tax_total'          => self::format_amount_for_response( $item->get_tax_total() ),
+							'shipping_tax_total' => self::format_amount_for_response( $item->get_shipping_tax_total() ),
+						);
+					},
+					$order->get_taxes()
+				)
+			),
+		);
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index 2f2012a64a3..ee3bd3cb70f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -537,6 +537,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 			$this->assertSame( 'array', $input['status']['type'] );
 			$this->assertContains( 'test-active', $input['status']['items']['enum'] );
 			$this->assertContains( 'test-active', $output['status']['enum'] );
+			$this->assertContains( 'shop_test_contract', $output['type']['enum'], 'An order of a registered type should pass the output schema.' );

 			$query_args = $this->capture_order_query_args(
 				array(
@@ -4625,8 +4626,8 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {

 		$query = wp_get_ability( 'woocommerce/orders-query' )->execute(
 			array(
-				'id'                 => $order->get_id(),
-				'include_line_items' => true,
+				'id'            => $order->get_id(),
+				'include_lines' => true,
 			)
 		);
 		$this->assertSame( 'Pickup', $query['orders'][0]['shipping_lines'][1]['method_title'] );
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/OrdersQueryTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/OrdersQueryTest.php
index 7b381f1d61f..abc74f34a48 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/OrdersQueryTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/OrdersQueryTest.php
@@ -447,6 +447,200 @@ class OrdersQueryTest extends WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * @testdox Should return the order number, type, parent, totals and paid and completed dates of every order.
+	 *
+	 * @dataProvider storage_modes
+	 * @param bool $hpos Whether to use the custom orders table.
+	 */
+	public function test_returns_order_fields( bool $hpos ): void {
+		$this->use_storage( $hpos );
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+		$paid_at = strtotime( '2026-01-02 03:04:05 UTC' );
+		$parent  = OrderHelper::create_order();
+		$child   = OrderHelper::create_order();
+		$child->set_parent_id( $parent->get_id() );
+		$child->set_date_paid( $paid_at );
+		$child->set_date_completed( $paid_at + HOUR_IN_SECONDS );
+		$child->save();
+		add_filter(
+			'woocommerce_order_number',
+			static function ( $number ) {
+				return 'A-' . $number;
+			}
+		);
+
+		$result = wp_get_ability( 'woocommerce/orders-query' )->execute( array( 'include' => array( $parent->get_id(), $child->get_id() ) ) );
+
+		$this->assertNotWPError( $result, 'The new fields should match the output schema.' );
+		$orders = array_column( $result['orders'], null, 'id' );
+		$child  = $orders[ $child->get_id() ];
+		$parent = $orders[ $parent->get_id() ];
+		$this->assertSame( 'A-' . $child['id'], $child['number'], 'The number should be the one shown to the customer, not the ID.' );
+		$this->assertSame( 'shop_order', $child['type'] );
+		$this->assertSame( $parent['id'], $child['parent_id'] );
+		$this->assertSame( 0, $parent['parent_id'] );
+		$this->assertSame( '10', $child['shipping_total'] );
+		$this->assertSame( '0', $child['discount_total'] );
+		$this->assertSame( '0', $child['total_tax'] );
+		$this->assertSame( '2026-01-02T03:04:05+00:00', $child['date_paid_gmt'] );
+		$this->assertSame( '2026-01-02T04:04:05+00:00', $child['date_completed_gmt'] );
+		$this->assertArrayHasKey( 'date_paid', $child );
+		$this->assertArrayHasKey( 'date_completed', $child );
+		$this->assertArrayNotHasKey( 'date_paid', $parent, 'An unpaid order should leave the date out rather than return null.' );
+		$this->assertArrayNotHasKey( 'date_completed_gmt', $parent );
+	}
+
+	/**
+	 * @testdox Should return the shipping, fee, coupon and tax lines with include_lines, named as in wc/v3/orders.
+	 *
+	 * @dataProvider storage_modes
+	 * @param bool $hpos Whether to use the custom orders table.
+	 */
+	public function test_include_lines_returns_every_line_type( bool $hpos ): void {
+		$this->use_storage( $hpos );
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+		list( $order, $rate_id ) = $this->create_order_with_every_line();
+
+		$result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+			array(
+				'id'            => $order->get_id(),
+				'include_lines' => true,
+			)
+		);
+
+		$this->assertNotWPError( $result, 'The lines should match the output schema.' );
+		$data = $result['orders'][0];
+		$this->assertArrayNotHasKey( 'line_items', $data, 'include_lines should not also return the product lines.' );
+		$this->assertSame( '5', $data['discount_total'] );
+
+		$this->assertCount( 1, $data['shipping_lines'] );
+		$this->assertSame( 'flat_rate_shipping', $data['shipping_lines'][0]['method_id'] );
+		$this->assertSame( '1', $data['shipping_lines'][0]['total_tax'] );
+
+		$this->assertCount( 1, $data['fee_lines'] );
+		$this->assertSame( 'Handling', $data['fee_lines'][0]['name'] );
+		$this->assertSame( 'taxable', $data['fee_lines'][0]['tax_status'] );
+		$this->assertSame( '5', $data['fee_lines'][0]['total'] );
+
+		$this->assertCount( 1, $data['coupon_lines'] );
+		$this->assertSame( 'save5', $data['coupon_lines'][0]['code'] );
+		$this->assertSame( '5', $data['coupon_lines'][0]['discount'] );
+
+		$this->assertCount( 1, $data['tax_lines'] );
+		$tax_line = $data['tax_lines'][0];
+		$this->assertSame( $rate_id, $tax_line['rate_id'] );
+		$this->assertSame( 'VAT', $tax_line['label'] );
+		$this->assertFalse( $tax_line['compound'] );
+		$this->assertEqualsWithDelta( (float) $data['total_tax'], (float) $tax_line['tax_total'] + (float) $tax_line['shipping_tax_total'], 0.001, 'The tax line should add up to the order tax.' );
+	}
+
+	/**
+	 * @testdox Should return the SKU, price, tax class and taxes of each line item.
+	 */
+	public function test_line_items_return_price_and_taxes(): void {
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+		list( $order ) = $this->create_order_with_every_line();
+		$product       = current( $order->get_items() )->get_product();
+
+		$result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+			array(
+				'id'                 => $order->get_id(),
+				'include_line_items' => true,
+			)
+		);
+
+		$this->assertNotWPError( $result, 'The line items should match the output schema.' );
+		$line_item = $result['orders'][0]['line_items'][0];
+		$this->assertArrayNotHasKey( 'shipping_lines', $result['orders'][0], 'Shipping lines should need include_lines.' );
+		$this->assertSame( $product->get_sku(), $line_item['sku'] );
+		$this->assertSame( '8.75', $line_item['price'], 'The price should be per unit and after the coupon: 35 over 4 units.' );
+		$this->assertSame( '', $line_item['tax_class'] );
+		$this->assertSame( '4', $line_item['subtotal_tax'] );
+		$this->assertSame( '3.5', $line_item['total_tax'] );
+	}
+
+	/**
+	 * @testdox Should leave out the SKU of a deleted product and return a price of 0 for a line with no quantity.
+	 */
+	public function test_line_items_without_product_or_quantity(): void {
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+		$order = OrderHelper::create_order();
+		$item  = current( $order->get_items() );
+		$item->set_quantity( 0 );
+		$item->save();
+		wc_get_product( $item->get_product_id() )->delete( true );
+
+		$result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+			array(
+				'id'                 => $order->get_id(),
+				'include_line_items' => true,
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$line_item = $result['orders'][0]['line_items'][0];
+		$this->assertArrayNotHasKey( 'sku', $line_item, 'A missing product should leave the SKU out rather than return null.' );
+		$this->assertSame( '0', $line_item['price'] );
+	}
+
+	/**
+	 * @testdox Should return no line arrays when neither include_line_items nor include_lines is sent.
+	 */
+	public function test_returns_no_lines_by_default(): void {
+		$order = OrderHelper::create_order();
+
+		$result = OrdersQuery::execute( array( 'id' => $order->get_id() ) );
+
+		foreach ( array( 'line_items', 'shipping_lines', 'fee_lines', 'coupon_lines', 'tax_lines' ) as $key ) {
+			$this->assertArrayNotHasKey( $key, $result['orders'][0] );
+		}
+	}
+
+	/**
+	 * Create a taxed order with one product line, one shipping line, a fee and a coupon.
+	 *
+	 * @return array{0: \WC_Order, 1: int} The order and the ID of its tax rate.
+	 */
+	private function create_order_with_every_line(): array {
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		$rate_id = \WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => '',
+				'tax_rate_state'    => '',
+				'tax_rate'          => '10.0000',
+				'tax_rate_name'     => 'VAT',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => '1',
+				'tax_rate_class'    => '',
+			)
+		);
+		\WC_Helper_Coupon::create_coupon(
+			'save5',
+			array(
+				'discount_type' => 'fixed_cart',
+				'coupon_amount' => '5',
+			)
+		);
+
+		$order = OrderHelper::create_order();
+		$fee   = new \WC_Order_Item_Fee();
+		$fee->set_props(
+			array(
+				'name'       => 'Handling',
+				'tax_status' => 'taxable',
+				'total'      => '5',
+			)
+		);
+		$order->add_item( $fee );
+		$order->apply_coupon( 'save5' );
+		$order->calculate_totals();
+
+		return array( wc_get_order( $order->get_id() ), $rate_id );
+	}
+
 	/**
 	 * Switch the order storage for the current test.
 	 *