Commit c9d531c5e36 for woocommerce

commit c9d531c5e36f0b342d08aef5ba50160bf9d362af
Author: Raluca Stan <ralucastn@gmail.com>
Date:   Mon Jul 6 17:56:10 2026 +0200

    Restore reduced stock when an order transitions to failed (#66256)

    Core restores stock on cancelled and pending, but not on failed. An order
    that reduced stock while on-hold (e.g. an accepted-but-pending SEPA/ACH
    payment, or historically PayPal Standard eCheck) and then fails keeps its
    stock reduced, and the hold-stock cron only cancels pending orders, so it
    never self-heals.

    Hook wc_maybe_increase_stock_levels on woocommerce_order_status_failed. It is
    guarded by the _order_stock_reduced flag, so it restores only orders that
    actually reduced stock and is a no-op for a pending -> failed decline. A later
    successful retry re-reduces exactly once via the normal reduce hooks.

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

diff --git a/plugins/woocommerce/changelog/36702-fix-restore-stock-on-failed b/plugins/woocommerce/changelog/36702-fix-restore-stock-on-failed
new file mode 100644
index 00000000000..912e2a2228f
--- /dev/null
+++ b/plugins/woocommerce/changelog/36702-fix-restore-stock-on-failed
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Restore reduced stock when an order transitions to failed, so inventory held by an order that reduced stock while on-hold (e.g. an accepted-but-pending SEPA/ACH payment) is released instead of staying reduced indefinitely.
diff --git a/plugins/woocommerce/includes/wc-stock-functions.php b/plugins/woocommerce/includes/wc-stock-functions.php
index 29b6c709df0..69926fc739b 100644
--- a/plugins/woocommerce/includes/wc-stock-functions.php
+++ b/plugins/woocommerce/includes/wc-stock-functions.php
@@ -127,7 +127,8 @@ add_action( 'woocommerce_order_status_processing', 'wc_maybe_reduce_stock_levels
 add_action( 'woocommerce_order_status_on-hold', 'wc_maybe_reduce_stock_levels' );

 /**
- * When a payment is cancelled, restore stock.
+ * Restore stock for an order that reduced it, when it moves to a status that releases stock
+ * (cancelled, pending, or failed).
  *
  * @since 3.0.0
  * @param int $order_id Order ID.
@@ -154,6 +155,11 @@ function wc_maybe_increase_stock_levels( $order_id ) {
 }
 add_action( 'woocommerce_order_status_cancelled', 'wc_maybe_increase_stock_levels' );
 add_action( 'woocommerce_order_status_pending', 'wc_maybe_increase_stock_levels' );
+// Restore stock when a stock-reduced order fails. wc_maybe_increase_stock_levels() only acts when
+// the order carries the `_order_stock_reduced` flag, so it restores an order that reduced stock
+// (in practice one left on-hold by an async payment such as SEPA/ACH) and does nothing for a
+// pending -> failed decline that never reduced. Added in 11.0.0.
+add_action( 'woocommerce_order_status_failed', 'wc_maybe_increase_stock_levels' );

 /**
  * Reduce stock levels for items within an order, if stock has not already been reduced for the items.
diff --git a/plugins/woocommerce/tests/php/includes/wc-stock-functions-tests.php b/plugins/woocommerce/tests/php/includes/wc-stock-functions-tests.php
index 7d1e073c773..98176bf7c47 100644
--- a/plugins/woocommerce/tests/php/includes/wc-stock-functions-tests.php
+++ b/plugins/woocommerce/tests/php/includes/wc-stock-functions-tests.php
@@ -28,13 +28,13 @@ class WC_Stock_Functions_Tests extends \WC_Unit_Test_Case {
 	public $order_stock_restore_statuses = array(
 		OrderInternalStatus::CANCELLED,
 		OrderInternalStatus::PENDING,
+		OrderInternalStatus::FAILED,
 	);

 	/**
 	 * @var array List of statuses which have no impact on inventory.
 	 */
 	public $order_stock_no_effect_statuses = array(
-		OrderInternalStatus::FAILED,
 		OrderInternalStatus::REFUNDED,
 	);

@@ -268,6 +268,68 @@ class WC_Stock_Functions_Tests extends \WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * An order that reduced stock while on-hold (e.g. an accepted-but-pending payment) and then fails
+	 * should have its stock restored and its stock-reduced flag cleared, mirroring cancel/pending.
+	 */
+	public function test_on_hold_to_failed_restores_stock_and_clears_reduced_flag() {
+		$order      = $this->create_order_from_cart_with_status( OrderInternalStatus::ON_HOLD );
+		$product_id = array_values( $order->get_items( 'line_item' ) )[0]->get_product_id();
+
+		$this->assertEquals( 9, wc_get_product( $product_id )->get_stock_quantity(), 'On-hold should reduce stock by the ordered quantity.' );
+		$this->assertTrue( (bool) $order->get_data_store()->get_stock_reduced( $order->get_id() ), 'The stock-reduced flag should be set while on-hold.' );
+
+		$order->set_status( OrderInternalStatus::FAILED );
+		$order->save();
+
+		$this->assertEquals( 10, wc_get_product( $product_id )->get_stock_quantity(), 'Failing an order that reduced stock should restore it.' );
+		$this->assertFalse( (bool) $order->get_data_store()->get_stock_reduced( $order->get_id() ), 'The stock-reduced flag should be cleared after the stock is restored.' );
+
+		$restore_notes = array_filter(
+			wc_get_order_notes( array( 'order_id' => $order->get_id() ) ),
+			function ( $note ) {
+				return false !== strpos( $note->content, 'Stock levels increased' );
+			}
+		);
+		$this->assertNotEmpty( $restore_notes, 'A "Stock levels increased" order note should be recorded when the stock is restored.' );
+	}
+
+	/**
+	 * A plain pending -> failed order (a payment declined before any stock was reduced) should be a
+	 * no-op: there is nothing to restore, so stock stays put.
+	 */
+	public function test_pending_to_failed_does_not_change_stock() {
+		$order      = $this->create_order_from_cart_with_status( OrderInternalStatus::PENDING );
+		$product_id = array_values( $order->get_items( 'line_item' ) )[0]->get_product_id();
+
+		$this->assertEquals( 10, wc_get_product( $product_id )->get_stock_quantity(), 'Pending should not reduce stock.' );
+
+		$order->set_status( OrderInternalStatus::FAILED );
+		$order->save();
+
+		$this->assertEquals( 10, wc_get_product( $product_id )->get_stock_quantity(), 'Failing an order that never reduced stock should not change stock.' );
+	}
+
+	/**
+	 * Admin-path safety check. A failed order is a dead end in the gateway flow, but an admin can
+	 * manually move it back to a paid status. Because the failure already restored the stock, doing
+	 * so must reduce stock exactly once, not twice.
+	 */
+	public function test_reactivating_a_failed_order_reduces_stock_only_once() {
+		$order      = $this->create_order_from_cart_with_status( OrderInternalStatus::ON_HOLD );
+		$product_id = array_values( $order->get_items( 'line_item' ) )[0]->get_product_id();
+
+		$order->set_status( OrderInternalStatus::FAILED );
+		$order->save();
+		$this->assertEquals( 10, wc_get_product( $product_id )->get_stock_quantity(), 'Failure should restore the reduced stock.' );
+
+		// Admin manually re-activates the order.
+		$order->set_status( OrderInternalStatus::PROCESSING );
+		$order->save();
+		$this->assertEquals( 9, wc_get_product( $product_id )->get_stock_quantity(), 'Re-activating the order should reduce stock exactly once.' );
+		$this->assertTrue( (bool) $order->get_data_store()->get_stock_reduced( $order->get_id() ), 'The stock-reduced flag should be set again after re-activation.' );
+	}
+
 	/**
 	 * Assert that a value is equal to another one and is of integer type.
 	 *