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