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