Commit 1e064996998 for woocommerce
commit 1e06499699875763a46bf9b90656887989e3f8fa
Author: Thomas Roberts <5656702+opr@users.noreply.github.com>
Date: Fri Aug 14 12:37:39 2026 +0100
Fix shopper blocked by their own stock hold after order cancellation (#67471)
* Fix cart stock check skipping the draft-order fallback on a stored false
WC_Cart::check_cart_item_stock() read order_awaiting_payment through the magic
property, and WC_Session::__isset() returns true for a stored boolean false.
absint(false) then yielded 0, so the store_api_draft_order fallback (added in
#42796) was never consulted and the shopper's own held order could be counted
against them.
A persistent false is written by the customer order-cancel path
(WC_Form_Handler / WC_Abstract_Order::cancel_order()); the completed-payment
write is unset again by destroy_cart_session() on cart empty, so that path
self-heals. Reading via get() and treating any falsy value (false/null/0) as
"no order" removes the __isset/false hazard and restores the fallback.
This is oversell-safe and is scoped to the single-order case only. It does NOT
address the single-id exclusion in check_cart_item_stock() (the primary
reproduction in #67354, which needs two or more of the shopper's own unpaid
orders); that case cannot be relaxed without risking an oversell and is left
for separate discussion.
Adds regression coverage for the false case plus baseline fallback, awaiting-id
exclusion, and an oversell-safety guard.
Refs #67354
* Clarify the awaiting-payment comment and cover order precedence
* Shorten the changelog entry for the stock hold fix
diff --git a/plugins/woocommerce/changelog/67354-fix-own-stock-reservation-lockout b/plugins/woocommerce/changelog/67354-fix-own-stock-reservation-lockout
new file mode 100644
index 00000000000..e9c22581ef7
--- /dev/null
+++ b/plugins/woocommerce/changelog/67354-fix-own-stock-reservation-lockout
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix a shopper being blocked in the checkout shortcode by a stock hold created by their own cancelled order.
diff --git a/plugins/woocommerce/includes/class-wc-cart.php b/plugins/woocommerce/includes/class-wc-cart.php
index d513a481073..ac2f70399dc 100644
--- a/plugins/woocommerce/includes/class-wc-cart.php
+++ b/plugins/woocommerce/includes/class-wc-cart.php
@@ -879,9 +879,15 @@ class WC_Cart extends WC_Legacy_Cart {
* @return bool|WP_Error
*/
public function check_cart_item_stock() {
- $error = new WP_Error();
- $product_qty_in_cart = $this->get_cart_item_quantities();
- $current_session_order_id = isset( WC()->session->order_awaiting_payment ) ? absint( WC()->session->order_awaiting_payment ) : absint( WC()->session->get( 'store_api_draft_order', 0 ) );
+ $error = new WP_Error();
+ $product_qty_in_cart = $this->get_cart_item_quantities();
+ // Identify the shopper's own order so its stock hold is not counted against them.
+ // The classic checkout stores an order ID in `order_awaiting_payment`, but completing a
+ // payment or cancelling an unpaid order writes `false` there instead of unsetting it, so
+ // treat any falsy value as "no order" and fall back to the Store API draft order. Read the
+ // value with get(), because WC_Session::__isset() reports a stored `false` as set.
+ $order_awaiting_payment = absint( WC()->session->get( 'order_awaiting_payment' ) );
+ $current_session_order_id = $order_awaiting_payment ? $order_awaiting_payment : absint( WC()->session->get( 'store_api_draft_order', 0 ) );
foreach ( $this->get_cart() as $values ) {
$product = $values['data'];
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
index 0ae20d8f236..5f519de19a6 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
@@ -5,6 +5,7 @@
* @package WooCommerce\Tests\Cart.
*/
+use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
@@ -904,6 +905,162 @@ class WC_Cart_Test extends \WC_Unit_Test_Case {
$order->delete( true );
}
+ /**
+ * Set up a stock-managed product with exactly one unit, held by a separate unpaid order.
+ *
+ * Mirrors the state an abandoned checkout leaves behind: the last unit is reserved by an
+ * order that belongs to the shopper's own session.
+ *
+ * @return array{0: WC_Product, 1: WC_Order} The product and the order holding its stock.
+ */
+ private function create_last_unit_product_held_by_order(): array {
+ $product = $this->create_stock_managed_product( 1 );
+ $holding_order = $this->create_order_holding_stock( $product, 1 );
+
+ // Sanity check: the separate order really holds the only unit.
+ $this->assertEquals( 1, wc_get_held_stock_quantity( wc_get_product( $product->get_id() ), 0 ) );
+
+ return array( $product, $holding_order );
+ }
+
+ /**
+ * Create a simple product that manages stock, with no backorders.
+ *
+ * @param int $stock_quantity How many units the product has in stock.
+ * @return WC_Product The product.
+ */
+ private function create_stock_managed_product( int $stock_quantity ): WC_Product {
+ update_option( 'woocommerce_manage_stock', 'yes' );
+ update_option( 'woocommerce_hold_stock_minutes', 60 );
+ // ReserveStock is only enabled once the reserved-stock table has shipped (schema >= 430).
+ update_option( 'woocommerce_schema_version', 430 );
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_manage_stock( true );
+ $product->set_stock_quantity( $stock_quantity );
+ $product->set_backorders( 'no' );
+ $product->set_stock_status( 'instock' );
+ $product->save();
+
+ return $product;
+ }
+
+ /**
+ * Create an unpaid order that holds a quantity of a product, as an in-progress checkout does.
+ *
+ * @param WC_Product $product The product to hold stock for.
+ * @param int $quantity How many units the order holds.
+ * @return WC_Order The order holding the stock.
+ */
+ private function create_order_holding_stock( WC_Product $product, int $quantity ): WC_Order {
+ $holding_order = WC_Helper_Order::create_order();
+ $holding_order->remove_order_items();
+ $holding_order->add_product( wc_get_product( $product->get_id() ), $quantity );
+ $holding_order->set_status( OrderStatus::PENDING );
+ $holding_order->save();
+
+ ( new ReserveStock() )->reserve_stock_for_order( $holding_order, 60 );
+
+ return $holding_order;
+ }
+
+ /**
+ * @testdox check_cart_item_stock does not block the shopper when their own hold is tracked by store_api_draft_order and order_awaiting_payment is boolean false (Cause A: payment_complete() writes false, not unset).
+ */
+ public function test_check_cart_item_stock_not_blocked_by_own_hold_when_awaiting_payment_is_false() {
+ list( $product, $holding_order ) = $this->create_last_unit_product_held_by_order();
+
+ WC()->cart->empty_cart();
+ WC()->cart->add_to_cart( $product->get_id(), 1 );
+
+ // The draft pointer correctly identifies the shopper's own holding order...
+ WC()->session->set( 'store_api_draft_order', $holding_order->get_id() );
+ // ...but a completed payment earlier in the session left this as boolean false rather than unsetting it.
+ WC()->session->set( 'order_awaiting_payment', false );
+
+ $this->assertTrue(
+ WC()->cart->check_cart_item_stock(),
+ 'A boolean-false order_awaiting_payment must fall through to the store_api_draft_order pointer, not skip it.'
+ );
+ }
+
+ /**
+ * @testdox check_cart_item_stock does not block the shopper when only store_api_draft_order identifies their own hold (baseline fallback, no order_awaiting_payment set).
+ */
+ public function test_check_cart_item_stock_not_blocked_by_own_hold_via_draft_fallback() {
+ list( $product, $holding_order ) = $this->create_last_unit_product_held_by_order();
+
+ WC()->cart->empty_cart();
+ WC()->cart->add_to_cart( $product->get_id(), 1 );
+
+ WC()->session->set( 'store_api_draft_order', $holding_order->get_id() );
+ WC()->session->set( 'order_awaiting_payment', null );
+
+ $this->assertTrue( WC()->cart->check_cart_item_stock() );
+ }
+
+ /**
+ * @testdox check_cart_item_stock uses order_awaiting_payment to exclude the shopper's own hold when it holds a real order id.
+ */
+ public function test_check_cart_item_stock_uses_order_awaiting_payment_when_set() {
+ list( $product, $holding_order ) = $this->create_last_unit_product_held_by_order();
+
+ WC()->cart->empty_cart();
+ WC()->cart->add_to_cart( $product->get_id(), 1 );
+
+ WC()->session->set( 'store_api_draft_order', 0 );
+ WC()->session->set( 'order_awaiting_payment', $holding_order->get_id() );
+
+ $this->assertTrue( WC()->cart->check_cart_item_stock() );
+ }
+
+ /**
+ * @testdox check_cart_item_stock still blocks when a live hold is NOT identified as the shopper's own (oversell safety: the fix must not relax holds it cannot attribute to this session).
+ */
+ public function test_check_cart_item_stock_blocks_when_hold_is_not_the_shoppers_own() {
+ list( $product ) = $this->create_last_unit_product_held_by_order();
+
+ WC()->cart->empty_cart();
+ WC()->cart->add_to_cart( $product->get_id(), 1 );
+
+ // Session points at neither the holding order nor any awaiting order.
+ WC()->session->set( 'store_api_draft_order', 0 );
+ WC()->session->set( 'order_awaiting_payment', false );
+
+ $result = WC()->cart->check_cart_item_stock();
+ $this->assertInstanceOf( WP_Error::class, $result );
+ $this->assertContains( 'out-of-stock', $result->get_error_codes() );
+ }
+
+ /**
+ * @testdox check_cart_item_stock prefers order_awaiting_payment over store_api_draft_order when both point at a live hold (the classic checkout order wins).
+ */
+ public function test_check_cart_item_stock_prefers_order_awaiting_payment_over_draft_order() {
+ // Three units in stock: the classic checkout order holds two of them, the draft order holds one.
+ $product = $this->create_stock_managed_product( 3 );
+ $classic_order = $this->create_order_holding_stock( $product, 2 );
+ $draft_order = $this->create_order_holding_stock( $product, 1 );
+
+ // Sanity check: between them, the two orders hold all three units.
+ $this->assertEquals( 3, wc_get_held_stock_quantity( wc_get_product( $product->get_id() ), 0 ) );
+
+ WC()->cart->empty_cart();
+ WC()->cart->add_to_cart( $product->get_id(), 2 );
+
+ WC()->session->set( 'order_awaiting_payment', $classic_order->get_id() );
+ WC()->session->set( 'store_api_draft_order', $draft_order->get_id() );
+
+ /*
+ * Excluding the classic order leaves one unit held, so the two units in the cart fit into
+ * the three in stock. Excluding the draft order instead would leave two units held and
+ * block the cart, so this assertion only holds while order_awaiting_payment takes priority.
+ */
+ $this->assertTrue(
+ WC()->cart->check_cart_item_stock(),
+ 'order_awaiting_payment must take priority over store_api_draft_order when both hold stock.'
+ );
+ }
+
/**
* @testdox Should clear the cart after payment when the order cart hash matches.
*/