Commit 869054a7b54 for woocommerce
commit 869054a7b54873816a73d612d517a43d0df96977
Author: Thomas Roberts <5656702+opr@users.noreply.github.com>
Date: Fri Aug 21 18:47:59 2026 +0100
Revert "Stop a shopper being blocked by their own stale stock reservation" (#67931)
* Revert "Stop a shopper being blocked by their own stale stock reservation (#6…"
This reverts commit e817ab399ecacee9ab1b064c8fe17ce8e560f47e.
* Add changefile(s) from automation for the following project(s): woocommerce
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/67931-revert-67751-fix-67354-own-reserved-stock-grace-window b/plugins/woocommerce/changelog/67931-revert-67751-fix-67354-own-reserved-stock-grace-window
new file mode 100644
index 00000000000..63202a40bd6
--- /dev/null
+++ b/plugins/woocommerce/changelog/67931-revert-67751-fix-67354-own-reserved-stock-grace-window
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Restore pending stock holds to prevent multiple payable orders from reserving the same stock.
diff --git a/plugins/woocommerce/changelog/fix-67354-own-reserved-stock-grace-window b/plugins/woocommerce/changelog/fix-67354-own-reserved-stock-grace-window
deleted file mode 100644
index 99a5e645688..00000000000
--- a/plugins/woocommerce/changelog/fix-67354-own-reserved-stock-grace-window
+++ /dev/null
@@ -1,4 +0,0 @@
-Significance: minor
-Type: fix
-
-Stop a shopper being blocked by their own abandoned stock reservation. A hold placed by the same shopper is excluded once it is older than a threshold, filterable via woocommerce_own_stock_hold_exclusion_threshold_minutes and defaulting to 10 minutes. Holds belonging to other shoppers are unaffected.
diff --git a/plugins/woocommerce/src/Checkout/Helpers/ReserveStock.php b/plugins/woocommerce/src/Checkout/Helpers/ReserveStock.php
index 01fa2d893ef..3ecf85a47bf 100644
--- a/plugins/woocommerce/src/Checkout/Helpers/ReserveStock.php
+++ b/plugins/woocommerce/src/Checkout/Helpers/ReserveStock.php
@@ -7,7 +7,6 @@ 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;
@@ -18,23 +17,6 @@ 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 simply block again until they
- * expire — the failure direction is blocking, never overselling.
- */
- private const MAX_OWN_ORDERS = 10;
-
/**
* Is stock reservation enabled?
*
@@ -42,16 +24,6 @@ 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
*/
@@ -177,8 +149,6 @@ 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 );
@@ -323,8 +293,6 @@ 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.
@@ -336,184 +304,4 @@ 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.
- *
- * Only the shopper who placed the hold is affected. Return 0 to exclude own
- * holds immediately, or false to disable the exclusion and keep own holds
- * blocking for the full reservation window.
- *
- * @since 11.2.0
- *
- * @param int|false $threshold_minutes Threshold in minutes, or false to disable.
- * @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', 10, $product_id, $exclude_order_id );
-
- if ( false === $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
deleted file mode 100644
index 958c259f95b..00000000000
--- a/plugins/woocommerce/tests/php/src/Checkout/Helpers/ReserveStockOwnHoldsTest.php
+++ /dev/null
@@ -1,485 +0,0 @@
-<?php
-/**
- * Tests for excluding a shopper's own stale stock holds.
- */
-
-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 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. The oversell invariant.
- // ---------------------------------------------------------------------
-
- /**
- * A stale hold on an order the shopper does not own keeps blocking, even
- * while the shopper's own stale hold is excluded.
- */
- public function test_own_stale_hold_does_not_exclude_another_shoppers_hold() {
- $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, wc_get_held_stock_quantity( $product, 0 ) );
- }
-
- // ---------------------------------------------------------------------
- // 2. The exclusion threshold.
- // ---------------------------------------------------------------------
-
- /**
- * The shopper's own hold still blocks inside the default 10 minute threshold.
- */
- public function test_own_fresh_hold_still_blocks() {
- $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, wc_get_held_stock_quantity( $product, 0 ) );
- }
-
- /**
- * Past the threshold the shopper stops being blocked by their own attempt.
- */
- public function test_own_stale_hold_is_excluded() {
- $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, wc_get_held_stock_quantity( $product, 0 ) );
- }
-
- /**
- * A false threshold disables the exclusion and restores the behaviour from
- * before this change.
- */
- public function test_false_threshold_keeps_own_hold_blocking() {
- $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 );
-
- add_filter( 'woocommerce_own_stock_hold_exclusion_threshold_minutes', '__return_false' );
- try {
- $held = wc_get_held_stock_quantity( $product, 0 );
- } finally {
- remove_filter( 'woocommerce_own_stock_hold_exclusion_threshold_minutes', '__return_false' );
- }
-
- $this->assertSame( 1, $held );
- }
-
- /**
- * A threshold of zero excludes the shopper's own hold immediately.
- */
- public function test_zero_threshold_excludes_own_hold_immediately() {
- $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 );
-
- add_filter( 'woocommerce_own_stock_hold_exclusion_threshold_minutes', '__return_zero' );
- try {
- $held = wc_get_held_stock_quantity( $product, 0 );
- } finally {
- remove_filter( 'woocommerce_own_stock_hold_exclusion_threshold_minutes', '__return_zero' );
- }
-
- $this->assertSame( 0, $held );
- }
-
- // ---------------------------------------------------------------------
- // 3. Ownership sources.
- // ---------------------------------------------------------------------
-
- /**
- * The reported flow: a guest on the block checkout whose draft pointer has
- * already moved on to a new draft order.
- */
- public function test_session_list_covers_a_guest_whose_draft_pointer_moved_on() {
- $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, wc_get_held_stock_quantity( $product, $new_draft ) );
- }
-
- /**
- * A session list that arrived from somebody else's session confers nothing.
- *
- * Core hands session data to a different shopper in two places (cart token
- * cloning and guest-to-user migration); both change the session customer id
- * first, which is what the stamp detects.
- */
- public function test_borrowed_session_list_confers_no_ownership() {
- $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, wc_get_held_stock_quantity( $product, 0 ) );
- }
-
- /**
- * A billing email is not an ownership claim.
- *
- * The attacker types the victim's address into their own checkout. The
- * victim's in-flight hold must keep blocking.
- */
- public function test_billing_email_confers_no_ownership() {
- $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, wc_get_held_stock_quantity( $product, 0 ) );
- }
-
- /**
- * A guest who exceeds the session list limit loses the oldest entry, and that
- * hold blocks again: the failure direction is blocking, never overselling.
- */
- public function test_own_holds_beyond_the_session_list_limit_block_again() {
- $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, wc_get_held_stock_quantity( $product, 0 ) );
- }
-
- // ---------------------------------------------------------------------
- // 4. Contexts with no shopper.
- // ---------------------------------------------------------------------
-
- /**
- * With no session — the admin, WP-CLI, cron — nothing is excluded, even for
- * a signed in user who happens to have unpaid orders of their own.
- */
- public function test_no_session_leaves_the_total_unchanged() {
- $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, wc_get_held_stock_quantity( $product, 0 ) );
- }
-
- // ---------------------------------------------------------------------
- // 5. The public filter contract.
- // ---------------------------------------------------------------------
-
- /**
- * woocommerce_query_for_reserved_stock still receives a scalar order id, and
- * still receives the complete core query, exclusion clause included.
- */
- public function test_filter_signature_is_unchanged() {
- $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 {
- wc_get_held_stock_quantity( $product, 4242 );
- } 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.
- // ---------------------------------------------------------------------
-
- /**
- * 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
- }
-}