Commit e171986872f for woocommerce
commit e171986872f4f707378e42d4b9285cd87b400203
Author: Samuel Urbanowicz <samiuelson@gmail.com>
Date: Mon Aug 17 11:30:38 2026 +0200
Return 400 instead of 500 for status-less engine errors on the wc/v3 refunds compute_totals path (#67623)
* Return 400 instead of 500 for status-less engine errors in v3 refunds
Five validation errors in DataUtils::validate_line_items carried no HTTP
status in their error data. The wc/v4 envelope backfills 400 for these,
but the wc/v3 boundary forwards error data verbatim, so WordPress served
them as HTTP 500 on the compute_totals create path. Add the missing
status at the five engine sites and default status-less engine errors to
400 in prefix_error_code, mirroring the v4 envelope.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Address review feedback on v3 refunds error status
Documents the two prefix_error_code() cases raised in review and adds the
missing v3 regression test. No behavior change: the released classic v3
create path does not route through prefix_error_code(), and 400 is what
the wc/v4 envelope already backfills at every patched engine site.
- Scope the prefix_error_code() docblock to unprefixed engine codes, and
record why the already-prefixed early return leaves no gap: every
prefixed error reaching this endpoint comes from normalize_line_item()
with an explicit status.
- Note that replacing non-array error data rather than nesting it mirrors
the wc/v4 envelope, which also reads a status only out of array data.
- Explain why the tax over-refund cap returns 400 where the caps above it
return 422, so it is not re-litigated against the convention documented
earlier in the method.
- Add test_zero_source_quantity_auto_compute_returns_400, covering the
fifth patched engine site through POST /wc/v3/orders/<id>/refunds. It
was covered at unit level and via wc/v4, but not through v3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHyS5bLDqHtHVisnR8ueVa
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-v3-refunds-error-status b/plugins/woocommerce/changelog/fix-v3-refunds-error-status
new file mode 100644
index 00000000000..91f27edd11d
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-v3-refunds-error-status
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Return HTTP 400 instead of 500 for invalid refund_tax and auto-compute inputs on the wc/v3 refunds compute_totals path, by adding the missing HTTP status to five shared-engine validation errors and defaulting status-less engine errors to 400 at the v3 boundary.
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 6ab3b62eb3f..a3b16fefff6 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
@@ -349,6 +349,11 @@ class WC_REST_Order_Refunds_Controller extends WC_REST_Order_Refunds_V2_Controll
* uses `woocommerce_rest_*`, so errors crossing into a v3 response are
* renamed at this boundary. Codes that already carry the prefix pass through
* unchanged, and the message and data (including the HTTP status) are kept.
+ * An unprefixed engine error whose data carries no HTTP status is backfilled
+ * with 400, the same default the wc/v4 envelope applies, so it is not served
+ * as a 500. An already-prefixed code returns untouched above and so misses
+ * that backfill, which leaves no gap: every prefixed error reaching this
+ * endpoint is built by normalize_line_item() with an explicit status.
*
* @param WP_Error $error The error whose code should be prefixed.
*
@@ -361,7 +366,20 @@ class WC_REST_Order_Refunds_Controller extends WC_REST_Order_Refunds_V2_Controll
return $error;
}
- return new WP_Error( 'woocommerce_rest_' . $code, $error->get_error_message(), $error->get_error_data() );
+ // Every DataUtils error site attaches array data; the guard below is here
+ // so a non-array payload cannot turn the $data['status'] write into a
+ // fatal. Replacing such a payload rather than nesting it mirrors the wc/v4
+ // envelope, which likewise reads a status only out of array data and drops
+ // the rest, so both versions answer an identical error identically.
+ $data = $error->get_error_data();
+ if ( ! is_array( $data ) ) {
+ $data = array();
+ }
+ if ( ! isset( $data['status'] ) ) {
+ $data['status'] = 400;
+ }
+
+ return new WP_Error( 'woocommerce_rest_' . $code, $error->get_error_message(), $data );
}
/**
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 f73dc537dc3..24ef62375e2 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
@@ -262,7 +262,8 @@ class DataUtils {
if ( $refund_total_missing && isset( $line_item['refund_tax'] ) ) {
return new WP_Error(
'invalid_line_item',
- __( 'refund_tax cannot be combined with an auto-computed refund_total. Provide refund_total explicitly when supplying refund_tax.', 'woocommerce' )
+ __( 'refund_tax cannot be combined with an auto-computed refund_total. Provide refund_total explicitly when supplying refund_tax.', 'woocommerce' ),
+ array( 'status' => WP_Http::BAD_REQUEST )
);
}
@@ -299,7 +300,8 @@ class DataUtils {
/* translators: %d: line item id */
__( 'Cannot auto-compute refund for line item %d: source quantity is zero. Provide an explicit refund_total.', 'woocommerce' ),
(int) $line_item_id
- )
+ ),
+ array( 'status' => WP_Http::BAD_REQUEST )
);
}
@@ -449,7 +451,7 @@ class DataUtils {
foreach ( $line_item['refund_tax'] as $refund_tax ) {
if ( ! isset( $refund_tax['id'], $refund_tax['refund_total'] ) ) {
- return new WP_Error( 'invalid_line_item', __( 'Tax id and refund_total are required.', 'woocommerce' ) );
+ return new WP_Error( 'invalid_line_item', __( 'Tax id and refund_total are required.', 'woocommerce' ), array( 'status' => WP_Http::BAD_REQUEST ) );
}
$tax_id = $refund_tax['id'];
$tax_refund_total = $refund_tax['refund_total'];
@@ -461,7 +463,8 @@ class DataUtils {
/* translators: %s: tax IDs */
__( 'Line item tax not found. Must be: %s.', 'woocommerce' ),
implode( ', ', $allowed_tax_ids )
- )
+ ),
+ array( 'status' => WP_Http::BAD_REQUEST )
);
}
@@ -495,13 +498,21 @@ class DataUtils {
$already_refunded_tax = (float) ( $refund_data['tax_totals'][ $line_item_id ][ $tax_id ] ?? 0.0 );
$remaining_tax = abs( $stored_tax ) - $already_refunded_tax;
if ( abs( $requested_tax ) > NumberUtil::round( $remaining_tax, $price_decimals ) ) {
+ // 400, not the 422 the over-refund caps above use: this is the
+ // status the released wc/v4 envelope already backfills for this
+ // error, so anything else would change a shipped response. It
+ // also keeps the code-to-status mapping one-to-one across this
+ // file — invalid_refund_amount is 400 at every site, sharing the
+ // code with the wrong-sign guard above, which is malformed
+ // input, while each 422 carries its own over-refund code.
return new WP_Error(
'invalid_refund_amount',
sprintf(
/* translators: %s: remaining refundable tax total */
__( 'Refund tax total cannot be greater than the remaining refundable tax for this line item (%s).', 'woocommerce' ),
wc_format_decimal( $remaining_tax, $price_decimals )
- )
+ ),
+ array( 'status' => WP_Http::BAD_REQUEST )
);
}
}
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 9ac38484dcc..dff4c04b0ef 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
@@ -265,6 +265,172 @@ class WC_REST_Order_Refunds_Computed_Totals_Test extends WC_REST_Unit_Test_Case
$this->assertCount( 0, wc_get_order( $order->get_id() )->get_refunds(), 'A refund with inconsistent tax accounting must never be created.' );
}
+ /**
+ * @testdox refund_tax combined with an auto-computed refund_total returns 400, not 500.
+ */
+ public function test_refund_tax_with_auto_computed_total_returns_400(): 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(
+ 'compute_totals' => true,
+ 'line_items' => array(
+ array(
+ 'id' => $item_id,
+ 'quantity' => 1,
+ 'refund_tax' => array(
+ array(
+ 'id' => $tax_rate_id,
+ 'refund_total' => 5.00,
+ ),
+ ),
+ ),
+ ),
+ )
+ );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $this->assertEquals( 'woocommerce_rest_invalid_line_item', $response->get_data()['code'] );
+ $this->assertCount( 0, wc_get_order( $order->get_id() )->get_refunds() );
+ }
+
+ /**
+ * @testdox A refund_tax entry referencing a tax id not on the line returns 400, not 500.
+ */
+ public function test_refund_tax_unknown_tax_id_returns_400(): 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(
+ 'compute_totals' => true,
+ 'line_items' => array(
+ array(
+ 'id' => $item_id,
+ 'refund_total' => 50.00,
+ 'refund_tax' => array(
+ array(
+ 'id' => $tax_rate_id + 999,
+ 'refund_total' => 5.00,
+ ),
+ ),
+ ),
+ ),
+ )
+ );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $this->assertEquals( 'woocommerce_rest_invalid_line_item', $response->get_data()['code'] );
+ $this->assertCount( 0, wc_get_order( $order->get_id() )->get_refunds() );
+ }
+
+ /**
+ * @testdox A refund_tax entry missing id or refund_total returns 400, not 500.
+ */
+ public function test_refund_tax_missing_fields_returns_400(): 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(
+ 'compute_totals' => true,
+ 'line_items' => array(
+ array(
+ 'id' => $item_id,
+ 'refund_total' => 50.00,
+ 'refund_tax' => array(
+ array( 'id' => $tax_rate_id ),
+ ),
+ ),
+ ),
+ )
+ );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $this->assertEquals( 'woocommerce_rest_invalid_line_item', $response->get_data()['code'] );
+ $this->assertCount( 0, wc_get_order( $order->get_id() )->get_refunds() );
+ }
+
+ /**
+ * @testdox A refund_tax total exceeding the remaining refundable tax returns 400, not 500.
+ */
+ public function test_refund_tax_exceeding_remaining_tax_returns_400(): 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(
+ 'compute_totals' => true,
+ 'line_items' => array(
+ array(
+ 'id' => $item_id,
+ 'refund_total' => 50.00,
+ 'refund_tax' => array(
+ array(
+ 'id' => $tax_rate_id,
+ 'refund_total' => 50.00,
+ ),
+ ),
+ ),
+ ),
+ )
+ );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $this->assertEquals( 'woocommerce_rest_invalid_refund_amount', $response->get_data()['code'] );
+ $this->assertCount( 0, wc_get_order( $order->get_id() )->get_refunds() );
+ }
+
+ /**
+ * @testdox Auto-computing a refund against a line whose source quantity is zero returns 400, not 500.
+ */
+ public function test_zero_source_quantity_auto_compute_returns_400(): void {
+ // create_order_with_product() cannot build a zero-quantity line, so the order
+ // is assembled here. The order total is non-zero so the order is not treated
+ // as already fully refunded, which would fail earlier with a different error.
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'quantity' => 0,
+ 'subtotal' => 0,
+ 'total' => 0,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->set_total( 10.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $response = $this->do_create_request(
+ $order->get_id(),
+ array(
+ 'compute_totals' => true,
+ 'line_items' => array(
+ array(
+ 'id' => $item->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ )
+ );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $this->assertEquals( 'woocommerce_rest_invalid_line_item', $response->get_data()['code'] );
+ $this->assertStringContainsString( 'source quantity is zero', $response->get_data()['message'] );
+ $this->assertCount( 0, wc_get_order( $order->get_id() )->get_refunds() );
+ }
+
/**
* @testdox A quantity-form refund after a partial amount refund is clamped to the line's remaining refundable amount.
*/