Avoid reserving stock for draft orders under payment is attempted (#49446)

* Use wc_reserve_stock_for_order only before taking payment

* Release stock on exception

* changelog
This commit is contained in:
Mike Jolley 2024-07-16 12:00:23 +01:00 committed by GitHub
parent f3e003c236
commit 6d52388b8b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 52 additions and 38 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: enhancement
Updated block checkout and Store API stock handling so stock is only reserved when attempting payment for an order.

View File

@ -10,6 +10,8 @@
defined( 'ABSPATH' ) || exit; defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
/** /**
* Update a product's stock amount. * Update a product's stock amount.
* *
@ -348,7 +350,8 @@ function wc_get_held_stock_quantity( WC_Product $product, $exclude_order_id = 0
return 0; return 0;
} }
return ( new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock() )->get_reserved_stock( $product, $exclude_order_id ); $reserve_stock = new ReserveStock();
return $reserve_stock->get_reserved_stock( $product, $exclude_order_id );
} }
/** /**
@ -374,7 +377,8 @@ function wc_reserve_stock_for_order( $order ) {
$order = $order instanceof WC_Order ? $order : wc_get_order( $order ); $order = $order instanceof WC_Order ? $order : wc_get_order( $order );
if ( $order ) { if ( $order ) {
( new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock() )->reserve_stock_for_order( $order ); $reserve_stock = new ReserveStock();
$reserve_stock->reserve_stock_for_order( $order );
} }
} }
add_action( 'woocommerce_checkout_order_created', 'wc_reserve_stock_for_order' ); add_action( 'woocommerce_checkout_order_created', 'wc_reserve_stock_for_order' );
@ -400,7 +404,8 @@ function wc_release_stock_for_order( $order ) {
$order = $order instanceof WC_Order ? $order : wc_get_order( $order ); $order = $order instanceof WC_Order ? $order : wc_get_order( $order );
if ( $order ) { if ( $order ) {
( new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock() )->release_stock_for_order( $order ); $reserve_stock = new ReserveStock();
$reserve_stock->release_stock_for_order( $order );
} }
} }
add_action( 'woocommerce_checkout_order_exception', 'wc_release_stock_for_order' ); add_action( 'woocommerce_checkout_order_exception', 'wc_release_stock_for_order' );

View File

@ -88,7 +88,7 @@ final class ReserveStock {
try { try {
$items = array_filter( $items = array_filter(
$order->get_items(), $order->get_items(),
function( $item ) { function ( $item ) {
return $item->is_type( 'line_item' ) && $item->get_product() instanceof \WC_Product && $item->get_quantity() > 0; return $item->is_type( 'line_item' ) && $item->get_product() instanceof \WC_Product && $item->get_quantity() > 0;
} }
); );

View File

@ -2,11 +2,9 @@
namespace Automattic\WooCommerce\StoreApi\Routes\V1; namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Payments\PaymentResult; use Automattic\WooCommerce\StoreApi\Payments\PaymentResult;
use Automattic\WooCommerce\StoreApi\Exceptions\InvalidStockLevelsInCartException;
use Automattic\WooCommerce\StoreApi\Exceptions\InvalidCartException; use Automattic\WooCommerce\StoreApi\Exceptions\InvalidCartException;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait; use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStockException; use Automattic\WooCommerce\Checkout\Helpers\ReserveStockException;
use Automattic\WooCommerce\StoreApi\Utilities\CheckoutTrait; use Automattic\WooCommerce\StoreApi\Utilities\CheckoutTrait;
use Automattic\WooCommerce\Utilities\RestApiUtil; use Automattic\WooCommerce\Utilities\RestApiUtil;
@ -147,6 +145,11 @@ class Checkout extends AbstractCartRoute {
if ( is_wp_error( $response ) ) { if ( is_wp_error( $response ) ) {
$response = $this->error_to_response( $response ); $response = $this->error_to_response( $response );
// If we encountered an exception, free up stock.
if ( $this->order ) {
wc_release_stock_for_order( $this->order );
}
} }
return $this->add_response_headers( $response ); return $this->add_response_headers( $response );
@ -181,7 +184,6 @@ class Checkout extends AbstractCartRoute {
* 5. Process Payment * 5. Process Payment
* *
* @throws RouteException On error. * @throws RouteException On error.
* @throws InvalidStockLevelsInCartException On error.
* *
* @param \WP_REST_Request $request Request object. * @param \WP_REST_Request $request Request object.
* *
@ -195,35 +197,56 @@ class Checkout extends AbstractCartRoute {
$this->cart_controller->calculate_totals(); $this->cart_controller->calculate_totals();
/** /**
* Validate items etc are allowed in the order before the order is processed. This will fix violations and tell * Validate items and fix violations before the order is processed.
* the customer.
*/ */
$this->cart_controller->validate_cart(); $this->cart_controller->validate_cart();
/** /**
* Obtain Draft Order and process request data. * Persist customer session data from the request first so that OrderController::update_addresses_from_cart
*
* Note: Customer data is persisted from the request first so that OrderController::update_addresses_from_cart
* uses the up to date customer address. * uses the up to date customer address.
*/ */
$this->update_customer_from_request( $request ); $this->update_customer_from_request( $request );
$this->create_or_update_draft_order( $request );
$this->update_order_from_request( $request );
/** /**
* Process customer data. * Create (or update) Draft Order and process request data.
*
* Update order with customer details, and sign up a user account as necessary.
*/ */
$this->create_or_update_draft_order( $request );
$this->update_order_from_request( $request );
$this->process_customer( $request ); $this->process_customer( $request );
/** /**
* Validate order. * Validate updated order before payment is attempted.
*
* This logic ensures the order is valid before payment is attempted.
*/ */
$this->order_controller->validate_order_before_payment( $this->order ); $this->order_controller->validate_order_before_payment( $this->order );
/**
* Reserve stock for the order.
*
* In the shortcode based checkout, when POSTing the checkout form the order would be created and fire the
* `woocommerce_checkout_order_created` action. This in turn would trigger the `wc_reserve_stock_for_order`
* function so that stock would be held pending payment.
*
* Via the block based checkout and Store API we already have a draft order, but when POSTing to the /checkout
* endpoint we do the same; reserve stock for the order to allow time to process payment.
*
* Note, stock is only "held" while the order has the status wc-checkout-draft or pending. Stock is freed when
* the order changes status, or there is an exception.
*
* @see ReserveStock::get_query_for_reserved_stock()
*
* @since 9.2 Stock is no longer held for all draft orders, nor on non-POST requests. See https://github.com/woocommerce/woocommerce/issues/44231
* @since 9.2 Uses wc_reserve_stock_for_order() instead of using the ReserveStock class directly.
*/
try {
wc_reserve_stock_for_order( $this->order );
} catch ( ReserveStockException $e ) {
throw new RouteException(
esc_html( $e->getErrorCode() ),
esc_html( $e->getMessage() ),
esc_html( $e->getCode() )
);
}
wc_do_deprecated_action( wc_do_deprecated_action(
'__experimental_woocommerce_blocks_checkout_order_processed', '__experimental_woocommerce_blocks_checkout_order_processed',
array( array(
@ -402,24 +425,6 @@ class Checkout extends AbstractCartRoute {
// Store order ID to session. // Store order ID to session.
$this->set_draft_order_id( $this->order->get_id() ); $this->set_draft_order_id( $this->order->get_id() );
/**
* Try to reserve stock for the order.
*
* If creating a draft order on checkout entry, set the timeout to 10 mins.
* If POSTing to the checkout (attempting to pay), set the timeout to 60 mins (using the woocommerce_hold_stock_minutes option).
*/
try {
$reserve_stock = new ReserveStock();
$duration = $request->get_method() === 'POST' ? (int) get_option( 'woocommerce_hold_stock_minutes', 60 ) : 10;
$reserve_stock->reserve_stock_for_order( $this->order, $duration );
} catch ( ReserveStockException $e ) {
throw new RouteException(
$e->getErrorCode(),
$e->getMessage(),
$e->getCode()
);
}
} }
/** /**