Commit 204a8e635b6 for woocommerce

commit 204a8e635b6873354ef2ad89394a440d7f054563
Author: Mike Jolley <mike.jolley@me.com>
Date:   Mon Oct 26 16:32:36 2026 +0000

    Prevent refunding more units of an order item than are left to refund (#70130)

    * Reject refunds of more units than are left to refund

    The admin refund request only clamped quantities to zero, so a typed value
    or a repeated partial refund could refund and restock more units than were
    ordered. Check each quantity against the units not yet refunded before the
    refund is created, cap the refund input's max to match, and return an error
    instead of a fatal when the order does not exist.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Add changelog entry for the refund quantity limit

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Handle fractional quantities in the refund quantity limit

    Stores can allow decimal quantities through the woocommerce_stock_amount
    filter. Round the remaining refundable quantity so float noise does not
    reject refunding the exact remainder, show it in the error with %s
    instead of truncating via %d, and keep decimals in the input's max.

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

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/31738-refund-quantity-limit b/plugins/woocommerce/changelog/31738-refund-quantity-limit
new file mode 100644
index 00000000000..663f311f279
--- /dev/null
+++ b/plugins/woocommerce/changelog/31738-refund-quantity-limit
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Stop order refunds from refunding or restocking more units of an item than are left to refund.
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-item.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-item.php
index da335517392..8d97f893c8f 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-item.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-item.php
@@ -11,6 +11,7 @@ defined( 'ABSPATH' ) || exit;

 use Automattic\WooCommerce\Internal\Admin\Orders\ItemQuantityLimits;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController;
+use Automattic\WooCommerce\Utilities\NumberUtil;

 $product      = $item->get_product();
 $product_link = $product ? admin_url( 'post.php?post=' . $item->get_product_id() . '&action=edit' ) : '';
@@ -134,7 +135,7 @@ $item_name = apply_filters( 'woocommerce_order_item_name', $item->get_name(), $i
 			<input type="number" step="<?php echo esc_attr( $step_edit ); ?>" min="<?php echo esc_attr( $min_edit ); ?>" autocomplete="off" name="order_item_qty[<?php echo absint( $item_id ); ?>]" placeholder="0" value="<?php echo esc_attr( $item->get_quantity() ); ?>" data-qty="<?php echo esc_attr( $item->get_quantity() ); ?>" size="4" class="quantity" />
 		</div>
 		<div class="refund" style="display: none;">
-			<input type="number" step="<?php echo esc_attr( $step_refund ); ?>" min="<?php echo esc_attr( $min_refund ); ?>" max="<?php echo absint( $item->get_quantity() ); ?>" autocomplete="off" name="refund_order_item_qty[<?php echo absint( $item_id ); ?>]" placeholder="0" size="4" class="refund_order_item_qty" />
+			<input type="number" step="<?php echo esc_attr( $step_refund ); ?>" min="<?php echo esc_attr( $min_refund ); ?>" max="<?php echo esc_attr( (string) max( 0, NumberUtil::round( abs( $item->get_quantity() ) - abs( $refunded_qty ), WC_ROUNDING_PRECISION ) ) ); ?>" autocomplete="off" name="refund_order_item_qty[<?php echo absint( $item_id ); ?>]" placeholder="0" size="4" class="refund_order_item_qty" />
 		</div>
 	</td>
 	<td class="line_cost" width="1%" data-sort-value="<?php echo esc_attr( $item->get_total() ); ?>">
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index 5d22cf610de..8de169c384f 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -2951,7 +2951,12 @@ class WC_AJAX {
 		$response               = array();

 		try {
-			$order      = wc_get_order( $order_id );
+			$order = wc_get_order( $order_id );
+
+			if ( ! $order instanceof WC_Order ) {
+				throw new Exception( __( 'Invalid order ID.', 'woocommerce' ) );
+			}
+
 			$max_refund = wc_format_decimal( $order->get_total() - $order->get_total_refunded(), wc_get_price_decimals() );

 			if ( ( ! $refund_amount && ( wc_format_decimal( 0, wc_get_price_decimals() ) !== $refund_amount ) ) || $max_refund < $refund_amount || 0 > $refund_amount ) {
@@ -2973,8 +2978,27 @@ class WC_AJAX {
 					'refund_tax'   => array(),
 				);
 			}
+			$order_items = $order->get_items();
 			foreach ( $line_item_qtys as $item_id => $qty ) {
-				$line_items[ $item_id ]['qty'] = max( $qty, 0 );
+				$qty = max( $qty, 0 );
+
+				// Enforce the refund input's max here, because this AJAX request skips browser validation.
+				// Rounding drops float noise when the woocommerce_stock_amount filter allows fractional quantities.
+				if ( $qty > 0 && isset( $order_items[ $item_id ] ) ) {
+					$remaining_qty = NumberUtil::round( abs( $order_items[ $item_id ]->get_quantity() ) - abs( $order->get_qty_refunded_for_item( $item_id ) ), WC_ROUNDING_PRECISION );
+
+					if ( $qty > $remaining_qty ) {
+						throw new Exception(
+							sprintf(
+								/* translators: %s: remaining refundable quantity */
+								__( 'Line item quantity cannot be greater than the remaining refundable quantity (%s).', 'woocommerce' ),
+								$remaining_qty
+							)
+						);
+					}
+				}
+
+				$line_items[ $item_id ]['qty'] = $qty;
 			}
 			foreach ( $line_item_totals as $item_id => $total ) {
 				$line_items[ $item_id ]['refund_total'] = wc_format_decimal( $total );
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index c224f21eb28..c6cc362c421 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -8454,18 +8454,6 @@ parameters:
 			count: 1
 			path: includes/class-wc-ajax.php

-		-
-			message: '#^Cannot call method get_total\(\) on WC_Order\|WC_Order_Refund\|false\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/class-wc-ajax.php
-
-		-
-			message: '#^Cannot call method get_total_refunded\(\) on WC_Order\|WC_Order_Refund\|false\.$#'
-			identifier: method.nonObject
-			count: 2
-			path: includes/class-wc-ajax.php
-
 		-
 			message: '#^Cannot call method get_zone_name\(\) on bool\|WC_Shipping_Zone\.$#'
 			identifier: method.nonObject
diff --git a/plugins/woocommerce/tests/php/includes/admin/meta-boxes/views/html-order-items-test.php b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/views/html-order-items-test.php
index 1ce5812e17d..0193c1aeb4d 100644
--- a/plugins/woocommerce/tests/php/includes/admin/meta-boxes/views/html-order-items-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/views/html-order-items-test.php
@@ -86,6 +86,80 @@ class WC_Admin_Meta_Box_Order_Items_View_Test extends WC_Unit_Test_Case {
 		$this->assertSame( '$0.00', trim( $refunded_nodes->item( 0 )->textContent ), 'A zero refunded total should render without a minus sign.' );
 	}

+	/**
+	 * @testdox The refund quantity input's max excludes units that were already refunded.
+	 */
+	public function test_refund_quantity_max_excludes_refunded_units(): void {
+		$order   = wc_create_order();
+		$item_id = $order->add_product( WC_Helper_Product::create_simple_product(), 3 );
+		$order->calculate_totals();
+		$order->save();
+
+		wc_create_refund(
+			array(
+				'amount'     => 10,
+				'order_id'   => $order->get_id(),
+				'line_items' => array(
+					$item_id => array(
+						'qty'          => 1,
+						'refund_total' => 10,
+					),
+				),
+			)
+		);
+		$order = wc_get_order( $order->get_id() );
+
+		ob_start();
+		include WC_ABSPATH . 'includes/admin/meta-boxes/views/html-order-items.php';
+		$order_items_html = ob_get_clean();
+
+		$qty_inputs = $this->get_xpath_for_html( $order_items_html )->query( "//input[@name='refund_order_item_qty[{$item_id}]']" );
+
+		$this->assertSame( 1, $qty_inputs->length, 'The line item should have one refund quantity input.' );
+		$this->assertSame( '2', $qty_inputs->item( 0 )->getAttribute( 'max' ), 'The max should be the 3 ordered units less the 1 already refunded.' );
+	}
+
+	/**
+	 * @testdox The refund quantity input's max keeps fractional remainders when stock amounts allow decimals.
+	 */
+	public function test_refund_quantity_max_keeps_fractional_remainder(): void {
+		remove_filter( 'woocommerce_stock_amount', 'intval' );
+		add_filter( 'woocommerce_stock_amount', 'floatval' );
+
+		try {
+			$order   = wc_create_order();
+			$item_id = $order->add_product( WC_Helper_Product::create_simple_product(), 2.3 );
+			$order->calculate_totals();
+			$order->save();
+
+			wc_create_refund(
+				array(
+					'amount'     => 1,
+					'order_id'   => $order->get_id(),
+					'line_items' => array(
+						$item_id => array(
+							'qty'          => 0.1,
+							'refund_total' => 1,
+						),
+					),
+				)
+			);
+			$order = wc_get_order( $order->get_id() );
+
+			ob_start();
+			include WC_ABSPATH . 'includes/admin/meta-boxes/views/html-order-items.php';
+			$order_items_html = ob_get_clean();
+		} finally {
+			remove_filter( 'woocommerce_stock_amount', 'floatval' );
+			add_filter( 'woocommerce_stock_amount', 'intval' );
+		}
+
+		$qty_inputs = $this->get_xpath_for_html( $order_items_html )->query( "//input[@name='refund_order_item_qty[{$item_id}]']" );
+
+		$this->assertSame( 1, $qty_inputs->length, 'The line item should have one refund quantity input.' );
+		$this->assertSame( '2.2', $qty_inputs->item( 0 )->getAttribute( 'max' ), 'The max should be the 2.3 ordered units less the 0.1 already refunded.' );
+	}
+
 	/**
 	 * Parses an HTML fragment and returns an XPath object for it.
 	 *
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
index 5d6a4b427a3..1734ac62ff5 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
@@ -2877,6 +2877,147 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
 		unset( $_POST['security'], $_POST['order_id'], $_POST['refund_amount'], $_POST['refunded_amount'], $_POST['refund_reason'], $_POST['line_item_qtys'], $_POST['line_item_totals'], $_POST['line_item_tax_totals'], $_POST['api_refund'] );
 	}

+	/**
+	 * @testdox Refunding more units of a line item than were ordered is rejected and creates no refund.
+	 */
+	public function test_refund_line_items_rejects_quantity_above_ordered_quantity(): void {
+		$order   = $this->create_order_for_refund_quantity_test( 1 );
+		$item_id = array_keys( $order->get_items() )[0];
+
+		$response = $this->do_refund_line_items_request( $order, $item_id, 2, '20' );
+
+		$this->assertFalse( $response['success'] ?? true, 'The AJAX refund request should fail.' );
+		$this->assertSame( 'Line item quantity cannot be greater than the remaining refundable quantity (1).', $response['data']['error'] ?? '', 'The error should state the remaining refundable quantity.' );
+		$this->assertCount( 0, wc_get_order( $order->get_id() )->get_refunds(), 'No refund should be created.' );
+	}
+
+	/**
+	 * @testdox A refund that would exceed the quantity left after earlier refunds is rejected.
+	 */
+	public function test_refund_line_items_rejects_quantity_above_remaining_quantity(): void {
+		$order   = $this->create_order_for_refund_quantity_test( 2 );
+		$item_id = array_keys( $order->get_items() )[0];
+
+		$response = $this->do_refund_line_items_request( $order, $item_id, 1, '10' );
+		$this->assertTrue( $response['success'] ?? false, 'The first refund of 1 unit should succeed.' );
+
+		$response = $this->do_refund_line_items_request( wc_get_order( $order->get_id() ), $item_id, 2, '20' );
+
+		$this->assertFalse( $response['success'] ?? true, 'The second refund of 2 units should fail.' );
+		$this->assertSame( 'Line item quantity cannot be greater than the remaining refundable quantity (1).', $response['data']['error'] ?? '', 'The error should state the remaining refundable quantity.' );
+
+		$order = wc_get_order( $order->get_id() );
+		$this->assertCount( 1, $order->get_refunds(), 'Only the first refund should exist.' );
+		$this->assertSame( -1, $order->get_qty_refunded_for_item( $item_id ), 'Only 1 unit should be refunded.' );
+	}
+
+	/**
+	 * @testdox Refunding the units left after an earlier partial refund still works.
+	 */
+	public function test_refund_line_items_allows_refunding_remaining_quantity(): void {
+		$order   = $this->create_order_for_refund_quantity_test( 2 );
+		$item_id = array_keys( $order->get_items() )[0];
+
+		$this->do_refund_line_items_request( $order, $item_id, 1, '10' );
+		$response = $this->do_refund_line_items_request( wc_get_order( $order->get_id() ), $item_id, 1, '10' );
+
+		$this->assertTrue( $response['success'] ?? false, 'Refunding the last remaining unit should succeed.' );
+
+		$order = wc_get_order( $order->get_id() );
+		$this->assertCount( 2, $order->get_refunds(), 'Both refunds should exist.' );
+		$this->assertSame( -2, $order->get_qty_refunded_for_item( $item_id ), 'Both units should be refunded.' );
+	}
+
+	/**
+	 * @testdox Fractional quantities are checked against the exact fractional remainder when stock amounts allow decimals.
+	 */
+	public function test_refund_line_items_checks_fractional_remaining_quantity(): void {
+		remove_filter( 'woocommerce_stock_amount', 'intval' );
+		add_filter( 'woocommerce_stock_amount', 'floatval' );
+
+		try {
+			$order   = $this->create_order_for_refund_quantity_test( 2.3 );
+			$item_id = array_keys( $order->get_items() )[0];
+
+			$response = $this->do_refund_line_items_request( $order, $item_id, 0.1, '1' );
+			$this->assertTrue( $response['success'] ?? false, 'Refunding 0.1 units should succeed.' );
+
+			$response = $this->do_refund_line_items_request( wc_get_order( $order->get_id() ), $item_id, 2.3, '23' );
+			$this->assertFalse( $response['success'] ?? true, 'Refunding more than the 2.2 units left should fail.' );
+			$this->assertSame( 'Line item quantity cannot be greater than the remaining refundable quantity (2.2).', $response['data']['error'] ?? '', 'The error should state the fractional remaining quantity.' );
+
+			$response = $this->do_refund_line_items_request( wc_get_order( $order->get_id() ), $item_id, 2.2, '22' );
+			$this->assertTrue( $response['success'] ?? false, 'Refunding exactly the 2.2 units left should succeed despite float rounding in 2.3 - 0.1.' );
+		} finally {
+			remove_filter( 'woocommerce_stock_amount', 'floatval' );
+			add_filter( 'woocommerce_stock_amount', 'intval' );
+		}
+	}
+
+	/**
+	 * @testdox Refunding an order that does not exist returns an error instead of a fatal.
+	 */
+	public function test_refund_line_items_rejects_missing_order(): void {
+		$order   = $this->create_order_for_refund_quantity_test( 1 );
+		$item_id = array_keys( $order->get_items() )[0];
+		$order->delete( true );
+
+		$response = $this->do_refund_line_items_request( $order, $item_id, 1, '10' );
+
+		$this->assertFalse( $response['success'] ?? true, 'The AJAX refund request should fail.' );
+		$this->assertSame( 'Invalid order ID.', $response['data']['error'] ?? '', 'The error should say the order ID is invalid.' );
+	}
+
+	/**
+	 * Creates an order with a $10 product at the given quantity plus a $100 product, so the
+	 * refund amount limit never blocks a refund before the quantity check does.
+	 *
+	 * @param int|float $quantity Quantity of the first (cheaper) product.
+	 * @return WC_Order
+	 */
+	private function create_order_for_refund_quantity_test( $quantity ): WC_Order {
+		$expensive_product = WC_Helper_Product::create_simple_product();
+		$expensive_product->set_regular_price( 100 );
+		$expensive_product->save();
+
+		$order = new WC_Order();
+		$order->add_product( WC_Helper_Product::create_simple_product(), $quantity );
+		$order->add_product( $expensive_product, 1 );
+		$order->calculate_totals();
+		$order->save();
+
+		return $order;
+	}
+
+	/**
+	 * Posts a single line item refund through the admin AJAX handler.
+	 *
+	 * @param WC_Order  $order   Order to refund.
+	 * @param int       $item_id Order item ID to refund.
+	 * @param int|float $qty     Quantity to refund.
+	 * @param string    $amount  Amount to refund, used for both the line and the refund total.
+	 * @return array|null Decoded JSON response.
+	 */
+	private function do_refund_line_items_request( WC_Order $order, int $item_id, $qty, string $amount ): ?array {
+		$this->_setRole( 'administrator' );
+
+		$_POST['security']             = wp_create_nonce( 'order-item' );
+		$_POST['order_id']             = $order->get_id();
+		$_POST['refund_amount']        = $amount;
+		$_POST['refunded_amount']      = (string) $order->get_total_refunded();
+		$_POST['refund_reason']        = '';
+		$_POST['line_item_qtys']       = wp_json_encode( array( $item_id => $qty ) );
+		$_POST['line_item_totals']     = wp_json_encode( array( $item_id => $amount ) );
+		$_POST['line_item_tax_totals'] = wp_json_encode( array() );
+		$_POST['api_refund']           = 'false';
+
+		try {
+			return $this->do_ajax( 'woocommerce_refund_line_items' );
+		} finally {
+			unset( $_POST['security'], $_POST['order_id'], $_POST['refund_amount'], $_POST['refunded_amount'], $_POST['refund_reason'], $_POST['line_item_qtys'], $_POST['line_item_totals'], $_POST['line_item_tax_totals'], $_POST['api_refund'] );
+		}
+	}
+
 	/**
 	 * The ?wc-ajax=get_variation endpoint renders the matched variation's description through
 	 * wc_format_content(), which fires the woocommerce_short_description filter. Eager block registration is