Commit 0defc95813c for woocommerce

commit 0defc95813cad39630ada18bf3b1355b9a08c5b7
Author: Mike Jolley <mike.jolley@me.com>
Date:   Fri Nov 6 10:42:29 2026 +0000

    Disable the refund restock checkbox until items can be restocked (#70241)

    * Disable the refund restock checkbox until items can be restocked

    * Add changelog entry for refund restock checkbox state

    * Round decimal quantities in the refund restock help text

    * Avoid rounding small refund quantities to zero in the restock count

    * Update restock checkbox while typing and keep the merchant's choice

    Update the checkbox on each keystroke, restore an untick after a quantity is cleared, pluralise the restock count with _n, and drop trailing full stops from the messages.

diff --git a/plugins/woocommerce/changelog/33804-restock-checkbox-state b/plugins/woocommerce/changelog/33804-restock-checkbox-state
new file mode 100644
index 00000000000..41114433915
--- /dev/null
+++ b/plugins/woocommerce/changelog/33804-restock-checkbox-state
@@ -0,0 +1,4 @@
+Significance: patch
+Type: update
+
+Disable the refund restock checkbox until a quantity is entered for an item that can be restocked, and explain why.
diff --git a/plugins/woocommerce/client/legacy/css/admin.scss b/plugins/woocommerce/client/legacy/css/admin.scss
index 93c9a5f109b..c4d8f11a582 100644
--- a/plugins/woocommerce/client/legacy/css/admin.scss
+++ b/plugins/woocommerce/client/legacy/css/admin.scss
@@ -1644,6 +1644,19 @@ body.woocommerce_page_wc-customer-stock-notifications {
 		.label-highlight {
 			font-weight: 700;
 		}
+
+		.restock-refunded-items {
+
+			.total {
+				vertical-align: top;
+			}
+
+			.description {
+				margin: 0;
+				font-size: 12px;
+				line-height: 1.5;
+			}
+		}
 	}

 	// Stack the coupons/totals columns full width once the admin sidebar hides.
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 197b087b2ce..630128608d6 100644
--- a/plugins/woocommerce/client/legacy/js/admin/meta-boxes-order.js
+++ b/plugins/woocommerce/client/legacy/js/admin/meta-boxes-order.js
@@ -364,6 +364,8 @@ jQuery( function ( $ ) {
 				.on( 'change', '.refund input.refund_line_total, .refund input.refund_line_tax', this.refunds.input_changed )
 				.on( 'change keyup', '.wc-order-refund-items #refund_amount', this.refunds.amount_changed )
 				.on( 'change', 'input.refund_order_item_qty', this.refunds.refund_quantity_changed )
+				.on( 'input', 'input.refund_order_item_qty', this.refunds.update_restock_checkbox )
+				.on( 'change', '#restock_refunded_items', this.refunds.update_restock_checkbox )

 				// Qty
 				.on( 'change', 'input.quantity', this.quantity_changed )
@@ -653,6 +655,7 @@ jQuery( function ( $ ) {
 			$( 'div.wc-order-totals-items' ).slideUp();
 			$( '#woocommerce-order-items' ).find( 'div.refund' ).show();
 			$( '.wc-order-edit-line-item .wc-order-edit-line-item-actions' ).hide();
+			wc_meta_boxes_order_items.refunds.update_restock_checkbox();

 			window.wcTracks.recordEvent( 'order_edit_refund_button_click', {
 				order_id: woocommerce_admin_meta_boxes.post_id,
@@ -1265,19 +1268,79 @@ jQuery( function ( $ ) {
 					}
 				});

-				// Restock checkbox
-				if ( refund_qty > 0 ) {
-					$( '#restock_refunded_items' ).closest( 'tr' ).show();
+				wc_meta_boxes_order_items.refunds.update_restock_checkbox();
+
+				$( this ).trigger( 'refund_quantity_changed' );
+			},
+
+			// Only enable restocking once a line that holds stock has a refund quantity, and say what will happen.
+			update_restock_checkbox: function() {
+				var $checkbox          = $( '#restock_refunded_items' );
+				var restock_qty        = 0;
+				var has_restockable    = false;
+				var has_other_quantity = false;
+				var last_checked;
+				var message;
+
+				if ( ! $checkbox.length ) {
+					return;
+				}
+
+				$( '.woocommerce_order_items input.refund_order_item_qty' ).each( function() {
+					var can_restock = 'yes' === $( this ).attr( 'data-can-restock' );
+					var qty         = parseFloat( $( this ).val() ) || 0;
+
+					has_restockable = has_restockable || can_restock;
+
+					if ( qty <= 0 ) {
+						return;
+					}
+
+					if ( can_restock ) {
+						restock_qty += qty;
+					} else {
+						has_other_quantity = true;
+					}
+				});
+
+				// Strip floating point noise from decimal quantities without rounding small values to zero.
+				restock_qty = parseFloat( restock_qty.toPrecision( 12 ) );
+
+				if ( restock_qty > 0 ) {
+					// Restore the merchant's last choice, or the woocommerce_restock_refunded_items default the first time.
+					if ( $checkbox.prop( 'disabled' ) ) {
+						last_checked = $checkbox.data( 'restock-checked' );
+						$checkbox
+							.prop( 'disabled', false )
+							.prop( 'checked', undefined === last_checked ? $checkbox.prop( 'defaultChecked' ) : last_checked );
+					}
+
+					if ( ! $checkbox.prop( 'checked' ) ) {
+						message = woocommerce_admin_meta_boxes.i18n_restock_none;
+					} else {
+						message = wp.i18n.sprintf(
+							/* translators: %s: number of items that will be returned to stock. */
+							wp.i18n._n( '%s item will be restocked', '%s items will be restocked', restock_qty, 'woocommerce' ),
+							restock_qty
+						);
+					}
 				} else {
-					$( '#restock_refunded_items' ).closest( 'tr' ).hide();
-					$( '.woocommerce_order_items input.refund_order_item_qty' ).each( function() {
-						if ( $( this ).val() > 0 ) {
-							$( '#restock_refunded_items' ).closest( 'tr' ).show();
-						}
-					});
+					if ( ! $checkbox.prop( 'disabled' ) ) {
+						$checkbox.data( 'restock-checked', $checkbox.prop( 'checked' ) );
+					}
+
+					$checkbox.prop( 'disabled', true ).prop( 'checked', false );
+
+					if ( ! has_restockable ) {
+						message = woocommerce_admin_meta_boxes.i18n_restock_order_not_reduced;
+					} else if ( has_other_quantity ) {
+						message = woocommerce_admin_meta_boxes.i18n_restock_items_not_reduced;
+					} else {
+						message = woocommerce_admin_meta_boxes.i18n_restock_enter_quantity;
+					}
 				}

-				$( this ).trigger( 'refund_quantity_changed' );
+				$( '#restock_refunded_items_description' ).text( message );
 			}
 		},

diff --git a/plugins/woocommerce/client/legacy/js/admin/test/meta-boxes-order-restock.test.js b/plugins/woocommerce/client/legacy/js/admin/test/meta-boxes-order-restock.test.js
new file mode 100644
index 00000000000..5369aa3760d
--- /dev/null
+++ b/plugins/woocommerce/client/legacy/js/admin/test/meta-boxes-order-restock.test.js
@@ -0,0 +1,266 @@
+describe( 'Order refund restock checkbox', () => {
+	let $;
+
+	const renderItemRow = ( itemId, canRestock ) => `
+		<tr class="item" data-order_item_id="${ itemId }">
+			<td class="quantity">
+				<input type="number" class="quantity" value="3">
+				<div class="refund">
+					<input type="number" class="refund_order_item_qty" data-can-restock="${ canRestock }">
+				</div>
+			</td>
+			<td class="line_cost">
+				<input type="text" class="line_total" data-total="30">
+				<div class="refund">
+					<input type="text" class="refund_line_total">
+				</div>
+			</td>
+		</tr>
+	`;
+
+	// Handlers are delegated from #woocommerce-order-items, so only its contents are replaced.
+	const renderOrderItems = ( { lines, defaultChecked = true } ) => {
+		document.getElementById( 'woocommerce-order-items' ).innerHTML = `
+				<table class="woocommerce_order_items">
+					<tbody>
+						${ lines
+							.map( ( canRestock, index ) =>
+								renderItemRow( index + 1, canRestock )
+							)
+							.join( '' ) }
+					</tbody>
+				</table>
+				<button type="button" class="refund-items"></button>
+				<div class="wc-order-refund-items">
+					<table class="wc-order-totals">
+						<tr>
+							<td class="label">
+								<label for="restock_refunded_items">Restock refunded items:</label>
+								<p class="description" id="restock_refunded_items_description"></p>
+							</td>
+							<td class="total">
+								<input type="checkbox" id="restock_refunded_items" aria-describedby="restock_refunded_items_description" ${
+									defaultChecked ? 'checked' : ''
+								}>
+							</td>
+						</tr>
+					</table>
+					<input type="text" id="refund_amount">
+				</div>
+		`;
+	};
+
+	const checkbox = () => document.getElementById( 'restock_refunded_items' );
+	const description = () =>
+		document.getElementById( 'restock_refunded_items_description' )
+			.textContent;
+	const openRefundPanel = () => $( 'button.refund-items' ).trigger( 'click' );
+	const setRefundQty = ( itemId, qty ) =>
+		$( `tr[data-order_item_id="${ itemId }"] input.refund_order_item_qty` )
+			.val( qty )
+			.trigger( 'change' );
+
+	beforeAll( () => {
+		// jQuery binds document-ready callbacks on a timer once the document is loaded.
+		jest.useFakeTimers();
+		jest.resetModules();
+
+		$ = require( 'jquery' );
+		global.jQuery = $;
+		global.accounting = require( '../../accounting/accounting' );
+		$.fn.selectWoo = jest.fn().mockReturnThis();
+		$.fn.stupidtable = jest.fn().mockReturnThis();
+		global.woocommerce_admin = { mon_decimal_point: '.' };
+		// English plural rules, matching what wp.i18n returns without a translation loaded.
+		global.wp = {
+			i18n: {
+				_n: ( single, plural, number ) =>
+					1 === number ? single : plural,
+				sprintf: ( format, value ) => format.replace( '%s', value ),
+			},
+		};
+		global.woocommerce_admin_meta_boxes = {
+			currency_format_num_decimals: 2,
+			rounding_precision: 6,
+			i18n_restock_enter_quantity:
+				'Enter a quantity above to return items to stock',
+			i18n_restock_none: 'No items will be restocked',
+			i18n_restock_items_not_reduced:
+				'Stock was not reduced for these items',
+			i18n_restock_order_not_reduced:
+				'Stock was not reduced for this order',
+		};
+
+		document.body.innerHTML = '<div id="woocommerce-order-items"></div>';
+		require( '../meta-boxes-order' );
+		jest.runAllTimers();
+		jest.useRealTimers();
+	} );
+
+	afterAll( () => {
+		document.body.innerHTML = '';
+		delete global.jQuery;
+		delete global.accounting;
+		delete global.woocommerce_admin;
+		delete global.wp;
+		delete global.woocommerce_admin_meta_boxes;
+	} );
+
+	test( 'is disabled and unticked when the refund panel opens', () => {
+		renderOrderItems( { lines: [ 'yes' ] } );
+		openRefundPanel();
+
+		expect( checkbox().disabled ).toBe( true );
+		expect( checkbox().checked ).toBe( false );
+		expect( description() ).toBe(
+			'Enter a quantity above to return items to stock'
+		);
+	} );
+
+	test( 'stays disabled when only a refund amount is entered', () => {
+		renderOrderItems( { lines: [ 'yes' ] } );
+		openRefundPanel();
+		$( '#refund_amount' ).val( '10' ).trigger( 'change' );
+
+		expect( checkbox().disabled ).toBe( true );
+		expect( checkbox().checked ).toBe( false );
+	} );
+
+	test( 'enables, ticks and counts the items once a quantity is entered', () => {
+		renderOrderItems( { lines: [ 'yes', 'yes' ] } );
+		openRefundPanel();
+
+		setRefundQty( 1, 1 );
+		expect( checkbox().disabled ).toBe( false );
+		expect( checkbox().checked ).toBe( true );
+		expect( description() ).toBe( '1 item will be restocked' );
+
+		setRefundQty( 2, 2 );
+		expect( description() ).toBe( '3 items will be restocked' );
+	} );
+
+	test( 'counts decimal quantities without floating point noise', () => {
+		renderOrderItems( { lines: [ 'yes', 'yes' ] } );
+		openRefundPanel();
+
+		setRefundQty( 1, 0.1 );
+		setRefundQty( 2, 0.2 );
+		expect( checkbox().disabled ).toBe( false );
+		expect( description() ).toBe( '0.3 items will be restocked' );
+	} );
+
+	test( 'does not round a small positive quantity down to zero', () => {
+		const roundingPrecision =
+			global.woocommerce_admin_meta_boxes.rounding_precision;
+		global.woocommerce_admin_meta_boxes.rounding_precision = 2;
+
+		renderOrderItems( { lines: [ 'yes' ] } );
+		openRefundPanel();
+		setRefundQty( 1, 0.001 );
+
+		global.woocommerce_admin_meta_boxes.rounding_precision =
+			roundingPrecision;
+		expect( checkbox().disabled ).toBe( false );
+		expect( description() ).toBe( '0.001 items will be restocked' );
+	} );
+
+	test( 'keeps the merchant choice when unticked and quantities change', () => {
+		renderOrderItems( { lines: [ 'yes' ] } );
+		openRefundPanel();
+		setRefundQty( 1, 1 );
+
+		$( checkbox() ).prop( 'checked', false ).trigger( 'change' );
+		expect( description() ).toBe( 'No items will be restocked' );
+
+		setRefundQty( 1, 2 );
+		expect( checkbox().checked ).toBe( false );
+		expect( description() ).toBe( 'No items will be restocked' );
+	} );
+
+	test( 'keeps the merchant untick when a quantity is cleared and entered again', () => {
+		renderOrderItems( { lines: [ 'yes' ] } );
+		openRefundPanel();
+		setRefundQty( 1, 1 );
+		$( checkbox() ).prop( 'checked', false ).trigger( 'change' );
+
+		setRefundQty( 1, '' );
+		setRefundQty( 1, 2 );
+		expect( checkbox().disabled ).toBe( false );
+		expect( checkbox().checked ).toBe( false );
+		expect( description() ).toBe( 'No items will be restocked' );
+	} );
+
+	test( 'updates while a quantity is typed, before the field loses focus', () => {
+		renderOrderItems( { lines: [ 'yes' ] } );
+		openRefundPanel();
+
+		const $qty = $(
+			'tr[data-order_item_id="1"] input.refund_order_item_qty'
+		);
+		$qty.val( '2' ).trigger( 'input' );
+		expect( checkbox().disabled ).toBe( false );
+		expect( checkbox().checked ).toBe( true );
+		expect( description() ).toBe( '2 items will be restocked' );
+
+		$qty.val( '' ).trigger( 'input' );
+		expect( checkbox().disabled ).toBe( true );
+		expect( description() ).toBe(
+			'Enter a quantity above to return items to stock'
+		);
+	} );
+
+	test( 'disables and unticks again when all quantities are cleared', () => {
+		renderOrderItems( { lines: [ 'yes' ] } );
+		openRefundPanel();
+		setRefundQty( 1, 1 );
+		setRefundQty( 1, '' );
+
+		expect( checkbox().disabled ).toBe( true );
+		expect( checkbox().checked ).toBe( false );
+		expect( description() ).toBe(
+			'Enter a quantity above to return items to stock'
+		);
+	} );
+
+	test( 'only counts lines that can be restocked', () => {
+		renderOrderItems( { lines: [ 'yes', 'no' ] } );
+		openRefundPanel();
+		setRefundQty( 1, 1 );
+		setRefundQty( 2, 2 );
+
+		expect( description() ).toBe( '1 item will be restocked' );
+	} );
+
+	test( 'stays disabled when quantities are only on lines that cannot be restocked', () => {
+		renderOrderItems( { lines: [ 'yes', 'no' ] } );
+		openRefundPanel();
+		setRefundQty( 2, 2 );
+
+		expect( checkbox().disabled ).toBe( true );
+		expect( checkbox().checked ).toBe( false );
+		expect( description() ).toBe( 'Stock was not reduced for these items' );
+	} );
+
+	test( 'stays disabled when no line in the order can be restocked', () => {
+		renderOrderItems( { lines: [ 'no' ] } );
+		openRefundPanel();
+
+		expect( checkbox().disabled ).toBe( true );
+		expect( description() ).toBe( 'Stock was not reduced for this order' );
+
+		setRefundQty( 1, 1 );
+		expect( checkbox().disabled ).toBe( true );
+		expect( checkbox().checked ).toBe( false );
+		expect( description() ).toBe( 'Stock was not reduced for this order' );
+	} );
+
+	test( 'respects an unticked default from the woocommerce_restock_refunded_items filter', () => {
+		renderOrderItems( { lines: [ 'yes' ], defaultChecked: false } );
+		openRefundPanel();
+		setRefundQty( 1, 1 );
+
+		expect( checkbox().disabled ).toBe( false );
+		expect( checkbox().checked ).toBe( false );
+		expect( description() ).toBe( 'No items will be restocked' );
+	} );
+} );
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
index 0b0e47a83b5..f5144262ae1 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
@@ -609,7 +609,8 @@ if ( ! class_exists( 'WC_Admin_Assets', false ) ) :
 			if ( $this->is_order_meta_box_screen( $screen_id ) ) {
 				$default_location = wc_get_customer_default_location();

-				wp_enqueue_script( 'wc-admin-order-meta-boxes', WC()->plugin_url() . '/assets/js/admin/meta-boxes-order' . $suffix . '.js', array( 'wc-admin-meta-boxes', 'wc-backbone-modal', 'selectWoo', 'wc-clipboard', 'wp-a11y' ), $version );
+				wp_enqueue_script( 'wc-admin-order-meta-boxes', WC()->plugin_url() . '/assets/js/admin/meta-boxes-order' . $suffix . '.js', array( 'wc-admin-meta-boxes', 'wc-backbone-modal', 'selectWoo', 'wc-clipboard', 'wp-a11y', 'wp-i18n' ), $version );
+				wp_set_script_translations( 'wc-admin-order-meta-boxes', 'woocommerce' );
 				wp_localize_script(
 					'wc-admin-order-meta-boxes',
 					'woocommerce_admin_meta_boxes_order',
@@ -668,6 +669,10 @@ if ( ! class_exists( 'WC_Admin_Assets', false ) ) :
 					'remove_shipping_notice'                          => $remove_shipping_notice,
 					'i18n_select_items'                               => __( 'Please select some items.', 'woocommerce' ),
 					'i18n_do_refund'                                  => __( 'Are you sure you wish to process this refund? This action cannot be undone.', 'woocommerce' ),
+					'i18n_restock_enter_quantity'                     => __( 'Enter a quantity above to return items to stock', 'woocommerce' ),
+					'i18n_restock_none'                               => __( 'No items will be restocked', 'woocommerce' ),
+					'i18n_restock_items_not_reduced'                  => __( 'Stock was not reduced for these items', 'woocommerce' ),
+					'i18n_restock_order_not_reduced'                  => __( 'Stock was not reduced for this order', 'woocommerce' ),
 					'i18n_delete_refund'                              => __( 'Are you sure you wish to delete this refund? This action cannot be undone.', 'woocommerce' ),
 					'i18n_delete_tax'                                 => __( 'Are you sure you wish to delete this tax column? This action cannot be undone.', 'woocommerce' ),
 					'i18n_no_title'                                   => __( '(no title)', 'woocommerce' ),
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 8d97f893c8f..dc6634ffbdc 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
@@ -20,6 +20,9 @@ $row_class    = apply_filters( 'woocommerce_admin_html_order_item_class', ! empt
 $wc_price_arg = array( 'currency' => $order->get_currency() );
 $is_visible   = $product && $product->is_visible();

+// Mirrors the checks in wc_restock_refunded_items(), so the refund panel only offers restocking that will happen.
+$can_restock = $product && $product->managing_stock() && $item->get_meta( '_reduced_stock', true );
+
 /**
  * Filter the order item name.
  *
@@ -135,7 +138,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 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" />
+			<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" data-can-restock="<?php echo esc_attr( wc_bool_to_string( $can_restock ) ); ?>" />
 		</div>
 	</td>
 	<td class="line_cost" width="1%" data-sort-value="<?php echo esc_attr( $item->get_total() ); ?>">
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-items.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-items.php
index 0ed8283c99a..79669b20b6c 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-items.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-items.php
@@ -366,10 +366,23 @@ if ( wc_tax_enabled() ) {
 <?php if ( $render_refunds ) : ?>
 <div class="wc-order-data-row wc-order-refund-items wc-order-data-row-toggle" style="display: none;">
 	<table class="wc-order-totals">
-		<?php if ( 'yes' === get_option( 'woocommerce_manage_stock' ) ) : ?>
-			<tr>
-				<td class="label"><label for="restock_refunded_items"><?php esc_html_e( 'Restock refunded items', 'woocommerce' ); ?>:</label></td>
-				<td class="total"><input type="checkbox" id="restock_refunded_items" name="restock_refunded_items" <?php checked( apply_filters( 'woocommerce_restock_refunded_items', true ) ); ?> /></td>
+		<?php
+		if ( 'yes' === get_option( 'woocommerce_manage_stock' ) ) :
+			/**
+			 * Filters whether the restock checkbox is ticked by default once a refund quantity is entered.
+			 *
+			 * @since 3.5.0
+			 *
+			 * @param bool $restock_by_default Whether to tick the restock checkbox by default.
+			 */
+			$restock_by_default = apply_filters( 'woocommerce_restock_refunded_items', true );
+			?>
+			<tr class="restock-refunded-items">
+				<td class="label">
+					<label for="restock_refunded_items"><?php esc_html_e( 'Restock refunded items', 'woocommerce' ); ?>:</label>
+					<p class="description" id="restock_refunded_items_description"></p>
+				</td>
+				<td class="total"><input type="checkbox" id="restock_refunded_items" name="restock_refunded_items" aria-describedby="restock_refunded_items_description" <?php checked( $restock_by_default ); ?> /></td>
 			</tr>
 		<?php endif; ?>
 		<tr>
diff --git a/plugins/woocommerce/tests/e2e/tests/order/order-refund.spec.ts b/plugins/woocommerce/tests/e2e/tests/order/order-refund.spec.ts
index 1f0a6181e2d..40e44b18761 100644
--- a/plugins/woocommerce/tests/e2e/tests/order/order-refund.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/order/order-refund.spec.ts
@@ -72,12 +72,20 @@ test.describe.serial(
 			).toBeVisible();
 			await expect(
 				page.locator( '#restock_refunded_items' )
-			).toBeChecked();
+			).toBeDisabled();

 			// Initiate a refund
 			await page.locator( '.refund_order_item_qty' ).fill( '1' );
 			await page.locator( '#refund_reason' ).fill( 'No longer wanted' );

+			// The product does not manage stock, so a quantity can't enable restocking.
+			await expect(
+				page.locator( '#restock_refunded_items' )
+			).toBeDisabled();
+			await expect(
+				page.locator( '#restock_refunded_items_description' )
+			).toHaveText( 'Stock was not reduced for this order' );
+
 			// Confirm values
 			await expect( page.locator( '.refund_line_total' ) ).toHaveValue(
 				'9.99'
@@ -234,11 +242,22 @@ test.describe(
 			).toBeVisible();
 			await expect(
 				page.locator( '#restock_refunded_items' )
-			).toBeChecked();
+			).toBeDisabled();
+			await expect(
+				page.locator( '#restock_refunded_items' )
+			).not.toBeChecked();

 			// Initiate a refund
 			await page.locator( '.refund_order_item_qty >> nth=1' ).fill( '2' );
 			await page.locator( '#refund_reason' ).fill( 'No longer wanted' );
+
+			// A quantity on a line holding stock enables restocking and ticks it.
+			await expect(
+				page.locator( '#restock_refunded_items' )
+			).toBeChecked();
+			await expect(
+				page.locator( '#restock_refunded_items_description' )
+			).toHaveText( '2 items will be restocked' );
 			page.on( 'dialog', ( dialog ) => dialog.accept() );
 			await page.locator( '.do-manual-refund' ).click();

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 01274385821..9cb96716a84 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
@@ -197,6 +197,56 @@ class WC_Admin_Meta_Box_Order_Items_View_Test extends WC_Unit_Test_Case {
 		$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.' );
 	}

+	/**
+	 * @testdox Refund quantity inputs flag only the lines that refunding can return to stock.
+	 */
+	public function test_refund_quantity_inputs_flag_restockable_lines(): void {
+		update_option( 'woocommerce_manage_stock', 'yes' );
+
+		$reduced_product   = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'manage_stock'   => true,
+				'stock_quantity' => 10,
+			)
+		);
+		$unmanaged_product = WC_Helper_Product::create_simple_product( true, array( 'manage_stock' => false ) );
+		$order             = wc_create_order();
+		$reduced_item_id   = $order->add_product( $reduced_product, 2 );
+		$unmanaged_item_id = $order->add_product( $unmanaged_product, 1 );
+		$order->calculate_totals();
+		$order->save();
+		wc_reduce_stock_levels( $order );
+
+		// Added after stock was reduced, so this line holds no stock to give back.
+		$not_reduced_item_id = $order->add_product( $reduced_product, 1 );
+		$order->save();
+		$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();
+
+		$xpath = $this->get_xpath_for_html( $order_items_html );
+
+		foreach (
+			array(
+				$reduced_item_id     => 'yes',
+				$unmanaged_item_id   => 'no',
+				$not_reduced_item_id => 'no',
+			) as $item_id => $expected
+		) {
+			$inputs = $xpath->query( "//input[@name='refund_order_item_qty[{$item_id}]']" );
+			$this->assertSame( 1, $inputs->length, "Line {$item_id} should render a refund quantity input." );
+			$this->assertSame( $expected, $inputs->item( 0 )->getAttribute( 'data-can-restock' ), "Line {$item_id} should flag whether it can be restocked." );
+		}
+
+		$checkbox = $xpath->query( "//input[@id='restock_refunded_items']" )->item( 0 );
+		$this->assertNotNull( $checkbox, 'The refund panel should render the restock checkbox.' );
+		$this->assertSame( 'restock_refunded_items_description', $checkbox->getAttribute( 'aria-describedby' ), 'The restock checkbox should point to its help text.' );
+		$this->assertSame( 1, $xpath->query( "//*[@id='restock_refunded_items_description']" )->length, 'The restock help text element should exist.' );
+	}
+
 	/**
 	 * Parses an HTML fragment and returns an XPath object for it.
 	 *