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'] . '→' . $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'] . '→' . $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' ),
+ )
+ );
+ }
+}