Commit 0586265ba62 for woocommerce
commit 0586265ba629d51bdab4e2649b65f22f8c7d1bf2
Author: Thomas Roberts <5656702+opr@users.noreply.github.com>
Date: Tue Sep 29 14:40:02 2026 +0100
Add opt-in exclusion for shoppers' stale stock holds (#68963)
* Add opt-in exclusion for shoppers' stale stock holds
* Add changelog for stale stock hold exclusion
diff --git a/plugins/woocommerce/changelog/fix-67354-own-reserved-stock-grace-window b/plugins/woocommerce/changelog/fix-67354-own-reserved-stock-grace-window
new file mode 100644
index 00000000000..bee4c4ceb10
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-67354-own-reserved-stock-grace-window
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add an opt-in filter (woocommerce_own_stock_hold_exclusion_threshold_minutes) for excluding a shopper's own stale stock holds.
diff --git a/plugins/woocommerce/src/Checkout/Helpers/ReserveStock.php b/plugins/woocommerce/src/Checkout/Helpers/ReserveStock.php
index 3ecf85a47bf..e507401040e 100644
--- a/plugins/woocommerce/src/Checkout/Helpers/ReserveStock.php
+++ b/plugins/woocommerce/src/Checkout/Helpers/ReserveStock.php
@@ -7,6 +7,7 @@ namespace Automattic\WooCommerce\Checkout\Helpers;
use Automattic\WooCommerce\Enums\OrderInternalStatus;
use Automattic\WooCommerce\Enums\OrderItemType;
+use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Utilities\OrderUtil;
use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
@@ -17,6 +18,22 @@ defined( 'ABSPATH' ) || exit;
*/
final class ReserveStock {
+ /**
+ * Session key listing orders this shopper has taken a stock hold for.
+ *
+ * Holds array( 'customer' => string, 'order_ids' => int[] ), where `customer`
+ * is the session customer id that recorded them.
+ */
+ private const OWN_ORDERS_SESSION_KEY = 'stock_holding_orders';
+
+ /**
+ * Upper bound on own orders considered when excluding a shopper's stale holds.
+ *
+ * Keeps the generated IN clause bounded. A shopper who exceeds it loses the
+ * oldest ids from the list, and those holds block again until they expire.
+ */
+ private const MAX_OWN_ORDERS = 10;
+
/**
* Is stock reservation enabled?
*
@@ -24,6 +41,16 @@ final class ReserveStock {
*/
private $enabled = true;
+ /**
+ * Request scoped memo of a signed in customer's potential stock-holding order ids.
+ *
+ * A new ReserveStock is constructed for each wc_get_held_stock_quantity()
+ * call, so the memo cannot live on the instance.
+ *
+ * @var array<int, int[]>
+ */
+ private static $potential_order_ids_by_customer = array();
+
/**
* Constructor
*/
@@ -149,6 +176,8 @@ final class ReserveStock {
foreach ( $rows as $product_id => $quantity ) {
$this->reserve_stock_for_product( $product_id, $quantity, $order, $minutes );
}
+
+ $this->remember_order_for_shopper( $order );
}
} catch ( ReserveStockException $e ) {
$this->release_stock_for_order( $order );
@@ -293,6 +322,8 @@ final class ReserveStock {
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $query .= $this->get_stale_own_holds_exclusion_clause( $product_id, $exclude_order_id );
+
/**
* Filter: woocommerce_query_for_reserved_stock
* Allows to filter the query for getting reserved stock of a product.
@@ -304,4 +335,184 @@ final class ReserveStock {
*/
return apply_filters( 'woocommerce_query_for_reserved_stock', $query, $product_id, $exclude_order_id );
}
+
+ /**
+ * Builds a SQL clause excluding the current shopper's stale stock holds.
+ *
+ * @param int $product_id Product whose reserved stock is being counted.
+ * @param int $exclude_order_id Order already excluded by the caller.
+ * @return string SQL clause, or an empty string when no holds qualify.
+ */
+ private function get_stale_own_holds_exclusion_clause( $product_id, $exclude_order_id ): string {
+ global $wpdb;
+
+ /**
+ * Filters how many minutes must pass before a shopper's own unpaid stock hold
+ * stops blocking that same shopper.
+ *
+ * The exclusion is disabled by default. Enabling it can create multiple payable
+ * orders for the same stock and cause overselling if an older payment later
+ * completes. Return 0 to exclude the shopper's own holds immediately.
+ *
+ * @since 11.3.0
+ *
+ * @param int|false $threshold_minutes Threshold in minutes, or false to keep the exclusion disabled.
+ * @param int $product_id Product whose reserved stock is being counted.
+ * @param int $exclude_order_id Order already excluded by the caller.
+ */
+ $threshold_minutes = apply_filters( 'woocommerce_own_stock_hold_exclusion_threshold_minutes', false, $product_id, $exclude_order_id );
+
+ if ( false === $threshold_minutes || ! is_numeric( $threshold_minutes ) ) {
+ return '';
+ }
+
+ $threshold_minutes = max( 0, (int) $threshold_minutes );
+
+ $order_ids = array_values( array_diff( $this->get_current_shopper_stock_holding_order_ids(), array( absint( $exclude_order_id ) ) ) );
+
+ if ( empty( $order_ids ) ) {
+ return '';
+ }
+
+ $placeholders = implode( ',', array_fill( 0, count( $order_ids ), '%d' ) );
+
+ // The `timestamp` column records when the hold was first placed, so it
+ // measures the age of the shopper's attempt rather than of the last write.
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The interpolated value contains only generated %d placeholders.
+ return $wpdb->prepare(
+ "
+ AND NOT (
+ stock_table.`order_id` IN ( $placeholders )
+ AND stock_table.`timestamp` < ( NOW() - INTERVAL %d MINUTE )
+ )
+ ",
+ array_merge( $order_ids, array( $threshold_minutes ) )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ }
+
+ /**
+ * Returns stock-holding order ids associated with the current shopper.
+ *
+ * Includes orders recorded by the current session and unpaid orders belonging
+ * to the signed-in customer. Ownership is never inferred from anything a
+ * shopper can type, such as the billing email.
+ *
+ * @return int[]
+ */
+ private function get_current_shopper_stock_holding_order_ids(): array {
+ $session = WC()->session;
+
+ // In the admin, WP-CLI and cron there is no shopper and WC()->session is
+ // not initialised, so nothing is ever treated as the requester's own hold.
+ if ( ! $session instanceof \WC_Session ) {
+ return array();
+ }
+
+ $order_ids = $this->get_session_stock_holding_order_ids( $session );
+
+ $customer_id = get_current_user_id();
+
+ if ( $customer_id ) {
+ $order_ids = array_merge( $order_ids, $this->get_potential_stock_holding_order_ids_for_customer( $customer_id ) );
+ }
+
+ return array_values( array_filter( array_unique( $order_ids ) ) );
+ }
+
+ /**
+ * Returns the stock-holding order ids recorded against the current session.
+ *
+ * The list is only trusted if it was written under this session's customer id.
+ *
+ * @param \WC_Session $session Session of the shopper making this request.
+ * @return int[]
+ */
+ private function get_session_stock_holding_order_ids( \WC_Session $session ): array {
+ $remembered = $session->get( self::OWN_ORDERS_SESSION_KEY, array() );
+
+ if ( ! is_array( $remembered ) ) {
+ return array();
+ }
+
+ if ( ! isset( $remembered['customer'], $remembered['order_ids'] ) || ! is_array( $remembered['order_ids'] ) ) {
+ return array();
+ }
+
+ // Core hands session data to a different shopper in two places (cart token
+ // cloning and guest-to-user migration), and both change the session
+ // customer id first. A mismatch means the list arrived from somebody
+ // else's session, so it confers no ownership.
+ if ( (string) $remembered['customer'] !== (string) $session->get_customer_id() ) {
+ return array();
+ }
+
+ return array_slice( array_map( 'absint', $remembered['order_ids'] ), 0, self::MAX_OWN_ORDERS );
+ }
+
+ /**
+ * Records a stock-holding order against the current customer session.
+ *
+ * The session's customer id is stamped alongside the ids so the list can be
+ * discarded if it later turns up in somebody else's session. See
+ * get_session_stock_holding_order_ids().
+ *
+ * @param \WC_Order $order Order that now holds stock.
+ */
+ private function remember_order_for_shopper( \WC_Order $order ): void {
+ $session = WC()->session;
+
+ // An order created in the admin or over WP-CLI has no session to record against.
+ if ( ! $session instanceof \WC_Session ) {
+ return;
+ }
+
+ $order_ids = $this->get_session_stock_holding_order_ids( $session );
+
+ array_unshift( $order_ids, $order->get_id() );
+
+ $order_ids = array_slice( array_values( array_unique( array_map( 'absint', $order_ids ) ) ), 0, self::MAX_OWN_ORDERS );
+
+ $session->set(
+ self::OWN_ORDERS_SESSION_KEY,
+ array(
+ 'customer' => (string) $session->get_customer_id(),
+ 'order_ids' => $order_ids,
+ )
+ );
+ }
+
+ /**
+ * Returns order ids of a signed-in customer that could be holding stock, newest first.
+ *
+ * Covers a shopper whose session pointers were lost (new device, cleared
+ * cookies, lapsed session). The statuses match the ones the reserved stock
+ * query counts.
+ *
+ * @param int $customer_id Customer ID.
+ * @return int[]
+ */
+ private function get_potential_stock_holding_order_ids_for_customer( int $customer_id ): array {
+ if ( isset( self::$potential_order_ids_by_customer[ $customer_id ] ) ) {
+ return self::$potential_order_ids_by_customer[ $customer_id ];
+ }
+
+ /** @var int[] $order_ids */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
+ $order_ids = wc_get_orders(
+ array(
+ 'customer' => $customer_id,
+ 'status' => array( OrderStatus::CHECKOUT_DRAFT, OrderStatus::PENDING ),
+ 'limit' => self::MAX_OWN_ORDERS,
+ 'orderby' => 'date',
+ 'order' => 'DESC',
+ 'return' => 'ids',
+ )
+ );
+
+ $order_ids = array_map( 'absint', $order_ids );
+
+ self::$potential_order_ids_by_customer[ $customer_id ] = $order_ids;
+
+ return $order_ids;
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Checkout/Helpers/ReserveStockOwnHoldsTest.php b/plugins/woocommerce/tests/php/src/Checkout/Helpers/ReserveStockOwnHoldsTest.php
new file mode 100644
index 00000000000..c03fc4844eb
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Checkout/Helpers/ReserveStockOwnHoldsTest.php
@@ -0,0 +1,499 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Checkout\Helpers;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use WC_Customer;
+use WC_Helper_Order;
+use WC_Helper_Product;
+use WC_Product_Simple;
+use WC_Session_Handler;
+use WC_Unit_Test_Case;
+
+/**
+ * Class ReserveStockOwnHoldsTest.
+ *
+ * Exercises the opt-in threshold that stops a shopper being blocked by their
+ * own stale, unpaid stock hold:
+ *
+ * 1. A hold belonging to another shopper always blocks, however old it is.
+ * 2. The shopper's own hold blocks inside the threshold and is excluded past it.
+ * 3. Ownership never comes from anything the shopper can type.
+ * 4. With no session (admin, WP-CLI, cron) nothing is excluded.
+ * 5. The woocommerce_query_for_reserved_stock signature is unchanged.
+ *
+ * Holds are written straight into wc_reserved_stock so `timestamp` can be aged
+ * exactly. Tests exercising the session list call wc_reserve_stock_for_order()
+ * for real.
+ */
+class ReserveStockOwnHoldsTest extends WC_Unit_Test_Case {
+
+ /**
+ * How long the reservation itself lasts, well beyond any threshold used here.
+ */
+ private const HOLD_MINUTES = 2880;
+
+ /**
+ * The session the bootstrap set up, put back in tearDown.
+ *
+ * @var \WC_Session|null
+ */
+ private $original_session;
+
+ /**
+ * The customer the bootstrap set up, put back in tearDown.
+ *
+ * @var WC_Customer|null
+ */
+ private $original_customer;
+
+ /**
+ * $_COOKIE as it was before the test, put back in tearDown.
+ *
+ * @var array
+ */
+ private $original_cookies;
+
+ /**
+ * Set up a shopper session and a store that manages stock.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ update_option( 'woocommerce_manage_stock', 'yes' );
+ update_option( 'woocommerce_schema_version', 430 );
+ update_option( 'woocommerce_hold_stock_minutes', self::HOLD_MINUTES );
+
+ $this->original_session = WC()->session;
+ $this->original_customer = WC()->customer;
+
+ // Earlier suites can leave a valid session cookie in $_COOKIE (the test
+ // framework never resets it), and WC_Session_Handler::init() would then
+ // give every session in these tests that cookie's customer id instead of
+ // minting distinct ones.
+ $this->original_cookies = $_COOKIE;
+
+ /** This filter is documented in includes/class-wc-session-handler.php */
+ unset( $_COOKIE[ (string) apply_filters( 'woocommerce_cookie', 'wp_woocommerce_session_' . COOKIEHASH ) ] );
+
+ WC()->session = new WC_Session_Handler();
+ WC()->session->init();
+ }
+
+ /**
+ * Leave no session, user or reservation behind.
+ *
+ * The session and customer singletons are restored rather than nulled: this
+ * suite runs in one process, and a null WC()->session fatals later classes.
+ */
+ public function tearDown(): void {
+ global $wpdb;
+
+ $wpdb->query( "DELETE FROM {$wpdb->wc_reserved_stock}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ wp_set_current_user( 0 );
+
+ $_COOKIE = $this->original_cookies;
+ WC()->session = $this->original_session;
+ WC()->customer = $this->original_customer;
+
+ parent::tearDown();
+ }
+
+ // ---------------------------------------------------------------------
+ // 1. Cross-shopper isolation.
+ // ---------------------------------------------------------------------
+
+ /**
+ * @testdox A stale hold belonging to another shopper remains counted when the exclusion is enabled.
+ */
+ public function test_own_stale_hold_does_not_exclude_another_shoppers_hold(): void {
+ $product = $this->create_stock_managed_product( 2 );
+ $shopper = $this->create_signed_in_shopper();
+ $mine = $this->create_unpaid_order_for( $shopper, $product );
+ $theirs = $this->create_shopper_with_unpaid_order( $product );
+
+ $this->hold_stock( $mine, $product->get_id(), 1, 120 );
+ $this->hold_stock( $theirs, $product->get_id(), 1, 120 );
+
+ $this->assertSame( 1, $this->get_held_stock_quantity_with_threshold( $product, 0, 10 ) );
+ }
+
+ // ---------------------------------------------------------------------
+ // 2. The exclusion threshold.
+ // ---------------------------------------------------------------------
+
+ /**
+ * @testdox The shopper's own hold remains counted inside an opted-in threshold.
+ */
+ public function test_own_fresh_hold_still_blocks(): void {
+ $product = $this->create_stock_managed_product( 1 );
+ $shopper = $this->create_signed_in_shopper();
+ $order = $this->create_unpaid_order_for( $shopper, $product );
+
+ $this->hold_stock( $order, $product->get_id(), 1, 9 );
+
+ $this->assertSame( 1, $this->get_held_stock_quantity_with_threshold( $product, 0, 10 ) );
+ }
+
+ /**
+ * @testdox The shopper's own hold is excluded after an opted-in threshold.
+ */
+ public function test_own_stale_hold_is_excluded(): void {
+ $product = $this->create_stock_managed_product( 1 );
+ $shopper = $this->create_signed_in_shopper();
+ $order = $this->create_unpaid_order_for( $shopper, $product );
+
+ $this->hold_stock( $order, $product->get_id(), 1, 11 );
+
+ $this->assertSame( 0, $this->get_held_stock_quantity_with_threshold( $product, 0, 10 ) );
+ }
+
+ /**
+ * @testdox The shopper's own stale hold remains counted by default.
+ */
+ public function test_own_stale_hold_blocks_by_default(): void {
+ $product = $this->create_stock_managed_product( 1 );
+ $shopper = $this->create_signed_in_shopper();
+ $order = $this->create_unpaid_order_for( $shopper, $product );
+
+ $this->hold_stock( $order, $product->get_id(), 1, 2000 );
+
+ $this->assertSame( 1, wc_get_held_stock_quantity( $product, 0 ) );
+ }
+
+ /**
+ * @testdox An invalid threshold leaves the shopper's own hold counted.
+ */
+ public function test_invalid_threshold_keeps_own_hold_blocking(): void {
+ $product = $this->create_stock_managed_product( 1 );
+ $shopper = $this->create_signed_in_shopper();
+ $order = $this->create_unpaid_order_for( $shopper, $product );
+
+ $this->hold_stock( $order, $product->get_id(), 1, 2000 );
+
+ $return_invalid_threshold = static function () {
+ return array();
+ };
+
+ add_filter( 'woocommerce_own_stock_hold_exclusion_threshold_minutes', $return_invalid_threshold );
+ try {
+ $held = wc_get_held_stock_quantity( $product, 0 );
+ } finally {
+ remove_filter( 'woocommerce_own_stock_hold_exclusion_threshold_minutes', $return_invalid_threshold );
+ }
+
+ $this->assertSame( 1, $held );
+ }
+
+ /**
+ * @testdox A zero threshold excludes the shopper's own hold immediately.
+ */
+ public function test_zero_threshold_excludes_own_hold_immediately(): void {
+ $product = $this->create_stock_managed_product( 1 );
+ $shopper = $this->create_signed_in_shopper();
+ $order = $this->create_unpaid_order_for( $shopper, $product );
+
+ $this->hold_stock( $order, $product->get_id(), 1, 5 );
+
+ $this->assertSame( 0, $this->get_held_stock_quantity_with_threshold( $product, 0, 0 ) );
+ }
+
+ // ---------------------------------------------------------------------
+ // 3. Ownership sources.
+ // ---------------------------------------------------------------------
+
+ /**
+ * @testdox The session list identifies a guest's hold after the draft pointer moves to a new order.
+ */
+ public function test_session_list_covers_a_guest_whose_draft_pointer_moved_on(): void {
+ $product = $this->create_stock_managed_product( 1 );
+ $order = $this->create_unpaid_order_for( 0, $product );
+
+ // The real reservation path, which is what records the order in the session.
+ wc_reserve_stock_for_order( wc_get_order( $order ) );
+ $this->age_hold( $order, 11 );
+
+ // The Store API has since minted a new draft and repointed the session at it.
+ $new_draft = $this->create_unpaid_order_for( 0, $product, OrderStatus::CHECKOUT_DRAFT );
+ WC()->session->set( 'store_api_draft_order', $new_draft );
+
+ $this->assertSame( 0, $this->get_held_stock_quantity_with_threshold( $product, $new_draft, 10 ) );
+ }
+
+ /**
+ * @testdox A session list copied from another shopper does not confer ownership.
+ */
+ public function test_borrowed_session_list_confers_no_ownership(): void {
+ $product = $this->create_stock_managed_product( 1 );
+ $order = $this->create_unpaid_order_for( 0, $product );
+
+ wc_reserve_stock_for_order( wc_get_order( $order ) );
+ $this->age_hold( $order, 11 );
+
+ $borrowed = WC()->session->get( 'stock_holding_orders', array() );
+
+ // A second shopper, whose session carries the first shopper's list verbatim
+ // but under their own, different customer id.
+ WC()->session = new WC_Session_Handler();
+ WC()->session->init();
+ WC()->session->set( 'stock_holding_orders', $borrowed );
+
+ $this->assertNotSame(
+ $borrowed['customer'],
+ (string) WC()->session->get_customer_id(),
+ 'Precondition: the borrowing session has a different customer id.'
+ );
+ $this->assertSame( 1, $this->get_held_stock_quantity_with_threshold( $product, 0, 10 ) );
+ }
+
+ /**
+ * @testdox A matching billing email does not confer ownership of another shopper's hold.
+ */
+ public function test_billing_email_confers_no_ownership(): void {
+ $product = $this->create_stock_managed_product( 1 );
+ $victim = $this->create_signed_in_shopper( 'victim@example.com' );
+ $order = $this->create_unpaid_order_for( $victim, $product );
+ $victim_order = wc_get_order( $order );
+ $victim_order->set_billing_email( 'victim@example.com' );
+ $victim_order->save();
+
+ $this->hold_stock( $order, $product->get_id(), 1, 120 );
+
+ // Now a different, unrelated shopper claiming the same address.
+ wp_set_current_user( 0 );
+ WC()->session = new WC_Session_Handler();
+ WC()->session->init();
+ WC()->customer = new WC_Customer();
+ WC()->customer->set_billing_email( 'victim@example.com' );
+
+ $this->assertSame( 1, $this->get_held_stock_quantity_with_threshold( $product, 0, 10 ) );
+ }
+
+ /**
+ * @testdox A guest's holds beyond the session list limit remain counted.
+ */
+ public function test_own_holds_beyond_the_session_list_limit_block_again(): void {
+ $product = $this->create_stock_managed_product( 20 );
+
+ // One more attempt than the list keeps.
+ for ( $i = 0; $i < 11; $i++ ) {
+ $order = $this->create_unpaid_order_for( 0, $product );
+ wc_reserve_stock_for_order( wc_get_order( $order ) );
+ $this->age_hold( $order, 11 );
+ }
+
+ // The list keeps the ten newest ids, so the first order's hold counts
+ // again while the remembered ten are excluded.
+ $this->assertSame( 1, $this->get_held_stock_quantity_with_threshold( $product, 0, 10 ) );
+ }
+
+ // ---------------------------------------------------------------------
+ // 4. Contexts with no shopper.
+ // ---------------------------------------------------------------------
+
+ /**
+ * @testdox No holds are excluded when there is no shopper session.
+ */
+ public function test_no_session_leaves_the_total_unchanged(): void {
+ $product = $this->create_stock_managed_product( 1 );
+ $shopper = $this->create_signed_in_shopper();
+ $order = $this->create_unpaid_order_for( $shopper, $product );
+
+ $this->hold_stock( $order, $product->get_id(), 1, 120 );
+
+ WC()->session = null;
+
+ $this->assertSame( 1, $this->get_held_stock_quantity_with_threshold( $product, 0, 10 ) );
+ }
+
+ // ---------------------------------------------------------------------
+ // 5. The public filter contract.
+ // ---------------------------------------------------------------------
+
+ /**
+ * @testdox The existing reserved-stock query filter receives its unchanged arguments and the exclusion clause.
+ */
+ public function test_filter_signature_is_unchanged(): void {
+ $product = $this->create_stock_managed_product( 1 );
+ $shopper = $this->create_signed_in_shopper();
+ $order = $this->create_unpaid_order_for( $shopper, $product );
+
+ $this->hold_stock( $order, $product->get_id(), 1, 120 );
+
+ $seen = array();
+
+ $capture = function ( $query, $product_id, $exclude_order_id ) use ( &$seen ) {
+ $seen = array(
+ 'query' => $query,
+ 'product_id' => $product_id,
+ 'exclude_order_id' => $exclude_order_id,
+ );
+
+ return $query;
+ };
+
+ add_filter( 'woocommerce_query_for_reserved_stock', $capture, 10, 3 );
+ try {
+ $this->get_held_stock_quantity_with_threshold( $product, 4242, 10 );
+ } finally {
+ remove_filter( 'woocommerce_query_for_reserved_stock', $capture, 10 );
+ }
+
+ $this->assertIsInt( $seen['exclude_order_id'] );
+ $this->assertSame( 4242, $seen['exclude_order_id'] );
+ $this->assertSame( $product->get_id(), $seen['product_id'] );
+ $this->assertStringContainsString( 'AND NOT (', $seen['query'], 'The filter sees the complete core query.' );
+ }
+
+ // ---------------------------------------------------------------------
+ // Helpers.
+ // ---------------------------------------------------------------------
+
+ /**
+ * Gets held stock with the own-hold exclusion explicitly enabled.
+ *
+ * @param WC_Product_Simple $product Product whose held stock is requested.
+ * @param int $exclude_order_id Order already excluded by the caller.
+ * @param int $threshold Own-hold exclusion threshold in minutes.
+ * @return int
+ */
+ private function get_held_stock_quantity_with_threshold( WC_Product_Simple $product, int $exclude_order_id, int $threshold ): int {
+ $set_threshold = static function () use ( $threshold ) {
+ return $threshold;
+ };
+
+ add_filter( 'woocommerce_own_stock_hold_exclusion_threshold_minutes', $set_threshold );
+ try {
+ return wc_get_held_stock_quantity( $product, $exclude_order_id );
+ } finally {
+ remove_filter( 'woocommerce_own_stock_hold_exclusion_threshold_minutes', $set_threshold );
+ }
+ }
+
+ /**
+ * A simple product managing a fixed quantity of stock.
+ *
+ * @param int $quantity Stock quantity.
+ * @return WC_Product_Simple
+ */
+ private function create_stock_managed_product( int $quantity ): WC_Product_Simple {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_manage_stock( true );
+ $product->set_stock_quantity( $quantity );
+ $product->save();
+
+ return $product;
+ }
+
+ /**
+ * A signed in shopper. A fresh user per test, because the customer lookup is
+ * memoised for the life of the request.
+ *
+ * @param string $email Optional email.
+ * @return int User ID.
+ */
+ private function create_signed_in_shopper( string $email = '' ): int {
+ $user_id = wp_insert_user(
+ array(
+ 'user_login' => uniqid( 'shopper_' ),
+ 'user_pass' => wp_generate_password(),
+ 'user_email' => '' !== $email ? $email : uniqid( 'shopper_' ) . '@example.com',
+ 'role' => 'customer',
+ )
+ );
+
+ wp_set_current_user( $user_id );
+
+ return (int) $user_id;
+ }
+
+ /**
+ * An unpaid order for a customer, holding one unit of the product.
+ *
+ * WC_Helper_Order::create_order() hard-codes a line quantity of 4, so the
+ * line item is rebuilt — with a save() between remove and add, without which
+ * the order reloads with no items and reserves nothing.
+ *
+ * @param int $customer_id Customer ID, 0 for a guest.
+ * @param WC_Product_Simple $product Product to add.
+ * @param string $status Order status, unpaid pending by default.
+ * @return int Order ID.
+ */
+ private function create_unpaid_order_for( int $customer_id, WC_Product_Simple $product, string $status = OrderStatus::PENDING ): int {
+ $order = WC_Helper_Order::create_order( $customer_id );
+ $order->remove_order_items();
+ $order->save();
+ $order->add_product( wc_get_product( $product->get_id() ), 1 );
+ $order->set_status( $status );
+ $order->save();
+
+ return $order->get_id();
+ }
+
+ /**
+ * An unpaid order belonging to somebody who is not the current user, and whose
+ * id never enters the current session.
+ *
+ * @param WC_Product_Simple $product Product to add.
+ * @return int Order ID.
+ */
+ private function create_shopper_with_unpaid_order( WC_Product_Simple $product ): int {
+ $current = get_current_user_id();
+ $other = $this->create_signed_in_shopper();
+ $order = $this->create_unpaid_order_for( $other, $product );
+
+ wp_set_current_user( $current );
+
+ return $order;
+ }
+
+ /**
+ * Write a hold directly, with an exact age.
+ *
+ * @param int $order_id Order holding the stock.
+ * @param int $product_id Product held.
+ * @param int $quantity Quantity held.
+ * @param int $age_minutes How long ago the hold was placed.
+ */
+ private function hold_stock( int $order_id, int $product_id, int $quantity, int $age_minutes ): void {
+ global $wpdb;
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $wpdb->query(
+ $wpdb->prepare(
+ "
+ INSERT INTO {$wpdb->wc_reserved_stock} ( `order_id`, `product_id`, `stock_quantity`, `timestamp`, `expires` )
+ VALUES ( %d, %d, %d, ( NOW() - INTERVAL %d MINUTE ), ( NOW() + INTERVAL %d MINUTE ) )
+ ",
+ $order_id,
+ $product_id,
+ $quantity,
+ $age_minutes,
+ self::HOLD_MINUTES
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ }
+
+ /**
+ * Backdate a hold that was created by the real reservation path.
+ *
+ * @param int $order_id Order holding the stock.
+ * @param int $age_minutes How long ago the hold should read as placed.
+ */
+ private function age_hold( int $order_id, int $age_minutes ): void {
+ global $wpdb;
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $wpdb->query(
+ $wpdb->prepare(
+ "UPDATE {$wpdb->wc_reserved_stock} SET `timestamp` = ( NOW() - INTERVAL %d MINUTE ) WHERE `order_id` = %d",
+ $age_minutes,
+ $order_id
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ }
+}