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