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