Commit 8d08bc72565 for woocommerce

commit 8d08bc72565fdb8291cd1554690311adaa625b64
Author: Samuel Urbanowicz <samiuelson@gmail.com>
Date:   Wed May 27 11:02:06 2026 +0200

    Add can_be_refunded field to v4 order and line item responses (#64162)

    * Add can_be_refunded field to v4 order and line item responses

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Fix array alignment warnings in shipping, fee schemas and test file

    * Fix fee/shipping tax accounting bug and add missing tests

    * Remove order status check from can_be_refunded to match core behavior

    * Add order status gate to can_be_refunded field

    Only orders with completed, processing, or on-hold status are now
    considered refundable. This aligns with the Linear spec requirement
    that cancelled, failed, and pending orders should not be refundable
    regardless of remaining amount.

    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

    * Extract shared refundability logic to AbstractLineItemSchema

    Move duplicated can_be_refunded calculation from OrderFeeSchema and
    OrderShippingSchema into a shared protected method on
    AbstractLineItemSchema. Remove unused $order variables from both
    child schemas.

    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

    * Eliminate N+1 refund queries in order serialization

    Move can_be_refunded computation from child schemas (OrderItemSchema,
    OrderFeeSchema, OrderShippingSchema) to OrderSchema, which loads
    refund data once per order via compute_item_refund_data() and applies
    it to each line item inline.

    Add explicit prime_refund_caches() in Controller::get_items() that
    batch-fetches all refunds for the page in a single query before
    serialization, ensuring the cache is warm regardless of data store
    behavior.

    * Improve can_be_refunded schema descriptions for clarity

    OrderItemSchema: change "remaining refundable quantity" to "remaining
    unrefunded quantity" to avoid confusion with stock quantity.
    OrderFeeSchema/OrderShippingSchema: reword for consistency.

    * Fix PHPStan errors and lint alignment issues

    Add @var type annotations for wc_get_orders() and get_items() calls
    to satisfy PHPStan. Fix array double arrow alignment in
    OrderFeeSchema and OrderShippingSchema after can_be_refunded removal.

    * Remove resolved PHPStan baseline entries

    The @var type annotations added in the previous commit resolved 3
    baselined argument.type errors for OrderSchema passing typed items
    to child schema get_item_response() methods. Remove the now-unmatched
    baseline entries.

    * Fix lint and PHPStan issues in Controller and OrderSchema

    Use multi-line doc comments with short descriptions for @var type
    annotations (satisfies both PHPCS and PHPStan). Fix equals sign
    alignment in Controller::prime_refund_caches().

    * Restore compute_item_refund_data method and remove DataUtils dependency

    The method was inadvertently removed during an external refactor that
    moved it to DataUtils, but that class doesn't have the method yet.
    Restore it as a private method on OrderSchema and remove the unused
    DataUtils import and property.

    * Move compute_item_refund_data to DataUtils

    Move the refund quantity/total pre-computation into
    DataUtils::compute_refunded_quantities_and_totals() so it can be
    reused by other V4 endpoints. Wire DataUtils into OrderSchema via DI.

    * Pass DataUtils to OrderSchema::init() in controller tests

    The init() signature now requires DataUtils after the refactor.

    * Share REFUNDABLE_STATUSES and guard refund data computation

    - Move REFUNDABLE_STATUSES to a public constant on DataUtils,
      reference it from OrderSchema instead of duplicating
    - Only compute refund data when line_items, shipping_lines, or
      fee_lines are requested

    * Fix associative array lint error in refund data fallback

    * Remove redundant refund cache priming

    PR #64232 (now in trunk) replaced the `orders/refunds{id}` cache
    (storing hydrated WC_Order_Refund objects) with `orders/refund_ids{id}`
    (storing only IDs and rehydrating via wc_get_orders). It also wired
    prime_refund_caches_for_orders() into the order data stores, so refund
    caches are primed automatically during the initial wc_get_orders()
    query.

    Our Controller::prime_refund_caches() wrote to the old `refunds` cache
    key, which WC_Order::get_refunds() no longer reads — making the
    priming dead code and incompatible with the new scheme. Drop it.

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/64162-woomob-2686-add-can_be_refunded-field-to-order-and-line-item-responses b/plugins/woocommerce/changelog/64162-woomob-2686-add-can_be_refunded-field-to-order-and-line-item-responses
new file mode 100644
index 00000000000..3a10f33ea37
--- /dev/null
+++ b/plugins/woocommerce/changelog/64162-woomob-2686-add-can_be_refunded-field-to-order-and-line-item-responses
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add `can_be_refunded` field to v4 order and line item REST API responses.
\ No newline at end of file
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 89a339b2dbf..cb7af06126f 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -67482,24 +67482,6 @@ parameters:
 			count: 1
 			path: src/Internal/RestApi/Routes/V4/Orders/Schema/OrderSchema.php

-		-
-			message: '#^Parameter \#1 \$order_item of method Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Orders\\Schema\\OrderFeeSchema\:\:get_item_response\(\) expects WC_Order_Item_Fee, WC_Order_Item given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Orders/Schema/OrderSchema.php
-
-		-
-			message: '#^Parameter \#1 \$order_item of method Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Orders\\Schema\\OrderItemSchema\:\:get_item_response\(\) expects WC_Order_Item_Product, WC_Order_Item given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Orders/Schema/OrderSchema.php
-
-		-
-			message: '#^Parameter \#1 \$order_item of method Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Orders\\Schema\\OrderShippingSchema\:\:get_item_response\(\) expects WC_Order_Item_Shipping, WC_Order_Item given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Orders/Schema/OrderSchema.php
-
 		-
 			message: '#^Parameter \#1 \$order_item of method Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Orders\\Schema\\OrderTaxSchema\:\:get_item_response\(\) expects WC_Order_Item_Tax, WC_Order_Item given\.$#'
 			identifier: argument.type
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderFeeSchema.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderFeeSchema.php
index 800320784dc..66f6478abe9 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderFeeSchema.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderFeeSchema.php
@@ -36,41 +36,47 @@ class OrderFeeSchema extends AbstractLineItemSchema {
 	 */
 	public function get_item_schema_properties(): array {
 		$schema = array(
-			'id'         => array(
+			'id'              => array(
 				'description' => __( 'Item ID.', 'woocommerce' ),
 				'type'        => 'integer',
 				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
 				'readonly'    => true,
 			),
-			'name'       => array(
+			'name'            => array(
 				'description' => __( 'Fee name.', 'woocommerce' ),
 				'type'        => 'string',
 				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
 			),
-			'tax_class'  => array(
+			'tax_class'       => array(
 				'description' => __( 'Tax class of fee.', 'woocommerce' ),
 				'type'        => 'string',
 				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
 			),
-			'tax_status' => array(
+			'tax_status'      => array(
 				'description' => __( 'Tax status of fee.', 'woocommerce' ),
 				'type'        => 'string',
 				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
 				'enum'        => array( 'taxable', 'none' ),
 			),
-			'total'      => array(
+			'total'           => array(
 				'description' => __( 'Line total (after discounts).', 'woocommerce' ),
 				'type'        => 'string',
 				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
 			),
-			'total_tax'  => array(
+			'total_tax'       => array(
 				'description' => __( 'Line total tax (after discounts).', 'woocommerce' ),
 				'type'        => 'string',
 				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
 				'readonly'    => true,
 			),
-			'taxes'      => $this->get_taxes_schema(),
-			'meta_data'  => $this->get_meta_data_schema(),
+			'taxes'           => $this->get_taxes_schema(),
+			'meta_data'       => $this->get_meta_data_schema(),
+			'can_be_refunded' => array(
+				'description' => __( 'Whether the fee can be refunded, based on remaining refundable amount.', 'woocommerce' ),
+				'type'        => 'boolean',
+				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+				'readonly'    => true,
+			),
 		);

 		return $schema;
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderItemSchema.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderItemSchema.php
index 98e3bc7a554..707f90a805e 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderItemSchema.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderItemSchema.php
@@ -124,6 +124,12 @@ class OrderItemSchema extends AbstractLineItemSchema {
 				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
 				'readonly'    => true,
 			),
+			'can_be_refunded' => array(
+				'description' => __( 'Whether the line item can be refunded. True when the item has a product and its ordered quantity has not been fully refunded.', 'woocommerce' ),
+				'type'        => 'boolean',
+				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+				'readonly'    => true,
+			),
 		);

 		if ( $this->cogs_is_enabled() ) {
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderSchema.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderSchema.php
index e3d3302fbf7..b7c09913e8c 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderSchema.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderSchema.php
@@ -15,6 +15,7 @@ use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractSchema;
 use Automattic\WooCommerce\Enums\OrderItemType;
 use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
+use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\DataUtils;
 use Automattic\WooCommerce\Utilities\OrderUtil;
 use WC_Order;
 use WP_REST_Request;
@@ -68,22 +69,32 @@ class OrderSchema extends AbstractSchema {
 	 */
 	private $order_shipping_schema;

+	/**
+	 * Refund data utils.
+	 *
+	 * @var DataUtils
+	 */
+	private $data_utils;
+
 	/**
 	 * Initialize the schema.
 	 *
 	 * @internal
+	 *
 	 * @param OrderItemSchema     $order_item_schema The order item schema.
 	 * @param OrderCouponSchema   $order_coupon_schema The order coupon schema.
 	 * @param OrderFeeSchema      $order_fee_schema The order fee schema.
 	 * @param OrderTaxSchema      $order_tax_schema The order tax schema.
 	 * @param OrderShippingSchema $order_shipping_schema The order shipping schema.
+	 * @param DataUtils           $data_utils Refund data utils.
 	 */
-	final public function init( OrderItemSchema $order_item_schema, OrderCouponSchema $order_coupon_schema, OrderFeeSchema $order_fee_schema, OrderTaxSchema $order_tax_schema, OrderShippingSchema $order_shipping_schema ) {
+	final public function init( OrderItemSchema $order_item_schema, OrderCouponSchema $order_coupon_schema, OrderFeeSchema $order_fee_schema, OrderTaxSchema $order_tax_schema, OrderShippingSchema $order_shipping_schema, DataUtils $data_utils ) {
 		$this->order_item_schema     = $order_item_schema;
 		$this->order_coupon_schema   = $order_coupon_schema;
 		$this->order_fee_schema      = $order_fee_schema;
 		$this->order_tax_schema      = $order_tax_schema;
 		$this->order_shipping_schema = $order_shipping_schema;
+		$this->data_utils            = $data_utils;
 	}

 	/**
@@ -537,6 +548,12 @@ class OrderSchema extends AbstractSchema {
 				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
 				'readonly'    => true,
 			),
+			'can_be_refunded'      => array(
+				'description' => __( 'Whether the order can be refunded, based on its status and remaining refundable amount.', 'woocommerce' ),
+				'type'        => 'boolean',
+				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+				'readonly'    => true,
+			),
 		);

 		if ( $this->cogs_is_enabled() ) {
@@ -643,6 +660,7 @@ class OrderSchema extends AbstractSchema {
 			'needs_payment'        => $order->needs_payment(),
 			'needs_processing'     => $order->needs_processing(),
 			'fulfillment_status'   => FulfillmentUtils::get_order_fulfillment_status( $order ),
+			'can_be_refunded'      => $this->calculate_order_can_be_refunded( $order ),
 		);

 		if ( in_array( 'refund_total', $include_fields, true ) ) {
@@ -653,19 +671,48 @@ class OrderSchema extends AbstractSchema {
 			$data['refund_tax'] = wc_format_decimal( $order->get_total_tax_refunded(), $dp );
 		}

+		// Pre-compute refund data once per order, only when line item fields are requested.
+		$needs_refund_data = array_intersect( array( 'line_items', 'shipping_lines', 'fee_lines' ), $include_fields );
+		$refund_data       = ! empty( $needs_refund_data )
+			? $this->data_utils->compute_refunded_quantities_and_totals( $order )
+			: array(
+				'qtys'   => array(),
+				'totals' => array(),
+			);
+
 		if ( in_array( 'line_items', $include_fields, true ) ) {
+			/**
+			 * Product line items.
+			 *
+			 * @var \WC_Order_Item_Product[] $line_items
+			 */
 			$line_items         = $order->get_items( OrderItemType::LINE_ITEM );
 			$data['line_items'] = array();
 			foreach ( $line_items as $line_item ) {
-				$data['line_items'][] = $this->order_item_schema->get_item_response( $line_item, $request );
+				$item_data = $this->order_item_schema->get_item_response( $line_item, $request );
+
+				$item_data['can_be_refunded'] = 0 !== $line_item->get_product_id()
+					&& ( $line_item->get_quantity() + ( $refund_data['qtys'][ $line_item->get_id() ] ?? 0 ) ) > 0;
+
+				$data['line_items'][] = $item_data;
 			}
 		}

 		if ( in_array( 'shipping_lines', $include_fields, true ) ) {
-			$line_items             = $order->get_items( OrderItemType::SHIPPING );
+			/**
+			 * Shipping line items.
+			 *
+			 * @var \WC_Order_Item_Shipping[] $shipping_lines
+			 */
+			$shipping_lines         = $order->get_items( OrderItemType::SHIPPING );
 			$data['shipping_lines'] = array();
-			foreach ( $line_items as $line_item ) {
-				$data['shipping_lines'][] = $this->order_shipping_schema->get_item_response( $line_item, $request );
+			foreach ( $shipping_lines as $shipping_line ) {
+				$item_data = $this->order_shipping_schema->get_item_response( $shipping_line, $request );
+				$refunded  = $refund_data['totals'][ $shipping_line->get_id() ] ?? 0.0;
+
+				$item_data['can_be_refunded'] = ( (float) $shipping_line->get_total() - $refunded ) > 0;
+
+				$data['shipping_lines'][] = $item_data;
 			}
 		}

@@ -678,10 +725,20 @@ class OrderSchema extends AbstractSchema {
 		}

 		if ( in_array( 'fee_lines', $include_fields, true ) ) {
-			$line_items        = $order->get_items( OrderItemType::FEE );
+			/**
+			 * Fee line items.
+			 *
+			 * @var \WC_Order_Item_Fee[] $fee_lines
+			 */
+			$fee_lines         = $order->get_items( OrderItemType::FEE );
 			$data['fee_lines'] = array();
-			foreach ( $line_items as $line_item ) {
-				$data['fee_lines'][] = $this->order_fee_schema->get_item_response( $line_item, $request );
+			foreach ( $fee_lines as $fee_line ) {
+				$item_data = $this->order_fee_schema->get_item_response( $fee_line, $request );
+				$refunded  = $refund_data['totals'][ $fee_line->get_id() ] ?? 0.0;
+
+				$item_data['can_be_refunded'] = ( (float) $fee_line->get_total() - $refunded ) > 0;
+
+				$data['fee_lines'][] = $item_data;
 			}
 		}

@@ -715,6 +772,21 @@ class OrderSchema extends AbstractSchema {
 		return $data;
 	}

+	/**
+	 * Determine whether an order can be refunded.
+	 *
+	 * An order can be refunded when its status allows refunds and it has remaining refundable amount.
+	 *
+	 * @param WC_Order $order Order instance.
+	 * @return bool
+	 */
+	private function calculate_order_can_be_refunded( WC_Order $order ): bool {
+		if ( ! in_array( $order->get_status(), DataUtils::REFUNDABLE_STATUSES, true ) ) {
+			return false;
+		}
+		return (float) $order->get_remaining_refund_amount() > 0;
+	}
+
 	/**
 	 * With HPOS, few internal meta keys such as _billing_address_index, _shipping_address_index are not considered internal anymore (since most internal keys were flattened into dedicated columns).
 	 *
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderShippingSchema.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderShippingSchema.php
index 2e5b0563801..8aeb72024ed 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderShippingSchema.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderShippingSchema.php
@@ -36,40 +36,46 @@ class OrderShippingSchema extends AbstractLineItemSchema {
 	 */
 	public function get_item_schema_properties(): array {
 		$schema = array(
-			'id'           => array(
+			'id'              => array(
 				'description' => __( 'Item ID.', 'woocommerce' ),
 				'type'        => 'integer',
 				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
 				'readonly'    => true,
 			),
-			'method_title' => array(
+			'method_title'    => array(
 				'description' => __( 'Shipping method name.', 'woocommerce' ),
 				'type'        => 'string',
 				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
 			),
-			'method_id'    => array(
+			'method_id'       => array(
 				'description' => __( 'Shipping method ID.', 'woocommerce' ),
 				'type'        => 'string',
 				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
 			),
-			'instance_id'  => array(
+			'instance_id'     => array(
 				'description' => __( 'Shipping instance ID.', 'woocommerce' ),
 				'type'        => 'string',
 				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
 			),
-			'total'        => array(
+			'total'           => array(
 				'description' => __( 'Line total (after discounts).', 'woocommerce' ),
 				'type'        => 'string',
 				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
 			),
-			'total_tax'    => array(
+			'total_tax'       => array(
 				'description' => __( 'Line total tax (after discounts).', 'woocommerce' ),
 				'type'        => 'string',
 				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
 				'readonly'    => true,
 			),
-			'taxes'        => $this->get_taxes_schema(),
-			'meta_data'    => $this->get_meta_data_schema(),
+			'taxes'           => $this->get_taxes_schema(),
+			'meta_data'       => $this->get_meta_data_schema(),
+			'can_be_refunded' => array(
+				'description' => __( 'Whether the shipping line can be refunded, based on remaining refundable amount.', 'woocommerce' ),
+				'type'        => 'boolean',
+				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+				'readonly'    => true,
+			),
 		);

 		return $schema;
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
index 2761f7eb171..280a3d93b65 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
@@ -10,6 +10,7 @@ namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds;
 defined( 'ABSPATH' ) || exit;

 use Automattic\WooCommerce\Enums\OrderItemType;
+use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Utilities\NumberUtil;
 use WP_Error;
 use WC_Order;
@@ -23,6 +24,15 @@ use WC_Tax;
  * @package Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds
  */
 class DataUtils {
+	/**
+	 * Order statuses that allow refunds.
+	 */
+	public const REFUNDABLE_STATUSES = array(
+		OrderStatus::COMPLETED,
+		OrderStatus::PROCESSING,
+		OrderStatus::ON_HOLD,
+	);
+
 	/**
 	 * Convert line items (schema format) to internal format. This keys arrays by item ID and has some different naming
 	 * conventions.
@@ -281,4 +291,56 @@ class DataUtils {

 		return $tax_rates;
 	}
+
+	/**
+	 * Pre-compute refund data for all line items in an order.
+	 *
+	 * Loads refunds once and builds lookup maps for refunded quantities and totals per item ID,
+	 * avoiding repeated get_refunds() calls during serialization.
+	 *
+	 * @param WC_Order $order Order instance.
+	 * @return array{qtys: array<int, int>, totals: array<int, float>}
+	 */
+	public function compute_refunded_quantities_and_totals( WC_Order $order ): array {
+		$qtys   = array();
+		$totals = array();
+
+		foreach ( $order->get_refunds() as $refund ) {
+			/**
+			 * Refunded product line items.
+			 *
+			 * @var \WC_Order_Item_Product[] $refunded_line_items
+			 */
+			$refunded_line_items = $refund->get_items( 'line_item' );
+			foreach ( $refunded_line_items as $refunded_item ) {
+				$original_id          = absint( $refunded_item->get_meta( '_refunded_item_id' ) );
+				$qtys[ $original_id ] = ( $qtys[ $original_id ] ?? 0 ) + $refunded_item->get_quantity();
+			}
+			/**
+			 * Refunded fee items.
+			 *
+			 * @var \WC_Order_Item_Fee[] $refunded_fees
+			 */
+			$refunded_fees = $refund->get_items( 'fee' );
+			foreach ( $refunded_fees as $refunded_item ) {
+				$original_id            = absint( $refunded_item->get_meta( '_refunded_item_id' ) );
+				$totals[ $original_id ] = ( $totals[ $original_id ] ?? 0.0 ) + (float) $refunded_item->get_total() * -1;
+			}
+			/**
+			 * Refunded shipping items.
+			 *
+			 * @var \WC_Order_Item_Shipping[] $refunded_shipping
+			 */
+			$refunded_shipping = $refund->get_items( 'shipping' );
+			foreach ( $refunded_shipping as $refunded_item ) {
+				$original_id            = absint( $refunded_item->get_meta( '_refunded_item_id' ) );
+				$totals[ $original_id ] = ( $totals[ $original_id ] ?? 0.0 ) + (float) $refunded_item->get_total() * -1;
+			}
+		}
+
+		return array(
+			'qtys'   => $qtys,
+			'totals' => $totals,
+		);
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Orders/class-wc-rest-orders-v4-can-be-refunded-test.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Orders/class-wc-rest-orders-v4-can-be-refunded-test.php
new file mode 100644
index 00000000000..cc514c5651f
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Orders/class-wc-rest-orders-v4-can-be-refunded-test.php
@@ -0,0 +1,562 @@
+<?php
+declare( strict_types = 1 );
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+
+/**
+ * Tests for the `can_be_refunded` field on v4 order and line item responses.
+ */
+class WC_REST_Orders_V4_Can_Be_Refunded_Test extends WC_REST_Unit_Test_Case {
+
+	/**
+	 * User ID for an admin user.
+	 *
+	 * @var int
+	 */
+	private $user_id;
+
+	/**
+	 * Enable the REST API v4 feature.
+	 */
+	private static function enable_rest_api_v4_feature(): void {
+		add_filter(
+			'woocommerce_admin_features',
+			array( __CLASS__, 'add_v4_feature' ),
+		);
+	}
+
+	/**
+	 * Disable the REST API v4 feature.
+	 */
+	private static function disable_rest_api_v4_feature(): void {
+		remove_filter(
+			'woocommerce_admin_features',
+			array( __CLASS__, 'add_v4_feature' ),
+		);
+	}
+
+	/**
+	 * Filter callback to add the rest-api-v4 feature.
+	 *
+	 * @param array $features Features array.
+	 * @return array
+	 */
+	public static function add_v4_feature( array $features ): array {
+		$features[] = 'rest-api-v4';
+		return $features;
+	}
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		self::enable_rest_api_v4_feature();
+		parent::setUp();
+
+		$this->user_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $this->user_id );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		parent::tearDown();
+		self::disable_rest_api_v4_feature();
+	}
+
+	/**
+	 * Helper to get a single order via the v4 API.
+	 *
+	 * @param int $order_id Order ID.
+	 * @return array Response data.
+	 */
+	private function get_order_response( int $order_id ): array {
+		$request  = new WP_REST_Request( 'GET', '/wc/v4/orders/' . $order_id );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 200, $response->get_status() );
+		return $response->get_data();
+	}
+
+	/**
+	 * Helper to create an order with a product line item.
+	 *
+	 * @param string $status Order status.
+	 * @return WC_Order
+	 */
+	private function create_order_with_product( string $status = 'completed' ): WC_Order {
+		$product = WC_Helper_Product::create_simple_product( true, array( 'regular_price' => '10.00' ) );
+		$order   = WC_Helper_Order::create_order( $this->user_id, $product );
+		$order->set_status( $status );
+		$order->save();
+		$order->calculate_totals( true );
+
+		return $order;
+	}
+
+	/**
+	 * @testdox Fresh unrefunded order has can_be_refunded true at order and line item level.
+	 */
+	public function test_fresh_order_can_be_refunded(): void {
+		$order = $this->create_order_with_product();
+		$data  = $this->get_order_response( $order->get_id() );
+
+		$this->assertTrue( $data['can_be_refunded'], 'Fresh order should be refundable' );
+		$this->assertNotEmpty( $data['line_items'], 'Order should have line items' );
+		$this->assertTrue( $data['line_items'][0]['can_be_refunded'], 'Fresh line item should be refundable' );
+	}
+
+	/**
+	 * @testdox Fully refunded order has can_be_refunded false at order and line item level.
+	 */
+	public function test_fully_refunded_order(): void {
+		$order     = $this->create_order_with_product();
+		$line_item = current( $order->get_items() );
+
+		wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => $order->get_total(),
+				'line_items' => array(
+					$line_item->get_id() => array(
+						'qty'          => $line_item->get_quantity(),
+						'refund_total' => $line_item->get_total(),
+						'refund_tax'   => array(),
+					),
+				),
+			)
+		);
+
+		$data = $this->get_order_response( $order->get_id() );
+
+		$this->assertFalse( $data['can_be_refunded'], 'Fully refunded order should not be refundable' );
+		$this->assertFalse( $data['line_items'][0]['can_be_refunded'], 'Fully refunded line item should not be refundable' );
+	}
+
+	/**
+	 * @testdox Partially refunded order has mixed can_be_refunded values.
+	 */
+	public function test_partially_refunded_order(): void {
+		$product_a = WC_Helper_Product::create_simple_product( true, array( 'regular_price' => '10.00' ) );
+		$product_b = WC_Helper_Product::create_simple_product( true, array( 'regular_price' => '20.00' ) );
+
+		$order = wc_create_order( array( 'customer_id' => $this->user_id ) );
+
+		$item_a = new WC_Order_Item_Product();
+		$item_a->set_props(
+			array(
+				'product'  => $product_a,
+				'quantity' => 2,
+				'subtotal' => 20,
+				'total'    => 20,
+			)
+		);
+		$item_a->save();
+		$order->add_item( $item_a );
+
+		$item_b = new WC_Order_Item_Product();
+		$item_b->set_props(
+			array(
+				'product'  => $product_b,
+				'quantity' => 1,
+				'subtotal' => 20,
+				'total'    => 20,
+			)
+		);
+		$item_b->save();
+		$order->add_item( $item_b );
+
+		$order->set_status( 'completed' );
+		$order->save();
+		$order->calculate_totals( true );
+
+		// Fully refund item A.
+		wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => 20,
+				'line_items' => array(
+					$item_a->get_id() => array(
+						'qty'          => 2,
+						'refund_total' => 20,
+						'refund_tax'   => array(),
+					),
+				),
+			)
+		);
+
+		$data = $this->get_order_response( $order->get_id() );
+
+		$this->assertTrue( $data['can_be_refunded'], 'Partially refunded order should still be refundable' );
+
+		$items_by_product = array();
+		foreach ( $data['line_items'] as $item ) {
+			$items_by_product[ $item['product_id'] ] = $item;
+		}
+
+		$this->assertFalse(
+			$items_by_product[ $product_a->get_id() ]['can_be_refunded'],
+			'Fully refunded line item should not be refundable'
+		);
+		$this->assertTrue(
+			$items_by_product[ $product_b->get_id() ]['can_be_refunded'],
+			'Unrefunded line item should be refundable'
+		);
+	}
+
+	/**
+	 * @testdox Order with cancelled status cannot be refunded even if it has remaining amount.
+	 */
+	public function test_cancelled_order_with_remaining_amount_is_not_refundable(): void {
+		$order = $this->create_order_with_product( 'cancelled' );
+		$data  = $this->get_order_response( $order->get_id() );
+
+		$this->assertFalse( $data['can_be_refunded'], 'Cancelled order should not be refundable regardless of remaining amount' );
+	}
+
+	/**
+	 * @testdox Order with failed status cannot be refunded even if it has remaining amount.
+	 */
+	public function test_failed_order_with_remaining_amount_is_not_refundable(): void {
+		$order = $this->create_order_with_product( 'failed' );
+		$data  = $this->get_order_response( $order->get_id() );
+
+		$this->assertFalse( $data['can_be_refunded'], 'Failed order should not be refundable regardless of remaining amount' );
+	}
+
+	/**
+	 * @testdox Order with on-hold status can be refunded if it has remaining amount.
+	 */
+	public function test_on_hold_order_with_remaining_amount_is_refundable(): void {
+		$order = $this->create_order_with_product( 'on-hold' );
+		$data  = $this->get_order_response( $order->get_id() );
+
+		$this->assertTrue( $data['can_be_refunded'], 'On-hold order with remaining amount should be refundable' );
+	}
+
+	/**
+	 * @testdox Order with pending status cannot be refunded even if it has remaining amount.
+	 */
+	public function test_pending_order_is_not_refundable(): void {
+		$order = $this->create_order_with_product( 'pending' );
+		$data  = $this->get_order_response( $order->get_id() );
+
+		$this->assertFalse( $data['can_be_refunded'], 'Pending order should not be refundable regardless of remaining amount' );
+	}
+
+	/**
+	 * @testdox Order with refunded status and no remaining amount has can_be_refunded false.
+	 */
+	public function test_refunded_status_order_no_remaining_amount(): void {
+		$order     = $this->create_order_with_product();
+		$line_item = current( $order->get_items() );
+
+		wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => $order->get_total(),
+				'line_items' => array(
+					$line_item->get_id() => array(
+						'qty'          => $line_item->get_quantity(),
+						'refund_total' => $line_item->get_total(),
+						'refund_tax'   => array(),
+					),
+				),
+			)
+		);
+
+		$data = $this->get_order_response( $order->get_id() );
+
+		$this->assertFalse( $data['can_be_refunded'], 'Fully refunded order should not be refundable' );
+	}
+
+	/**
+	 * @testdox Line item with product_id 0 has can_be_refunded false.
+	 */
+	public function test_line_item_without_product_not_refundable(): void {
+		$order = wc_create_order( array( 'customer_id' => $this->user_id ) );
+
+		$item = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'name'     => 'Custom item',
+				'quantity' => 1,
+				'subtotal' => 10,
+				'total'    => 10,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->set_status( 'completed' );
+		$order->save();
+		$order->calculate_totals( true );
+
+		$data = $this->get_order_response( $order->get_id() );
+
+		$this->assertFalse( $data['line_items'][0]['can_be_refunded'], 'Line item with product_id=0 should not be refundable' );
+	}
+
+	/**
+	 * @testdox List endpoint returns can_be_refunded for all orders with correct values.
+	 */
+	public function test_list_endpoint_returns_field(): void {
+		$completed_order = $this->create_order_with_product();
+		$cancelled_order = $this->create_order_with_product( 'cancelled' );
+
+		$request  = new WP_REST_Request( 'GET', '/wc/v4/orders' );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 200, $response->get_status() );
+
+		$orders_by_id = array();
+		foreach ( $response->get_data() as $order_data ) {
+			$this->assertArrayHasKey( 'can_be_refunded', $order_data, 'List endpoint should include can_be_refunded' );
+			$orders_by_id[ $order_data['id'] ] = $order_data;
+		}
+
+		$this->assertTrue( $orders_by_id[ $completed_order->get_id() ]['can_be_refunded'], 'Completed order should be refundable' );
+		$this->assertFalse( $orders_by_id[ $cancelled_order->get_id() ]['can_be_refunded'], 'Cancelled order should not be refundable' );
+	}
+
+	/**
+	 * @testdox The can_be_refunded field is read-only and ignored in POST requests.
+	 */
+	public function test_field_is_read_only(): void {
+		$product = WC_Helper_Product::create_simple_product( true, array( 'regular_price' => '10.00' ) );
+
+		$request = new WP_REST_Request( 'POST', '/wc/v4/orders' );
+		$request->set_body_params(
+			array(
+				'status'          => OrderStatus::COMPLETED,
+				'can_be_refunded' => false,
+				'line_items'      => array(
+					array(
+						'product_id' => $product->get_id(),
+						'quantity'   => 1,
+					),
+				),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 201, $response->get_status() );
+		$data = $response->get_data();
+
+		$this->assertTrue( $data['can_be_refunded'], 'can_be_refunded should be server-computed regardless of POST value' );
+	}
+
+	/**
+	 * @testdox Shipping line with remaining amount has can_be_refunded true.
+	 */
+	public function test_shipping_line_can_be_refunded(): void {
+		$order = WC_Helper_Order::create_order_with_fees_and_shipping( $this->user_id );
+		$order->set_status( 'completed' );
+		$order->save();
+		$order->calculate_totals( true );
+
+		$data = $this->get_order_response( $order->get_id() );
+
+		$this->assertNotEmpty( $data['shipping_lines'], 'Order should have shipping lines' );
+		$this->assertTrue( $data['shipping_lines'][0]['can_be_refunded'], 'Unrefunded shipping line should be refundable' );
+	}
+
+	/**
+	 * @testdox Fee line with remaining amount has can_be_refunded true.
+	 */
+	public function test_fee_line_can_be_refunded(): void {
+		$order = WC_Helper_Order::create_order_with_fees_and_shipping( $this->user_id );
+		$order->set_status( 'completed' );
+		$order->save();
+		$order->calculate_totals( true );
+
+		$data = $this->get_order_response( $order->get_id() );
+
+		$this->assertNotEmpty( $data['fee_lines'], 'Order should have fee lines' );
+		$this->assertTrue( $data['fee_lines'][0]['can_be_refunded'], 'Unrefunded fee line should be refundable' );
+	}
+
+	/**
+	 * @testdox Fully refunded shipping line has can_be_refunded false.
+	 */
+	public function test_fully_refunded_shipping_line(): void {
+		$order = WC_Helper_Order::create_order_with_fees_and_shipping( $this->user_id );
+		$order->set_status( 'completed' );
+		$order->save();
+		$order->calculate_totals( true );
+
+		$shipping_item = current( $order->get_items( 'shipping' ) );
+
+		wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => $shipping_item->get_total(),
+				'line_items' => array(
+					$shipping_item->get_id() => array(
+						'qty'          => 0,
+						'refund_total' => $shipping_item->get_total(),
+						'refund_tax'   => array(),
+					),
+				),
+			)
+		);
+
+		$data = $this->get_order_response( $order->get_id() );
+
+		$this->assertFalse( $data['shipping_lines'][0]['can_be_refunded'], 'Fully refunded shipping line should not be refundable' );
+	}
+
+	/**
+	 * @testdox Fully refunded fee line has can_be_refunded false.
+	 */
+	public function test_fully_refunded_fee_line(): void {
+		$order = WC_Helper_Order::create_order_with_fees_and_shipping( $this->user_id );
+		$order->set_status( 'completed' );
+		$order->save();
+		$order->calculate_totals( true );
+
+		$fee_item = current( $order->get_items( 'fee' ) );
+
+		wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => $fee_item->get_total(),
+				'line_items' => array(
+					$fee_item->get_id() => array(
+						'qty'          => 0,
+						'refund_total' => $fee_item->get_total(),
+						'refund_tax'   => array(),
+					),
+				),
+			)
+		);
+
+		$data = $this->get_order_response( $order->get_id() );
+
+		$this->assertFalse( $data['fee_lines'][0]['can_be_refunded'], 'Fully refunded fee line should not be refundable' );
+	}
+
+	/**
+	 * @testdox Fully refunded shipping line with tax has can_be_refunded false.
+	 */
+	public function test_fully_refunded_shipping_line_with_tax(): void {
+		$order = wc_create_order( array( 'customer_id' => $this->user_id ) );
+
+		$shipping_item = new WC_Order_Item_Shipping();
+		$shipping_item->set_props(
+			array(
+				'method_title' => 'Flat Rate',
+				'total'        => '10.00',
+			)
+		);
+		$shipping_item->set_taxes(
+			array(
+				'total' => array( 1 => '1.50' ),
+			)
+		);
+		$shipping_item->save();
+		$order->add_item( $shipping_item );
+		$order->set_status( 'completed' );
+		$order->save();
+		$order->update_taxes();
+		$order->calculate_totals( false );
+
+		wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => 11.50,
+				'line_items' => array(
+					$shipping_item->get_id() => array(
+						'qty'          => 0,
+						'refund_total' => 10.00,
+						'refund_tax'   => array( 1 => 1.50 ),
+					),
+				),
+			)
+		);
+
+		$data = $this->get_order_response( $order->get_id() );
+
+		$this->assertFalse(
+			$data['shipping_lines'][0]['can_be_refunded'],
+			'Fully refunded shipping line with tax should not be refundable'
+		);
+	}
+
+	/**
+	 * @testdox Fully refunded fee line with tax has can_be_refunded false.
+	 */
+	public function test_fully_refunded_fee_line_with_tax(): void {
+		$order = wc_create_order( array( 'customer_id' => $this->user_id ) );
+
+		$fee_item = new WC_Order_Item_Fee();
+		$fee_item->set_props(
+			array(
+				'name'  => 'Test Fee',
+				'total' => '20.00',
+			)
+		);
+		$fee_item->set_taxes(
+			array(
+				'total' => array( 1 => '3.00' ),
+			)
+		);
+		$fee_item->save();
+		$order->add_item( $fee_item );
+		$order->set_status( 'completed' );
+		$order->save();
+		$order->update_taxes();
+		$order->calculate_totals( false );
+
+		wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => 23.00,
+				'line_items' => array(
+					$fee_item->get_id() => array(
+						'qty'          => 0,
+						'refund_total' => 20.00,
+						'refund_tax'   => array( 1 => 3.00 ),
+					),
+				),
+			)
+		);
+
+		$data = $this->get_order_response( $order->get_id() );
+
+		$this->assertFalse(
+			$data['fee_lines'][0]['can_be_refunded'],
+			'Fully refunded fee line with tax should not be refundable'
+		);
+	}
+
+	/**
+	 * @testdox Zero-priced product line item with quantity follows quantity logic.
+	 */
+	public function test_zero_priced_item_follows_quantity_logic(): void {
+		$product = WC_Helper_Product::create_simple_product( true, array( 'regular_price' => '0.00' ) );
+		$order   = wc_create_order( array( 'customer_id' => $this->user_id ) );
+
+		$item = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 2,
+				'subtotal' => 0,
+				'total'    => 0,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->set_status( 'completed' );
+		$order->save();
+		$order->calculate_totals( true );
+
+		$data = $this->get_order_response( $order->get_id() );
+
+		$this->assertTrue(
+			$data['line_items'][0]['can_be_refunded'],
+			'Zero-priced item with remaining quantity should be refundable'
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Orders/class-wc-rest-orders-v4-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Orders/class-wc-rest-orders-v4-controller-tests.php
index 455f32d96c3..4b90c5db7db 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Orders/class-wc-rest-orders-v4-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Orders/class-wc-rest-orders-v4-controller-tests.php
@@ -11,6 +11,7 @@ use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema\OrderCouponS
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema\OrderFeeSchema;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema\OrderTaxSchema;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema\OrderShippingSchema;
+use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\DataUtils;

 /**
  * Orders Controller tests for V4 REST API.
@@ -89,8 +90,10 @@ class WC_REST_Orders_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
 		$order_tax_schema      = new OrderTaxSchema();
 		$order_shipping_schema = new OrderShippingSchema();

+		$data_utils = new DataUtils();
+
 		$this->order_schema = new OrderSchema();
-		$this->order_schema->init( $order_item_schema, $order_coupon_schema, $order_fee_schema, $order_tax_schema, $order_shipping_schema );
+		$this->order_schema->init( $order_item_schema, $order_coupon_schema, $order_fee_schema, $order_tax_schema, $order_shipping_schema, $data_utils );

 		// Create utils instances.
 		$collection_query  = new \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\CollectionQuery();