Commit 38e9b5a7b4c for woocommerce

commit 38e9b5a7b4ce30214a9625db093e268964d0f8c8
Author: Raluca Stan <ralucastn@gmail.com>
Date:   Fri Aug 28 15:37:50 2026 +0200

    Fix negative quantity validation in the admin order editor (#67921)

    * Pass an int customer note flag in the order item AJAX notes

    * Narrow the order item AJAX order guards to WC_Order

    * Send the add order item success response outside the try block

    * Decode the add order item exception messages for the JS alert

    * Add ItemQuantityLimits with admin order item quantity rules

    * Reject invalid quantities in the order item AJAX endpoints

    * Floor the order item quantity input minimum at the stored quantity

    * Honour the add context minimum in the add products modal input

    * Add a veto event before the backbone modal response

    * Validate quantity minimums in the order items panel and modal

    * Add e2e coverage for order item quantity validation

    * Add changelog entry

    * Make the backbone modal veto event cancelable

    Use a jQuery $.Event and preventDefault()/isDefaultPrevented() instead
    of a mutable { valid: true } object, so cancellation is idiomatic and
    cannot be undone by a later listener.

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Validate the implicit default quantity in the add products modal

    A blank item_qty is sent as 1 by the response handler, but a blank
    number input has no rangeUnderflow, so a filtered minimum above 1 was
    only caught server side, after the modal closed and the selection was
    lost. Fill blank inputs with the implicit 1 before the range check so
    the browser reports it and the modal stays open.

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Render the add products modal quantity minimum unfiltered

    Applying woocommerce_quantity_input_min_admin at modal render time
    passed false for the documented WC_Product parameter on every order
    edit screen, fataling any callback that type-declares it. No product is
    selected at render time, so the filter cannot answer meaningfully
    there; the add context is enforced server side, where a real product is
    always available.

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Revert "Validate the implicit default quantity in the add products modal"

    This reverts commit 1efe9f398208057339bd28daed36f00f7e6bd3d5.

    The normalization guarded blank quantities against a filtered minimum
    rendered on the modal input. That render-time filter call was removed
    (it passed false for the documented WC_Product parameter), so the modal
    minimum is always the hardcoded 0 and the implicit 1 can never be below
    it: the code had no reachable trigger left, and its e2e test could only
    simulate one. If a rendered or per-product minimum is introduced later,
    this commit shows the client-side handling to bring back with it.

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-31068-order-item-qty-validation b/plugins/woocommerce/changelog/fix-31068-order-item-qty-validation
new file mode 100644
index 00000000000..02554704ebd
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-31068-order-item-qty-validation
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Show the browser validation message for negative order item quantities in the admin order editor instead of silently failing, and reject them server-side in the order items AJAX endpoints.
diff --git a/plugins/woocommerce/client/legacy/js/admin/backbone-modal.js b/plugins/woocommerce/client/legacy/js/admin/backbone-modal.js
index 85aa0d3f336..8663680cb8d 100644
--- a/plugins/woocommerce/client/legacy/js/admin/backbone-modal.js
+++ b/plugins/woocommerce/client/legacy/js/admin/backbone-modal.js
@@ -109,6 +109,16 @@
 			$( document.body ).trigger( 'wc_backbone_modal_removed', this._target );
 		},
 		addButton: function( e ) {
+			// Allow listeners to cancel the response via event.preventDefault(),
+			// e.g. to validate inputs and keep the modal open. Covers click,
+			// touch and keyboard paths.
+			var beforeResponse = $.Event( 'wc_backbone_modal_before_response' );
+			$( document.body ).trigger( beforeResponse, [ this._target, this.$el ] );
+
+			if ( beforeResponse.isDefaultPrevented() ) {
+				return;
+			}
+
 			$( document.body ).trigger( 'wc_backbone_modal_response', [ this._target, this.getFormData() ] );
 			this.closeButton( e, true );
 		},
diff --git a/plugins/woocommerce/client/legacy/js/admin/meta-boxes-order.js b/plugins/woocommerce/client/legacy/js/admin/meta-boxes-order.js
index ae2e1a11985..9a5b05e5c2e 100644
--- a/plugins/woocommerce/client/legacy/js/admin/meta-boxes-order.js
+++ b/plugins/woocommerce/client/legacy/js/admin/meta-boxes-order.js
@@ -318,6 +318,7 @@ jQuery( function ( $ ) {

 			$( document.body )
 				.on( 'wc_backbone_modal_loaded', this.backbone.init )
+				.on( 'wc_backbone_modal_before_response', this.backbone.validate_response )
 				.on( 'wc_backbone_modal_response', this.backbone.response );
 		},

@@ -709,6 +710,43 @@ jQuery( function ( $ ) {
 			return false;
 		},

+		/**
+		 * Return the first of the given inputs whose value is below its min
+		 * attribute, or null when none is. Only the minimum (rangeUnderflow)
+		 * is checked; other constraints are deliberately ignored so they keep
+		 * their previous behaviour.
+		 *
+		 * @param {NodeList|jQuery} inputs Quantity inputs to check.
+		 * @return {HTMLInputElement|null} First input below its minimum.
+		 */
+		find_input_with_qty_below_min: function( inputs ) {
+			return Array.prototype.find.call( inputs, function( input ) {
+				return input.validity.rangeUnderflow;
+			} ) || null;
+		},
+
+		/**
+		 * Check the quantity inputs in the items panel against their minimum,
+		 * revealing and reporting the first one below it.
+		 *
+		 * @return {boolean} True when every quantity input meets its minimum.
+		 */
+		validate_quantity_inputs: function() {
+			var input = wc_meta_boxes_order_items.find_input_with_qty_below_min(
+				document.querySelectorAll( '#woocommerce-order-items input.quantity' )
+			);
+
+			if ( ! input ) {
+				return true;
+			}
+
+			var row = $( input ).closest( 'tr' );
+			row.find( '.view' ).hide();
+			row.find( '.edit' ).show();
+			input.reportValidity();
+			return false;
+		},
+
 		edit_item: function() {
 			$( this ).closest( 'tr' ).find( '.view' ).hide();
 			$( this ).closest( 'tr' ).find( '.edit' ).show();
@@ -909,6 +947,10 @@ jQuery( function ( $ ) {
 		},

 		save_line_items: function() {
+			if ( ! wc_meta_boxes_order_items.validate_quantity_inputs() ) {
+				return false;
+			}
+
 			var data = {
 				order_id: woocommerce_admin_meta_boxes.post_id,
 				items:    $( 'table.woocommerce_order_items :input[name], .wc-order-totals-items :input[name]' ).serialize(),
@@ -1212,6 +1254,25 @@ jQuery( function ( $ ) {

 		backbone: {

+			/**
+			 * Cancel the add products modal response when a quantity is below its
+			 * minimum, reporting it on the input so the modal stays open.
+			 */
+			validate_response: function( e, target, $modal ) {
+				if ( 'wc-modal-add-products' !== target || ! $modal || ! $modal.length ) {
+					return;
+				}
+
+				var qtyInputBelowMin = wc_meta_boxes_order_items.find_input_with_qty_below_min(
+					$modal[ 0 ].querySelectorAll( 'input[name="item_qty"]' )
+				);
+
+				if ( qtyInputBelowMin ) {
+					qtyInputBelowMin.reportValidity();
+					e.preventDefault();
+				}
+			},
+
 			init: function( e, target ) {
 				if ( 'wc-modal-add-products' === target ) {
 					$( document.body ).trigger( 'wc-enhanced-select-init' );
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 88383ec4cbc..da335517392 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
@@ -9,6 +9,7 @@

 defined( 'ABSPATH' ) || exit;

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

 $product      = $item->get_product();
@@ -116,13 +117,18 @@ $item_name = apply_filters( 'woocommerce_order_item_name', $item->get_name(), $i
 			/**
 			* Filter to change the product quantity minimum in the order editor of the admin area.
 			*
+			* In the 'edit' context the default is 0, floored at the item's current
+			* quantity when that is already negative (so orders created with negative
+			* quantities via the API remain editable). Since 11.2.0 the filter also
+			* runs with the 'add' context when products are added to an order.
+			*
 			* @since   5.8.0
-			* @param   string      $step    The current minimum amount to be used in the quantity editor.
+			* @param   string      $min     The current minimum amount to be used in the quantity editor.
 			* @param   WC_Product  $product The product that is being edited.
-			* @param   string      $context The context in which the quantity editor is shown, 'edit' or 'refund'.
+			* @param   string      $context The context in which the quantity editor is shown, 'edit', 'refund' or 'add'.
 			*/
-			$min_edit   = apply_filters( 'woocommerce_quantity_input_min_admin', '0', $product, 'edit' );
 			$min_refund = apply_filters( 'woocommerce_quantity_input_min_admin', '0', $product, 'refund' );
+			$min_edit   = wc_get_container()->get( ItemQuantityLimits::class )->get_quantity_input_min( $item, $product );
 		?>
 		<div class="edit" style="display: none;">
 			<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" />
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index f6a8663fe1b..a3e8cd59bf7 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -15,6 +15,7 @@ use Automattic\WooCommerce\Internal\ProductAttributes\VisualAttributeTermMeta;
 use Automattic\WooCommerce\Internal\Orders\CouponsController;
 use Automattic\WooCommerce\Internal\Orders\TaxesController;
 use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
+use Automattic\WooCommerce\Internal\Admin\Orders\ItemQuantityLimits;
 use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\CustomMetaBox;
 use Automattic\WooCommerce\Internal\Products\ProductsOrderingMoveService;
 use Automattic\WooCommerce\Internal\Utilities\Users;
@@ -1167,10 +1168,10 @@ class WC_AJAX {

 		try {
 			$response = self::maybe_add_order_item( $order_id, $items, $items_to_add );
-			wp_send_json_success( $response );
 		} catch ( Exception $e ) {
 			wp_send_json_error( array( 'error' => $e->getMessage() ) );
 		}
+		wp_send_json_success( $response );
 	}

 	/**
@@ -1187,13 +1188,17 @@ class WC_AJAX {
 		try {
 			$order = wc_get_order( $order_id );

-			if ( ! $order ) {
+			if ( ! $order instanceof WC_Order ) {
 				throw new Exception( __( 'Invalid order', 'woocommerce' ) );
 			}

+			// Unsaved edits from the items panel ride along with the add request;
+			// validate and save them first so they are neither lost nor able to
+			// bypass the quantity minimum.
 			if ( ! empty( $items ) ) {
 				$save_items = array();
 				parse_str( $items, $save_items );
+				wc_get_container()->get( ItemQuantityLimits::class )->validate_posted_item_quantities( $order, $save_items );
 				wc_save_order_items( $order->get_id(), $save_items );
 			}

@@ -1214,14 +1219,23 @@ class WC_AJAX {
 				}
 				if ( ProductType::VARIABLE === $product->get_type() ) {
 					/* translators: %s product name */
-					throw new Exception( sprintf( __( '%s is a variable product parent and cannot be added.', 'woocommerce' ), $product->get_name() ) );
+					$message = sprintf( __( '%s is a variable product parent and cannot be added.', 'woocommerce' ), $product->get_name() );
+
+					// The message is shown in a JS alert, not rendered as HTML.
+					throw new Exception( wp_strip_all_tags( html_entity_decode( $message, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ) ) );
 				}
+
+				wc_get_container()->get( ItemQuantityLimits::class )->validate_new_item_quantity( (float) $qty, $product );
+
 				$validation_error = new WP_Error();
 				$validation_error = apply_filters( 'woocommerce_ajax_add_order_item_validation', $validation_error, $product, $order, $qty );

 				if ( $validation_error->get_error_code() ) {
 					/* translators: %s: error message */
-					throw new Exception( sprintf( __( 'Error: %s', 'woocommerce' ), $validation_error->get_error_message() ) );
+					$message = sprintf( __( 'Error: %s', 'woocommerce' ), $validation_error->get_error_message() );
+
+					// The message is shown in a JS alert, not rendered as HTML.
+					throw new Exception( wp_strip_all_tags( html_entity_decode( $message, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ) ) );
 				}
 				$item_id                 = $order->add_product( $product, $qty, array( 'order' => $order ) );
 				$item                    = apply_filters( 'woocommerce_ajax_order_item', $order->get_item( $item_id ), $item_id, $order, $product );
@@ -1234,7 +1248,7 @@ class WC_AJAX {
 			}

 			/* translators: %s item name. */
-			$order->add_order_note( sprintf( __( 'Added line items: %s', 'woocommerce' ), implode( ', ', $order_notes ) ), false, true, array( 'note_group' => OrderNoteGroup::ORDER_UPDATE ) );
+			$order->add_order_note( sprintf( __( 'Added line items: %s', 'woocommerce' ), implode( ', ', $order_notes ) ), 0, true, array( 'note_group' => OrderNoteGroup::ORDER_UPDATE ) );

 			do_action( 'woocommerce_ajax_order_items_added', $added_items, $order );

@@ -1512,7 +1526,7 @@ class WC_AJAX {
 			$order_id = absint( $_POST['order_id'] );
 			$order    = wc_get_order( $order_id );

-			if ( ! $order ) {
+			if ( ! $order instanceof WC_Order ) {
 				throw new Exception( __( 'Invalid order', 'woocommerce' ) );
 			}

@@ -1537,6 +1551,7 @@ class WC_AJAX {
 			if ( ! empty( $items ) ) {
 				$save_items = array();
 				parse_str( $items, $save_items );
+				wc_get_container()->get( ItemQuantityLimits::class )->validate_posted_item_quantities( $order, $save_items );
 				wc_save_order_items( $order->get_id(), $save_items );
 			}

@@ -1556,10 +1571,10 @@ class WC_AJAX {

 						if ( $changed_stock && ! is_wp_error( $changed_stock ) ) {
 							/* translators: %1$s: item name %2$s: stock change */
-							$order->add_order_note( sprintf( __( 'Deleted %1$s and adjusted stock (%2$s)', 'woocommerce' ), $item->get_name(), $changed_stock['from'] . '&rarr;' . $changed_stock['to'] ), false, true, array( 'note_group' => OrderNoteGroup::PRODUCT_STOCK ) );
+							$order->add_order_note( sprintf( __( 'Deleted %1$s and adjusted stock (%2$s)', 'woocommerce' ), $item->get_name(), $changed_stock['from'] . '&rarr;' . $changed_stock['to'] ), 0, true, array( 'note_group' => OrderNoteGroup::PRODUCT_STOCK ) );
 						} else {
 							/* translators: %s item name. */
-							$order->add_order_note( sprintf( __( 'Deleted %s', 'woocommerce' ), $item->get_name() ), false, true, array( 'note_group' => OrderNoteGroup::ORDER_UPDATE ) );
+							$order->add_order_note( sprintf( __( 'Deleted %s', 'woocommerce' ), $item->get_name() ), 0, true, array( 'note_group' => OrderNoteGroup::ORDER_UPDATE ) );
 						}
 					}

@@ -1677,6 +1692,16 @@ class WC_AJAX {
 			$items = array();
 			parse_str( wp_unslash( $_POST['items'] ), $items ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

+			$order = wc_get_order( $order_id );
+
+			try {
+				if ( $order instanceof WC_Order ) {
+					wc_get_container()->get( ItemQuantityLimits::class )->validate_posted_item_quantities( $order, $items );
+				}
+			} catch ( Exception $e ) {
+				wp_send_json_error( array( 'error' => $e->getMessage() ) );
+			}
+
 			// Save order items.
 			wc_save_order_items( $order_id, $items );

diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 9a92bacd4f1..728372b7001 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -8373,7 +8373,7 @@ parameters:
 		-
 			message: '#^Call to an undefined method WC_Order\|WC_Order_Refund\:\:add_order_note\(\)\.$#'
 			identifier: method.notFound
-			count: 4
+			count: 1
 			path: includes/class-wc-ajax.php

 		-
diff --git a/plugins/woocommerce/src/Internal/Admin/Orders/ItemQuantityLimits.php b/plugins/woocommerce/src/Internal/Admin/Orders/ItemQuantityLimits.php
new file mode 100644
index 00000000000..1e25c65f44e
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Admin/Orders/ItemQuantityLimits.php
@@ -0,0 +1,155 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Admin\Orders;
+
+use WC_Order;
+use WC_Order_Item;
+use WC_Order_Item_Product;
+use WC_Product;
+
+/**
+ * Admin-side rules for the quantities an order line item accepts.
+ *
+ * The admin order editor renders quantity inputs with a minimum of 0, so
+ * merchants cannot enter negative quantities. Orders created through the
+ * REST API or by extensions may already contain negative quantities, so for
+ * existing items the minimum is floored at the stored quantity to keep those
+ * orders editable.
+ *
+ * Covers product line items only: fee and shipping lines have no quantity
+ * input in the admin editor, and posted quantities for them are ignored.
+ */
+class ItemQuantityLimits {
+
+	/**
+	 * Get the minimum quantity accepted for an existing order item in the admin editor.
+	 *
+	 * @since 11.2.0
+	 * @param WC_Order_Item         $item    Line item being edited.
+	 * @param WC_Product|false|null $product The item's product when the caller already
+	 *                                       resolved it; null to resolve it here.
+	 * @return string Numeric string, filtered through 'woocommerce_quantity_input_min_admin'.
+	 */
+	public function get_quantity_input_min( WC_Order_Item $item, $product = null ): string {
+		if ( null === $product ) {
+			$product = $item instanceof WC_Order_Item_Product ? $item->get_product() : false;
+		}
+
+		$default = (string) min( 0, (float) $item->get_quantity() );
+
+		/**
+		 * This filter is documented in includes/admin/meta-boxes/views/html-order-item.php
+		 *
+		 * @since 5.8.0
+		 */
+		$min = apply_filters( 'woocommerce_quantity_input_min_admin', $default, $product, 'edit' );
+
+		// A callback can return anything; fall back to the default on non-numeric values.
+		return is_numeric( $min ) ? (string) $min : $default;
+	}
+
+	/**
+	 * Validate the quantity requested for a product being added to an order.
+	 *
+	 * @since 11.2.0
+	 * @param float      $qty     Requested quantity.
+	 * @param WC_Product $product Product being added.
+	 * @return void
+	 * @throws \Exception When the quantity is below the allowed minimum.
+	 */
+	public function validate_new_item_quantity( float $qty, WC_Product $product ): void {
+		/**
+		 * This filter is documented in includes/admin/meta-boxes/views/html-order-item.php
+		 *
+		 * @since 5.8.0
+		 */
+		$min = apply_filters( 'woocommerce_quantity_input_min_admin', '0', $product, 'add' );
+
+		// A callback can return anything; fall back to the default on non-numeric values.
+		$min = is_numeric( $min ) ? (float) $min : 0.0;
+
+		if ( $qty < $min ) {
+			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- below_min_exception() strips and decodes the message for a JS alert.
+			throw $this->below_min_exception( $product->get_name(), $min );
+		}
+	}
+
+	/**
+	 * Validate the order_item_qty values posted by the admin order items screen.
+	 *
+	 * Item ids that do not belong to the given order are ignored.
+	 *
+	 * @since 11.2.0
+	 * @param WC_Order $order The order the posted items belong to.
+	 * @param array    $items Posted items, as parsed from the serialized form data
+	 *                        (the same shape wc_save_order_items receives).
+	 * @return void
+	 * @throws \Exception When a quantity is below the item's allowed minimum.
+	 */
+	public function validate_posted_item_quantities( WC_Order $order, array $items ): void {
+		if ( empty( $items['order_item_qty'] ) || ! is_array( $items['order_item_qty'] ) ) {
+			return;
+		}
+
+		$has_min_filter = has_filter( 'woocommerce_quantity_input_min_admin' );
+		$order_items    = null;
+
+		foreach ( $items['order_item_qty'] as $item_id => $posted_qty ) {
+			$qty = (float) wc_stock_amount( wp_unslash( $posted_qty ) );
+
+			// Without a filter the minimum is min( 0, stored quantity ), which is
+			// never above 0, so a non-negative quantity cannot fail: skip the
+			// per-item and product lookups on this hot path.
+			if ( $qty >= 0 && ! $has_min_filter ) {
+				continue;
+			}
+
+			if ( null === $order_items ) {
+				$order_items = $order->get_items();
+			}
+
+			$item = $order_items[ absint( $item_id ) ] ?? null;
+
+			if ( ! $item instanceof WC_Order_Item_Product ) {
+				continue;
+			}
+
+			$min = (float) $this->get_quantity_input_min( $item );
+
+			if ( $qty < $min ) {
+				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- below_min_exception() strips and decodes the message for a JS alert.
+				throw $this->below_min_exception( $item->get_name(), $min );
+			}
+		}
+	}
+
+	/**
+	 * Build the exception for a quantity below the allowed minimum.
+	 *
+	 * The message is plain text destined for a JS alert, never rendered as
+	 * HTML: entities are decoded first so stored names read naturally, then
+	 * any resulting markup is stripped. The name-embedding exceptions in the
+	 * AJAX handlers apply the same treatment.
+	 *
+	 * @since 11.2.0
+	 * @param string $name Product or order item name.
+	 * @param float  $min  Minimum accepted quantity.
+	 * @return \Exception
+	 */
+	private function below_min_exception( string $name, float $min ): \Exception {
+		return new \Exception(
+			wp_strip_all_tags(
+				html_entity_decode(
+					sprintf(
+						/* translators: 1: product or order item name, 2: minimum quantity accepted */
+						__( 'The quantity of "%1$s" must be %2$s or higher.', 'woocommerce' ),
+						$name,
+						wc_format_localized_decimal( (string) $min )
+					),
+					ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
+				)
+			)
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/e2e/tests/order/order-item-quantity-validation.spec.ts b/plugins/woocommerce/tests/e2e/tests/order/order-item-quantity-validation.spec.ts
new file mode 100644
index 00000000000..eda3ecd8dce
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/tests/order/order-item-quantity-validation.spec.ts
@@ -0,0 +1,141 @@
+/**
+ * External dependencies
+ */
+import { WC_API_PATH } from '@woocommerce/e2e-utils-playwright';
+
+/**
+ * Internal dependencies
+ */
+import { tags, expect, test } from '../../fixtures/fixtures';
+import { ADMIN_STATE_PATH } from '../../playwright.config';
+
+test.use( { storageState: ADMIN_STATE_PATH } );
+
+test.describe(
+	'Order item quantity validation',
+	{ tag: [ tags.SERVICES, tags.HPOS ] },
+	() => {
+		let orderId: number;
+		let productId: number;
+
+		test.beforeAll( async ( { restApi } ) => {
+			await restApi
+				.post( `${ WC_API_PATH }/products`, {
+					name: `Qty validation product ${ Date.now() }`,
+					type: 'simple',
+					regular_price: '10.00',
+				} )
+				.then( ( response: { data: { id: number } } ) => {
+					productId = response.data.id;
+				} );
+		} );
+
+		// A fresh order per test: a validation regression that persists a bad
+		// quantity must not leak into the other tests' fixtures.
+		test.beforeEach( async ( { restApi } ) => {
+			await restApi
+				.post( `${ WC_API_PATH }/orders`, {
+					status: 'pending',
+					line_items: [ { product_id: productId, quantity: 2 } ],
+				} )
+				.then( ( response: { data: { id: number } } ) => {
+					orderId = response.data.id;
+				} );
+		} );
+
+		test.afterEach( async ( { restApi } ) => {
+			await restApi.delete( `${ WC_API_PATH }/orders/${ orderId }`, {
+				force: true,
+			} );
+		} );
+
+		test.afterAll( async ( { restApi } ) => {
+			await restApi.delete( `${ WC_API_PATH }/products/${ productId }`, {
+				force: true,
+			} );
+		} );
+
+		test( 'the items panel Save button refuses a negative quantity', async ( {
+			page,
+		} ) => {
+			await page.goto(
+				`wp-admin/admin.php?page=wc-orders&action=edit&id=${ orderId }`
+			);
+
+			await page.locator( 'a.edit-order-item' ).first().click();
+			const qtyInput = page
+				.locator( 'input[name^="order_item_qty"]' )
+				.first();
+			await qtyInput.fill( '-1' );
+
+			await page
+				.locator( '#woocommerce-order-items button.save-action' )
+				.click();
+
+			// The input reports its constraint violation instead of saving.
+			const message = await qtyInput.evaluate(
+				( input: HTMLInputElement ) => input.validationMessage
+			);
+			expect( message ).not.toBe( '' );
+
+			// The negative quantity was not persisted.
+			await page.reload();
+			await expect(
+				page.locator( '#order_line_items td.quantity .view' ).first()
+			).toContainText( '2' );
+		} );
+
+		test( 'the add products modal blocks a negative quantity and stays open', async ( {
+			page,
+		} ) => {
+			await page.goto(
+				`wp-admin/admin.php?page=wc-orders&action=edit&id=${ orderId }`
+			);
+
+			await page.locator( 'button.add-line-item' ).click();
+			await page.locator( 'button.add-order-item' ).click();
+
+			// The modal wrapper has a zero-size box (its content is positioned
+			// absolutely), so visibility is asserted on the content element.
+			const modal = page.locator( '.wc-backbone-modal-add-products' );
+			const modalContent = modal.locator( '.wc-backbone-modal-content' );
+			await expect( modalContent ).toBeVisible();
+
+			await modal
+				.locator( 'input[name="item_qty"]' )
+				.first()
+				.fill( '-3' );
+			await modal.locator( '#btn-ok' ).click();
+
+			// Without the fix the modal closes and the request is sent;
+			// with it, the modal stays open showing the browser message.
+			await expect( modalContent ).toBeVisible();
+		} );
+
+		test( 'the Update button shows a validation message for an invalid item quantity', async ( {
+			page,
+		} ) => {
+			await page.goto(
+				`wp-admin/admin.php?page=wc-orders&action=edit&id=${ orderId }`
+			);
+
+			await page.locator( 'a.edit-order-item' ).first().click();
+			const qtyInput = page
+				.locator( 'input[name^="order_item_qty"]' )
+				.first();
+			await qtyInput.fill( '-1' );
+
+			await page.locator( 'button.save_order' ).click();
+
+			// The invalid field is visible and focused with its message —
+			// not silently swallowed. Focus is the engine-agnostic proof
+			// that the browser reported the field.
+			const message = await qtyInput.evaluate(
+				( input: HTMLInputElement ) => input.validationMessage
+			);
+			expect( message ).not.toBe( '' );
+			await expect( qtyInput ).toBeVisible();
+			await expect( qtyInput ).toBeFocused();
+		} );
+	}
+);
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 ca323a063e3..2c86d66e0bb 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
@@ -1205,6 +1205,154 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
 		$property->setValue( null, $has_run );
 	}

+	/**
+	 * @testdox add_order_item rejects a negative quantity with a JSON error and adds nothing to the order.
+	 */
+	public function test_add_order_item_rejects_negative_quantity() {
+		$this->_setRole( 'administrator' );
+
+		$product            = \WC_Helper_Product::create_simple_product();
+		$order              = \WC_Helper_Order::create_order();
+		$initial_item_count = count( $order->get_items() );
+
+		$_POST['order_id'] = $order->get_id();
+		$_POST['security'] = wp_create_nonce( 'order-item' );
+		$_POST['data']     = array(
+			array(
+				'id'  => (string) $product->get_id(),
+				'qty' => '-2',
+			),
+		);
+
+		$response = $this->do_ajax( 'woocommerce_add_order_item' );
+
+		$this->assertFalse( $response['success'] );
+
+		$order = wc_get_order( $order->get_id() );
+		$this->assertCount( $initial_item_count, $order->get_items() );
+	}
+
+	/**
+	 * @testdox add_order_item still accepts a positive quantity.
+	 */
+	public function test_add_order_item_accepts_positive_quantity() {
+		$this->_setRole( 'administrator' );
+
+		$product            = \WC_Helper_Product::create_simple_product();
+		$order              = \WC_Helper_Order::create_order();
+		$initial_item_count = count( $order->get_items() );
+
+		$_POST['order_id'] = $order->get_id();
+		$_POST['security'] = wp_create_nonce( 'order-item' );
+		$_POST['data']     = array(
+			array(
+				'id'  => (string) $product->get_id(),
+				'qty' => '2',
+			),
+		);
+
+		$response = $this->do_ajax( 'woocommerce_add_order_item' );
+
+		$this->assertTrue( $response['success'] );
+
+		$order = wc_get_order( $order->get_id() );
+		$this->assertCount( $initial_item_count + 1, $order->get_items() );
+	}
+
+	/**
+	 * @testdox save_order_items rejects a negative quantity and leaves the stored item untouched.
+	 */
+	public function test_save_order_items_rejects_negative_quantity() {
+		$this->_setRole( 'administrator' );
+
+		$order        = \WC_Helper_Order::create_order();
+		$items        = array_values( $order->get_items() );
+		$item         = $items[0];
+		$item_id      = $item->get_id();
+		$original_qty = $item->get_quantity();
+
+		$_POST['order_id'] = $order->get_id();
+		$_POST['security'] = wp_create_nonce( 'order-item' );
+		$_POST['items']    = http_build_query(
+			array(
+				'order_item_id'  => array( $item_id ),
+				'order_item_qty' => array( $item_id => '-1' ),
+				'line_total'     => array( $item_id => '-10' ),
+				'line_subtotal'  => array( $item_id => '-10' ),
+			)
+		);
+
+		$response = $this->do_ajax( 'woocommerce_save_order_items' );
+
+		$this->assertFalse( $response['success'] );
+
+		$fresh_item = \WC_Order_Factory::get_order_item( $item_id );
+		$this->assertEquals( $original_qty, $fresh_item->get_quantity() );
+	}
+
+	/**
+	 * @testdox save_order_items accepts a valid positive quantity change.
+	 */
+	public function test_save_order_items_accepts_positive_quantity() {
+		$this->_setRole( 'administrator' );
+
+		$order   = \WC_Helper_Order::create_order();
+		$items   = array_values( $order->get_items() );
+		$item    = $items[0];
+		$item_id = $item->get_id();
+
+		$_POST['order_id'] = $order->get_id();
+		$_POST['security'] = wp_create_nonce( 'order-item' );
+		$_POST['items']    = http_build_query(
+			array(
+				'order_item_id'  => array( $item_id ),
+				'order_item_qty' => array( $item_id => '3' ),
+				'line_total'     => array( $item_id => '30' ),
+				'line_subtotal'  => array( $item_id => '30' ),
+			)
+		);
+
+		$response = $this->do_ajax( 'woocommerce_save_order_items' );
+
+		$this->assertTrue( $response['success'] );
+
+		$fresh_item = \WC_Order_Factory::get_order_item( $item_id );
+		$this->assertEquals( 3, $fresh_item->get_quantity() );
+	}
+
+	/**
+	 * @testdox remove_order_item rejects a negative quantity passed through the pre-delete save and deletes nothing.
+	 */
+	public function test_remove_order_item_rejects_negative_quantity_in_passthrough() {
+		$this->_setRole( 'administrator' );
+
+		$order        = \WC_Helper_Order::create_order();
+		$items        = array_values( $order->get_items() );
+		$item         = $items[0];
+		$item_id      = $item->get_id();
+		$original_qty = $item->get_quantity();
+
+		$_POST['order_id']       = $order->get_id();
+		$_POST['security']       = wp_create_nonce( 'order-item' );
+		$_POST['order_item_ids'] = array( $item_id );
+		$_POST['items']          = http_build_query(
+			array(
+				'order_item_id'  => array( $item_id ),
+				'order_item_qty' => array( $item_id => '-1' ),
+				'line_total'     => array( $item_id => '-10' ),
+				'line_subtotal'  => array( $item_id => '-10' ),
+			)
+		);
+
+		$response = $this->do_ajax( 'woocommerce_remove_order_item' );
+
+		$this->assertFalse( $response['success'] );
+
+		$fresh_item = \WC_Order_Factory::get_order_item( $item_id );
+		$this->assertInstanceOf( \WC_Order_Item_Product::class, $fresh_item, 'The item should not have been deleted.' );
+		$this->assertEquals( $original_qty, $fresh_item->get_quantity() );
+	}
+
 	/**
 	 * Does the 'hard work' of triggering an ajax endpoint and capturing the response.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Orders/ItemQuantityLimitsTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Orders/ItemQuantityLimitsTest.php
new file mode 100644
index 00000000000..09f61c54625
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Orders/ItemQuantityLimitsTest.php
@@ -0,0 +1,222 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Admin\Orders;
+
+use Automattic\WooCommerce\Internal\Admin\Orders\ItemQuantityLimits;
+use WC_Helper_Order;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the ItemQuantityLimits class.
+ */
+class ItemQuantityLimitsTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var ItemQuantityLimits
+	 */
+	private $sut;
+
+	/**
+	 * Set up the system under test.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = wc_get_container()->get( ItemQuantityLimits::class );
+	}
+
+	/**
+	 * @testdox get_quantity_input_min returns 0 for an item with a positive quantity.
+	 */
+	public function test_min_is_zero_for_positive_quantity_item(): void {
+		$order = WC_Helper_Order::create_order();
+		$items = array_values( $order->get_items() );
+
+		$this->assertSame( '0', $this->sut->get_quantity_input_min( $items[0] ) );
+	}
+
+	/**
+	 * @testdox get_quantity_input_min floors at the stored quantity when it is negative, so existing negative orders stay editable.
+	 */
+	public function test_min_floors_at_stored_negative_quantity(): void {
+		$order = WC_Helper_Order::create_order();
+		$items = array_values( $order->get_items() );
+		$item  = $items[0];
+		$item->set_quantity( -5 );
+		$item->save();
+
+		$this->assertSame( '-5', $this->sut->get_quantity_input_min( $item ) );
+	}
+
+	/**
+	 * @testdox get_quantity_input_min applies the woocommerce_quantity_input_min_admin filter.
+	 */
+	public function test_min_is_filterable(): void {
+		$order = WC_Helper_Order::create_order();
+		$items = array_values( $order->get_items() );
+
+		$callback = function () {
+			return '-9999';
+		};
+		add_filter( 'woocommerce_quantity_input_min_admin', $callback );
+		$min = $this->sut->get_quantity_input_min( $items[0] );
+		remove_filter( 'woocommerce_quantity_input_min_admin', $callback );
+
+		$this->assertSame( '-9999', $min );
+	}
+
+	/**
+	 * @testdox validate_posted_item_quantities throws when a posted quantity is below the minimum.
+	 */
+	public function test_validate_posted_throws_below_min(): void {
+		$order = WC_Helper_Order::create_order();
+		$items = array_values( $order->get_items() );
+		$item  = $items[0];
+
+		$this->expectException( \Exception::class );
+		$this->sut->validate_posted_item_quantities(
+			$order,
+			array(
+				'order_item_qty' => array( $item->get_id() => '-1' ),
+			)
+		);
+	}
+
+	/**
+	 * @testdox validate_posted_item_quantities accepts a negative quantity when the stored quantity is already that negative.
+	 */
+	public function test_validate_posted_accepts_existing_negative_quantity(): void {
+		$this->expectNotToPerformAssertions();
+
+		$order = WC_Helper_Order::create_order();
+		$items = array_values( $order->get_items() );
+		$item  = $items[0];
+		$item->set_quantity( -5 );
+		$item->save();
+
+		$this->sut->validate_posted_item_quantities(
+			$order,
+			array(
+				'order_item_qty' => array( $item->get_id() => '-5' ),
+			)
+		);
+	}
+
+	/**
+	 * @testdox validate_posted_item_quantities ignores item ids that do not belong to the order.
+	 */
+	public function test_validate_posted_ignores_foreign_item_ids(): void {
+		$this->expectNotToPerformAssertions();
+
+		$order        = WC_Helper_Order::create_order();
+		$other_order  = WC_Helper_Order::create_order();
+		$foreign_item = array_values( $other_order->get_items() )[0];
+
+		$this->sut->validate_posted_item_quantities(
+			$order,
+			array(
+				'order_item_qty' => array( $foreign_item->get_id() => '-1' ),
+			)
+		);
+	}
+
+	/**
+	 * @testdox validate_posted_item_quantities ignores non-product items such as fees.
+	 */
+	public function test_validate_posted_ignores_non_product_items(): void {
+		$this->expectNotToPerformAssertions();
+
+		$order = WC_Helper_Order::create_order();
+		$fee   = new \WC_Order_Item_Fee();
+		$fee->set_name( 'Handling' );
+		$fee->set_total( '5' );
+		$order->add_item( $fee );
+		$order->save();
+
+		$this->sut->validate_posted_item_quantities(
+			$order,
+			array(
+				'order_item_qty' => array( $fee->get_id() => '-1' ),
+			)
+		);
+	}
+
+	/**
+	 * @testdox validate_new_item_quantity throws for a negative quantity on a new item.
+	 */
+	public function test_validate_new_item_throws_for_negative_quantity(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+
+		$this->expectException( \Exception::class );
+		$this->sut->validate_new_item_quantity( -2.0, $product );
+	}
+
+	/**
+	 * @testdox validate_new_item_quantity accepts zero and positive quantities.
+	 */
+	public function test_validate_new_item_accepts_non_negative_quantity(): void {
+		$this->expectNotToPerformAssertions();
+
+		$product = \WC_Helper_Product::create_simple_product();
+
+		$this->sut->validate_new_item_quantity( 0.0, $product );
+		$this->sut->validate_new_item_quantity( 3.0, $product );
+	}
+
+	/**
+	 * @testdox get_quantity_input_min falls back to the default when the filter returns a non-numeric value.
+	 */
+	public function test_min_falls_back_on_non_numeric_filter_value(): void {
+		$order = WC_Helper_Order::create_order();
+		$items = array_values( $order->get_items() );
+
+		$callback = function () {
+			return array( 'not-a-number' );
+		};
+		add_filter( 'woocommerce_quantity_input_min_admin', $callback );
+		$min = $this->sut->get_quantity_input_min( $items[0] );
+		remove_filter( 'woocommerce_quantity_input_min_admin', $callback );
+
+		$this->assertSame( '0', $min );
+	}
+
+	/**
+	 * @testdox validate_new_item_quantity runs the filter with the add context and the product.
+	 */
+	public function test_validate_new_item_passes_add_context_to_filter(): void {
+		$product  = \WC_Helper_Product::create_simple_product();
+		$captured = array();
+
+		$callback = function ( $min, $filter_product, $context ) use ( &$captured ) {
+			$captured = array( $filter_product, $context );
+			return $min;
+		};
+		add_filter( 'woocommerce_quantity_input_min_admin', $callback, 10, 3 );
+		$this->sut->validate_new_item_quantity( 1.0, $product );
+		remove_filter( 'woocommerce_quantity_input_min_admin', $callback );
+
+		$this->assertSame( $product->get_id(), $captured[0]->get_id() );
+		$this->assertSame( 'add', $captured[1] );
+	}
+
+	/**
+	 * @testdox validate_posted_item_quantities keeps decimal precision on stores that allow decimal stock.
+	 */
+	public function test_validate_posted_rejects_decimal_below_min(): void {
+		remove_filter( 'woocommerce_stock_amount', 'intval' );
+		add_filter( 'woocommerce_stock_amount', 'floatval' );
+
+		$order = WC_Helper_Order::create_order();
+		$items = array_values( $order->get_items() );
+
+		$this->expectException( \Exception::class );
+		$this->sut->validate_posted_item_quantities(
+			$order,
+			array(
+				'order_item_qty' => array( $items[0]->get_id() => '-0.5' ),
+			)
+		);
+	}
+}