Commit 3541d12b6dd for woocommerce

commit 3541d12b6dd8e4a1f0aeb60e18ad1209c283db20
Author: Tom Cafferkey <tjcafferkey@gmail.com>
Date:   Wed Oct 14 15:03:31 2026 +0100

    Fix invalid line item amounts in REST refunds (#69302)

    * Fix REST refund line item amount validation

    * Add changelog entry for REST refund validation

    * Fix taxed refund line amount validation

    * Reject wrong-sign REST refund line amounts

    * Reject refund items from another order

    * Compare refund line limits at currency precision

    * Reject opposite-sign tax on explicit REST refunds

    * Clarify missing REST refund amount error

    * Add since tag to refund line validation method

    * Validate tax-only REST refund line amounts

    * Fix tax-only REST refund creation

diff --git a/plugins/woocommerce/changelog/fix-68172-refund-line-item-validation b/plugins/woocommerce/changelog/fix-68172-refund-line-item-validation
new file mode 100644
index 00000000000..34fc26061bc
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-68172-refund-line-item-validation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent inaccurate product refunds and Analytics reports when refunding orders through the REST API.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller.php
index a3b16fefff6..c1543c2ee8e 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller.php
@@ -15,6 +15,7 @@ use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\DataUtils;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundPreviewSchema;
 use Automattic\WooCommerce\Utilities\MetaDataUtil;
+use Automattic\WooCommerce\Utilities\NumberUtil;

 /**
  * REST API Order Refunds controller class.
@@ -405,7 +406,7 @@ class WC_REST_Order_Refunds_Controller extends WC_REST_Order_Refunds_V2_Controll

 		$order = wc_get_order( (int) $request['order_id'] );

-		if ( ! $order ) {
+		if ( ! $order instanceof WC_Order ) {
 			return new WP_Error( 'woocommerce_rest_invalid_order_id', __( 'Invalid order ID.', 'woocommerce' ), 404 );
 		}

@@ -413,13 +414,27 @@ class WC_REST_Order_Refunds_Controller extends WC_REST_Order_Refunds_V2_Controll
 			return new WP_Error( 'woocommerce_rest_invalid_order_refund', __( 'Refund amount must be greater than zero.', 'woocommerce' ), 400 );
 		}

+		$validation_error = $this->validate_explicit_refund_amounts( $order, $request['line_items'], $request['amount'] );
+		if ( is_wp_error( $validation_error ) ) {
+			return $validation_error;
+		}
+
+		$line_items = $request['line_items'];
+		if ( is_array( $line_items ) ) {
+			foreach ( $line_items as $item_id => $line ) {
+				if ( is_array( $line ) && ! isset( $line['refund_total'] ) && ! empty( $line['refund_tax'] ) ) {
+					$line_items[ $item_id ]['refund_total'] = 0;
+				}
+			}
+		}
+
 		// Create the refund.
 		$refund = wc_create_refund(
 			array(
 				'order_id'       => $order->get_id(),
 				'amount'         => $request['amount'],
 				'reason'         => $request['reason'],
-				'line_items'     => $request['line_items'],
+				'line_items'     => $line_items,
 				'refund_payment' => $request['api_refund'],
 				'restock_items'  => $request['api_restock'],
 			)
@@ -451,6 +466,90 @@ class WC_REST_Order_Refunds_Controller extends WC_REST_Order_Refunds_V2_Controll
 		return apply_filters( "woocommerce_rest_pre_insert_{$this->post_type}_object", $refund, $request, $creating );
 	}

+	/**
+	 * Validate explicit refund amounts without changing quantity-only requests.
+	 *
+	 * This runs when compute_totals is false; the computed path uses
+	 * create_refund_with_computed_totals(). Compare each line's gross refund with its
+	 * refundable balance and with the requested order-level amount.
+	 *
+	 * @param WC_Order $order      Order being refunded.
+	 * @param array    $line_items Line items in the internal format.
+	 * @param mixed    $amount     Requested order-level refund amount.
+	 * @return true|WP_Error Validation result.
+	 */
+	private function validate_explicit_refund_amounts( $order, $line_items, $amount ) {
+		if ( ! is_array( $line_items ) || ! $line_items ) {
+			return true;
+		}
+
+		$line_total     = 0.0;
+		$price_decimals = wc_get_price_decimals();
+		$refund_data    = null;
+		foreach ( $line_items as $item_id => $line ) {
+			if ( ! is_array( $line ) || ( ! isset( $line['refund_total'] ) && empty( $line['refund_tax'] ) ) ) {
+				continue;
+			}
+			$line['refund_total'] = $line['refund_total'] ?? 0;
+
+			if ( ! is_numeric( $line['refund_total'] ) ) {
+				return new WP_Error( 'woocommerce_rest_invalid_refund_total', __( 'refund_total must be a number.', 'woocommerce' ), array( 'status' => 400 ) );
+			}
+
+			$gross_refund_total = (float) $line['refund_total'];
+			$has_nonzero_tax    = false;
+			if ( is_array( $line['refund_tax'] ?? null ) ) {
+				foreach ( $line['refund_tax'] as $tax_total ) {
+					if ( is_numeric( $tax_total ) ) {
+						$gross_refund_total += (float) $tax_total;
+						$has_nonzero_tax     = $has_nonzero_tax || 0.0 !== (float) $tax_total;
+					}
+				}
+			}
+			$line_total += $gross_refund_total;
+			if ( 0.0 === $gross_refund_total && 0.0 === (float) $line['refund_total'] && ! $has_nonzero_tax ) {
+				continue;
+			}
+
+			$item = $order->get_item( $item_id );
+			if ( ( ! $item instanceof WC_Order_Item_Product && ! $item instanceof WC_Order_Item_Fee && ! $item instanceof WC_Order_Item_Shipping ) || $item->get_order_id() !== $order->get_id() ) {
+				return new WP_Error( 'woocommerce_rest_line_item_not_found', __( 'Line item not found.', 'woocommerce' ), array( 'status' => 400 ) );
+			}
+
+			$signed_line_total = (float) $item->get_total() + (float) $item->get_total_tax();
+			if ( (float) $line['refund_total'] * $signed_line_total < 0 ) {
+				return new WP_Error( 'woocommerce_rest_invalid_refund_total', __( 'Refund total has the wrong sign for this line item.', 'woocommerce' ), array( 'status' => 400 ) );
+			}
+			if ( is_array( $line['refund_tax'] ?? null ) ) {
+				foreach ( $line['refund_tax'] as $tax_total ) {
+					if ( is_numeric( $tax_total ) && (float) $tax_total * $signed_line_total < 0 ) {
+						return new WP_Error( 'woocommerce_rest_invalid_refund_amount', __( 'Refund tax total has the wrong sign for this line item.', 'woocommerce' ), array( 'status' => 400 ) );
+					}
+				}
+			}
+			if ( 0.0 === $gross_refund_total ) {
+				continue;
+			}
+
+			if ( null === $refund_data ) {
+				$refund_data = $this->get_data_utils()->compute_refunded_quantities_and_totals( $order );
+			}
+			$validation_error = $this->get_data_utils()->validate_refund_line_total( $item, $gross_refund_total, (float) ( $refund_data['totals'][ $item_id ] ?? 0 ) );
+			if ( is_wp_error( $validation_error ) ) {
+				return $this->prefix_error_code( $validation_error );
+			}
+		}
+
+		if ( NumberUtil::round( $line_total, $price_decimals ) > NumberUtil::round( (float) $amount, $price_decimals ) ) {
+			$message = null === $amount
+				? __( 'Refund amount is required when it cannot be calculated from line items.', 'woocommerce' )
+				: __( 'Refund amount cannot be less than the total of line items.', 'woocommerce' );
+			return new WP_Error( 'woocommerce_rest_invalid_refund_amount', $message, array( 'status' => 400 ) );
+		}
+
+		return true;
+	}
+
 	/**
 	 * Create a refund with server-computed per-line totals (compute_totals mode).
 	 *
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 24ef62375e2..966a7be93ee 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
@@ -398,48 +398,10 @@ class DataUtils {
 					);
 				}

-				$item_total_with_tax = abs( $signed_line_total );
-				$abs_refund_total    = abs( $line_refund_gross );
-
-				// Mirror the preview path's three distinct over-refund errors (same
-				// codes, messages, and 422 status) so create and preview reject the
-				// same input identically. An over-refund is a well-formed but
-				// unprocessable request, so 422 — not 400 — is the correct status,
-				// matching the order-level cap the controller already returns.
-				if ( $abs_refund_total > NumberUtil::round( $item_total_with_tax, $price_decimals ) ) {
-					return new WP_Error(
-						'refund_total_exceeds_line',
-						sprintf(
-							/* translators: %s: line item total including tax */
-							__( 'refund_total cannot exceed the line item total including tax (%s).', 'woocommerce' ),
-							wc_format_decimal( $item_total_with_tax, $price_decimals )
-						),
-						array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
-					);
-				}
-
-				// Remaining is rounded to currency precision before both checks, so a
-				// sub-cent residue left by rounding drift counts as fully refunded
-				// rather than producing a "cannot exceed 0.00" rejection.
-				$refunded_total  = abs( (float) ( $refund_data['totals'][ $line_item_id ] ?? 0.0 ) );
-				$remaining_total = NumberUtil::round( $item_total_with_tax - $refunded_total, $price_decimals );
-				if ( $remaining_total <= 0 ) {
-					return new WP_Error(
-						'line_item_already_refunded',
-						__( 'This line item has already been fully refunded.', 'woocommerce' ),
-						array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
-					);
-				}
-				if ( $abs_refund_total > $remaining_total ) {
-					return new WP_Error(
-						'refund_total_exceeds_remaining',
-						sprintf(
-							/* translators: %s: remaining refundable amount */
-							__( 'refund_total cannot exceed the remaining refundable amount for this line item (%s).', 'woocommerce' ),
-							wc_format_decimal( $remaining_total, $price_decimals )
-						),
-						array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
-					);
+				$refunded_total   = (float) ( $refund_data['totals'][ $line_item_id ] ?? 0.0 );
+				$validation_error = $this->validate_refund_line_total( $item, $line_refund_gross, $refunded_total );
+				if ( is_wp_error( $validation_error ) ) {
+					return $validation_error;
 				}
 			}

@@ -523,6 +485,76 @@ class DataUtils {
 		return true;
 	}

+	/**
+	 * Check a line refund against the original line and previous refunds.
+	 *
+	 * Both refund paths use this limit. The computed path passes a tax-inclusive
+	 * amount; the explicit wc/v3 path passes its tax-inclusive gross amount.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param WC_Order_Item_Product|WC_Order_Item_Fee|WC_Order_Item_Shipping $item                  Original order item.
+	 * @param float                                                          $refund_total          Requested line item refund amount.
+	 * @param float                                                          $already_refunded_total Prior refunds for this item, including tax.
+	 * @return true|WP_Error Validation result.
+	 */
+	public function validate_refund_line_total( $item, float $refund_total, float $already_refunded_total ) {
+		$price_decimals      = wc_get_price_decimals();
+		$signed_line_total   = (float) $item->get_total() + (float) $item->get_total_tax();
+		$item_total_with_tax = abs( $signed_line_total );
+		$abs_refund_total    = NumberUtil::round( abs( $refund_total ), $price_decimals );
+
+		if ( $refund_total * $signed_line_total < 0 ) {
+			return new WP_Error(
+				'invalid_refund_total',
+				__( 'Refund total has the wrong sign for this line item.', 'woocommerce' ),
+				array( 'status' => WP_Http::BAD_REQUEST )
+			);
+		}
+
+		// Mirror the preview path's three distinct over-refund errors (same
+		// codes, messages, and 422 status) so create and preview reject the
+		// same input identically. An over-refund is a well-formed but
+		// unprocessable request, so 422 — not 400 — is the correct status,
+		// matching the order-level cap the controller already returns.
+		if ( $abs_refund_total > NumberUtil::round( $item_total_with_tax, $price_decimals ) ) {
+			return new WP_Error(
+				'refund_total_exceeds_line',
+				sprintf(
+					/* translators: %s: line item total including tax */
+					__( 'refund_total cannot exceed the line item total including tax (%s).', 'woocommerce' ),
+					wc_format_decimal( $item_total_with_tax, $price_decimals )
+				),
+				array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+			);
+		}
+
+		// Remaining is rounded to currency precision before both checks, so a
+		// sub-cent residue left by rounding drift counts as fully refunded
+		// rather than producing a "cannot exceed 0.00" rejection.
+		$remaining_total = NumberUtil::round( $item_total_with_tax - abs( $already_refunded_total ), $price_decimals );
+		if ( $remaining_total <= 0 ) {
+			return new WP_Error(
+				'line_item_already_refunded',
+				__( 'This line item has already been fully refunded.', 'woocommerce' ),
+				array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+			);
+		}
+		if ( $abs_refund_total > $remaining_total ) {
+			return new WP_Error(
+				'refund_total_exceeds_remaining',
+				sprintf(
+					/* translators: %s: remaining refundable amount */
+					__( 'refund_total cannot exceed the remaining refundable amount for this line item (%s).', 'woocommerce' ),
+					wc_format_decimal( $remaining_total, $price_decimals )
+				),
+				array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+			);
+		}
+
+		return true;
+	}
+
 	/**
 	 * Convert calculated taxes (internal format) to schema format.
 	 *
@@ -1145,7 +1177,7 @@ class DataUtils {
 				}

 				$item_total_with_tax = abs( $signed_line_total );
-				$abs_refund_total    = abs( $refund_total );
+				$abs_refund_total    = NumberUtil::round( abs( $refund_total ), $price_decimals );
 				if ( $abs_refund_total > NumberUtil::round( $item_total_with_tax, $price_decimals ) ) {
 					return new WP_Error(
 						'refund_total_exceeds_line',
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-computed-totals-test.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-computed-totals-test.php
index dff4c04b0ef..83d893edb51 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-computed-totals-test.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-computed-totals-test.php
@@ -832,6 +832,474 @@ class WC_REST_Order_Refunds_Computed_Totals_Test extends WC_REST_Unit_Test_Case
 		$this->assertEquals( '110.00', $response->get_data()['amount'] );
 	}

+	/**
+	 * @testdox A line with refund_tax but no refund_total is still checked against the refund amount and line sign.
+	 */
+	public function test_unflagged_tax_only_line_is_validated(): void {
+		$tax_rate_id = $this->create_tax_rate( 10.0 );
+		$order       = $this->create_order_with_product_and_tax( 100.00, 1, $tax_rate_id, 10.00 );
+		$item_id     = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'amount'     => '3',
+				'line_items' => array(
+					array(
+						'id'         => $item_id,
+						'refund_tax' => array(
+							array(
+								'id'           => $tax_rate_id,
+								'refund_total' => 50,
+							),
+						),
+					),
+				),
+			)
+		);
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertSame( 'Refund amount cannot be less than the total of line items.', $response->get_data()['message'] );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'amount'     => '1',
+				'line_items' => array(
+					array(
+						'id'         => $item_id,
+						'quantity'   => 1,
+						'refund_tax' => array(
+							array(
+								'id'           => $tax_rate_id,
+								'refund_total' => -40,
+							),
+						),
+					),
+				),
+			)
+		);
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertSame( 'Refund tax total has the wrong sign for this line item.', $response->get_data()['message'] );
+		$this->assertCount( 0, wc_get_order( $order->get_id() )->get_refunds() );
+	}
+
+	/**
+	 * @testdox A tax-only line without refund_total creates a refund for the requested amount.
+	 */
+	public function test_unflagged_tax_only_line_creates_refund(): void {
+		$tax_rate_id = $this->create_tax_rate( 10.0 );
+		$order       = $this->create_order_with_product_and_tax( 100.00, 1, $tax_rate_id, 10.00 );
+		$item_id     = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'amount'     => '4',
+				'line_items' => array(
+					array(
+						'id'         => $item_id,
+						'refund_tax' => array(
+							array(
+								'id'           => $tax_rate_id,
+								'refund_total' => 4,
+							),
+						),
+					),
+				),
+			)
+		);
+
+		$this->assertSame( 201, $response->get_status() );
+		$this->assertSame( '4.00', $response->get_data()['amount'] );
+		$refunds = wc_get_order( $order->get_id() )->get_refunds();
+		$this->assertCount( 1, $refunds );
+		$refund_items = array_values( $refunds[0]->get_items( 'line_item' ) );
+		$this->assertCount( 1, $refund_items );
+		$this->assertEquals( 0.0, (float) $refund_items[0]->get_total() );
+		$this->assertEquals( -4.0, (float) $refunds[0]->get_total_tax() );
+	}
+
+	/**
+	 * @testdox An omitted amount that cannot be calculated from every line requests an explicit amount.
+	 */
+	public function test_unflagged_missing_amount_with_incomplete_lines_is_required(): void {
+		$order   = $this->create_order_with_product( 100.00, 1 );
+		$item_id = $this->get_first_line_item_id( $order );
+		$fee     = new WC_Order_Item_Fee();
+		$fee->set_total( 10 );
+		$order->add_item( $fee );
+		$order->set_total( 110 );
+		$order->save();
+
+		$line_items = array(
+			array(
+				'id'           => $item_id,
+				'refund_total' => 20,
+			),
+			array(
+				'id'       => $fee->get_id(),
+				'quantity' => 1,
+			),
+		);
+		$response   = $this->do_create_request( $order->get_id(), array( 'line_items' => $line_items ) );
+
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertSame( 'woocommerce_rest_invalid_refund_amount', $response->get_data()['code'] );
+		$this->assertSame( 'Refund amount is required when it cannot be calculated from line items.', $response->get_data()['message'] );
+		$this->assertCount( 0, wc_get_order( $order->get_id() )->get_refunds() );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'amount'     => '5',
+				'line_items' => $line_items,
+			)
+		);
+
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertSame( 'woocommerce_rest_invalid_refund_amount', $response->get_data()['code'] );
+		$this->assertSame( 'Refund amount cannot be less than the total of line items.', $response->get_data()['message'] );
+		$this->assertCount( 0, wc_get_order( $order->get_id() )->get_refunds() );
+	}
+
+	/**
+	 * @testdox Without compute_totals a net refund plus tax exceeding the line gross is rejected.
+	 */
+	public function test_unflagged_net_plus_tax_exceeding_line_gross_is_rejected(): void {
+		$tax_rate_id = $this->create_tax_rate( 10.0 );
+		$order       = $this->create_order_with_product_and_tax( 100.00, 1, $tax_rate_id, 10.00 );
+		$item_id     = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'line_items' => array(
+					array(
+						'id'           => $item_id,
+						'quantity'     => 1,
+						'refund_total' => 105.00,
+						'refund_tax'   => array(
+							array(
+								'id'           => $tax_rate_id,
+								'refund_total' => 10.00,
+							),
+						),
+					),
+				),
+			)
+		);
+
+		$this->assertSame( 422, $response->get_status() );
+		$this->assertSame( 'woocommerce_rest_refund_total_exceeds_line', $response->get_data()['code'] );
+		$this->assertCount( 0, wc_get_order( $order->get_id() )->get_refunds() );
+	}
+
+	/**
+	 * @testdox A negative refund on a positive product cannot offset another product's refund.
+	 */
+	public function test_unflagged_negative_refund_on_positive_line_is_rejected(): void {
+		$order   = $this->create_order_with_product( 100.00, 1 );
+		$first   = $this->get_first_line_item_id( $order );
+		$product = WC_Helper_Product::create_simple_product();
+		$order->add_product(
+			$product,
+			1,
+			array(
+				'total'    => 100,
+				'subtotal' => 100,
+			)
+		);
+		$order->set_total( 200 );
+		$order->save();
+		$items  = $order->get_items( 'line_item' );
+		$second = array_key_last( $items );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'amount'     => '3',
+				'line_items' => array(
+					array(
+						'id'           => $first,
+						'refund_total' => 100,
+					),
+					array(
+						'id'           => $second,
+						'refund_total' => -97,
+					),
+				),
+			)
+		);
+
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertSame( 'woocommerce_rest_invalid_refund_total', $response->get_data()['code'] );
+		$this->assertCount( 0, wc_get_order( $order->get_id() )->get_refunds() );
+	}
+
+	/**
+	 * @testdox A negative tax entry cannot offset a positive line refund, even if the tax rate is absent from the line.
+	 * @dataProvider provide_lines_with_and_without_tax
+	 *
+	 * @param bool $taxed Whether the line has the requested tax rate.
+	 */
+	public function test_unflagged_opposite_sign_tax_is_rejected( bool $taxed ): void {
+		$tax_rate_id = $this->create_tax_rate( 10.0 );
+		$order       = $taxed
+			? $this->create_order_with_product_and_tax( 100.00, 1, $tax_rate_id, 10.00 )
+			: $this->create_order_with_product( 100.00, 1 );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'amount'     => '3',
+				'line_items' => array(
+					array(
+						'id'           => $this->get_first_line_item_id( $order ),
+						'refund_total' => 100,
+						'refund_tax'   => array(
+							array(
+								'id'           => $tax_rate_id,
+								'refund_total' => -97,
+							),
+						),
+					),
+				),
+			)
+		);
+
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertSame( 'woocommerce_rest_invalid_refund_amount', $response->get_data()['code'] );
+		$this->assertCount( 0, wc_get_order( $order->get_id() )->get_refunds() );
+	}
+
+	/**
+	 * Provide taxed and untaxed lines for the explicit refund tax validation test.
+	 *
+	 * @return array
+	 */
+	public static function provide_lines_with_and_without_tax(): array {
+		return array(
+			'taxed line'   => array( true ),
+			'untaxed line' => array( false ),
+		);
+	}
+
+	/**
+	 * @testdox A negative discount fee can be refunded alongside a positive product line.
+	 */
+	public function test_unflagged_negative_discount_fee_refund_succeeds(): void {
+		$order = $this->create_order_with_product_and_fee( 100.00, -10.00 );
+		$fee   = current( $order->get_items( 'fee' ) );
+		$item  = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'line_items' => array(
+					array(
+						'id'           => $item,
+						'refund_total' => 20,
+					),
+					array(
+						'id'           => $fee->get_id(),
+						'refund_total' => -5,
+					),
+				),
+			)
+		);
+
+		$this->assertSame( 201, $response->get_status() );
+		$this->assertSame( '15.00', $response->get_data()['amount'] );
+		$refunds = wc_get_order( $order->get_id() )->get_refunds();
+		$fees    = $refunds[0]->get_items( 'fee' );
+		$this->assertEquals( 5, current( $fees )->get_total() );
+	}
+
+	/**
+	 * @testdox A line item belonging to another order cannot be used to validate a refund.
+	 */
+	public function test_unflagged_foreign_order_item_is_rejected(): void {
+		$order   = $this->create_order_with_product( 20.00, 1 );
+		$foreign = $this->create_order_with_product( 20.00, 1 );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'amount'     => '5',
+				'line_items' => array(
+					array(
+						'id'           => $this->get_first_line_item_id( $foreign ),
+						'refund_total' => 5,
+					),
+				),
+			)
+		);
+
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertSame( 'woocommerce_rest_line_item_not_found', $response->get_data()['code'] );
+		$this->assertCount( 0, wc_get_order( $order->get_id() )->get_refunds() );
+	}
+
+	/**
+	 * @testdox Explicit fee and shipping refunds respect each line's original total.
+	 */
+	public function test_unflagged_fee_and_shipping_limits(): void {
+		$order = $this->create_order_with_product( 100.00, 1 );
+		$fee   = new WC_Order_Item_Fee();
+		$fee->set_total( 10 );
+		$order->add_item( $fee );
+		$shipping = new WC_Order_Item_Shipping();
+		$shipping->set_total( 5 );
+		$order->add_item( $shipping );
+		$order->set_total( 115 );
+		$order->save();
+
+		foreach ( array( $fee, $shipping ) as $item ) {
+			$response = $this->do_create_request(
+				$order->get_id(),
+				array(
+					'amount'     => '20',
+					'line_items' => array(
+						array(
+							'id'           => $item->get_id(),
+							'refund_total' => 20,
+						),
+					),
+				)
+			);
+			$this->assertSame( 422, $response->get_status() );
+			$this->assertSame( 'woocommerce_rest_refund_total_exceeds_line', $response->get_data()['code'] );
+		}
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'line_items' => array(
+					array(
+						'id'           => $fee->get_id(),
+						'refund_total' => 5,
+					),
+					array(
+						'id'           => $shipping->get_id(),
+						'refund_total' => 2,
+					),
+				),
+			)
+		);
+		$this->assertSame( 201, $response->get_status() );
+		$this->assertSame( '7.00', $response->get_data()['amount'] );
+		$this->assertCount( 1, wc_get_order( $order->get_id() )->get_refunds() );
+	}
+
+	/**
+	 * @testdox An already fully refunded line is rejected even when another line remains refundable.
+	 */
+	public function test_unflagged_fully_refunded_line_is_rejected(): void {
+		$order = $this->create_order_with_product_and_fee( 100.00, 10.00 );
+		$fee   = current( $order->get_items( 'fee' ) );
+		$first = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'amount'     => '10',
+				'line_items' => array(
+					array(
+						'id'           => $fee->get_id(),
+						'refund_total' => 10,
+					),
+				),
+			)
+		);
+		$this->assertSame( 201, $first->get_status() );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'amount'     => '1',
+				'line_items' => array(
+					array(
+						'id'           => $fee->get_id(),
+						'refund_total' => 1,
+					),
+				),
+			)
+		);
+		$this->assertSame( 422, $response->get_status() );
+		$this->assertSame( 'woocommerce_rest_line_item_already_refunded', $response->get_data()['code'] );
+		$this->assertCount( 1, wc_get_order( $order->get_id() )->get_refunds() );
+	}
+
+	/**
+	 * @testdox A full-line refund with higher-precision tax is accepted at currency precision.
+	 */
+	public function test_unflagged_high_precision_tax_full_refund(): void {
+		$previous = get_option( 'woocommerce_tax_round_at_subtotal' );
+		update_option( 'woocommerce_tax_round_at_subtotal', 'yes' );
+		try {
+			$rate_id  = $this->create_tax_rate( 8.333 );
+			$order    = $this->create_order_with_product_and_tax( 10.00, 1, $rate_id, 0.8333 );
+			$item_id  = $this->get_first_line_item_id( $order );
+			$response = $this->do_create_request(
+				$order->get_id(),
+				array(
+					'amount'     => '10.83',
+					'line_items' => array(
+						array(
+							'id'           => $item_id,
+							'refund_total' => 10,
+							'refund_tax'   => array(
+								array(
+									'id'           => $rate_id,
+									'refund_total' => 0.8333,
+								),
+							),
+						),
+					),
+				)
+			);
+			$this->assertSame( 201, $response->get_status() );
+			$this->assertSame( '10.83', $response->get_data()['amount'] );
+
+			$partial_order = $this->create_order_with_product_and_tax( 10.00, 1, $rate_id, 0.8333 );
+			$partial_id    = $this->get_first_line_item_id( $partial_order );
+			$first         = $this->do_create_request(
+				$partial_order->get_id(),
+				array(
+					'amount'     => '5',
+					'line_items' => array(
+						array(
+							'id'           => $partial_id,
+							'refund_total' => 5,
+						),
+					),
+				)
+			);
+			$this->assertSame( 201, $first->get_status() );
+
+			$remaining = $this->do_create_request(
+				$partial_order->get_id(),
+				array(
+					'amount'     => '5.83',
+					'line_items' => array(
+						array(
+							'id'           => $partial_id,
+							'refund_total' => 5,
+							'refund_tax'   => array(
+								array(
+									'id'           => $rate_id,
+									'refund_total' => 0.8333,
+								),
+							),
+						),
+					),
+				)
+			);
+			$this->assertSame( 201, $remaining->get_status() );
+			$this->assertCount( 2, wc_get_order( $partial_order->get_id() )->get_refunds() );
+		} finally {
+			update_option( 'woocommerce_tax_round_at_subtotal', $previous );
+		}
+	}
+
 	/**
 	 * @testdox The compute_totals parameter is declared in the create schema with a false default.
 	 */
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller-test.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller-test.php
index e5a35e1b1e3..0e8ec4e3843 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller-test.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller-test.php
@@ -85,6 +85,105 @@ class WC_REST_Order_Refunds_Controller_Test extends WC_REST_Unit_Test_Case {
 		$this->assertSame( array(), wc_get_order( $order->get_id() )->get_refunds() );
 	}

+	/**
+	 * @testdox Explicit item refunds reject amounts above the order line's paid total.
+	 */
+	public function test_explicit_refund_rejects_line_overage(): void {
+		wp_set_current_user( 1 );
+		$order   = WC_Helper_Order::create_order();
+		$item_id = current( $order->get_items( 'line_item' ) )->get_id();
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/orders/' . $order->get_id() . '/refunds' );
+		$request->set_body_params(
+			array(
+				'amount'     => '3',
+				'api_refund' => false,
+				'line_items' => array(
+					array(
+						'id'           => $item_id,
+						'quantity'     => 0,
+						'refund_total' => 30000,
+					),
+				),
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 422, $response->get_status() );
+		$this->assertSame( 'woocommerce_rest_refund_total_exceeds_line', $response->get_data()['code'] );
+		$this->assertCount( 0, wc_get_order( $order->get_id() )->get_refunds() );
+	}
+
+	/**
+	 * @testdox Explicit item refunds reject amounts exceeding the remaining balance after a partial refund.
+	 */
+	public function test_explicit_refund_rejects_overage_after_partial_refund(): void {
+		wp_set_current_user( 1 );
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( '100' );
+		$product->save();
+		$order = wc_create_order();
+		$order->add_product( $product, 1 );
+		$order->calculate_totals();
+		$order->save();
+		$item_id = current( $order->get_items( 'line_item' ) )->get_id();
+		$url     = '/wc/v3/orders/' . $order->get_id() . '/refunds';
+
+		foreach ( array( 50, 60 ) as $refund_total ) {
+			$request = new WP_REST_Request( 'POST', $url );
+			$request->set_body_params(
+				array(
+					'amount'     => (string) $refund_total,
+					'api_refund' => false,
+					'line_items' => array(
+						array(
+							'id'           => $item_id,
+							'quantity'     => 0,
+							'refund_total' => $refund_total,
+						),
+					),
+				)
+			);
+			$response = $this->server->dispatch( $request );
+			if ( 50 === $refund_total ) {
+				$this->assertSame( 201, $response->get_status(), 'The first partial refund should succeed.' );
+			}
+		}
+
+		$this->assertSame( 422, $response->get_status() );
+		$this->assertSame( 'woocommerce_rest_refund_total_exceeds_remaining', $response->get_data()['code'] );
+		$this->assertCount( 1, wc_get_order( $order->get_id() )->get_refunds() );
+	}
+
+	/**
+	 * @testdox Explicit item refunds reject allocations above their stated order refund amount.
+	 */
+	public function test_explicit_refund_rejects_amount_below_item_total(): void {
+		wp_set_current_user( 1 );
+		$order   = WC_Helper_Order::create_order();
+		$item_id = current( $order->get_items( 'line_item' ) )->get_id();
+		$request = new WP_REST_Request( 'POST', '/wc/v3/orders/' . $order->get_id() . '/refunds' );
+		$request->set_body_params(
+			array(
+				'amount'     => '3',
+				'api_refund' => false,
+				'line_items' => array(
+					array(
+						'id'           => $item_id,
+						'refund_total' => 4,
+					),
+				),
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertSame( 'woocommerce_rest_invalid_refund_amount', $response->get_data()['code'] );
+		$this->assertCount( 0, wc_get_order( $order->get_id() )->get_refunds() );
+	}
+
 	/**
 	 * Test if line, fees and shipping items are all included in refund response.
 	 */