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