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.
*