Merge pull request #25708 from woocommerce/update/reserve-stock-for-checkout
Introduce a `reserved stock` class and database table to prevent race conditions during checkout
This commit is contained in:
commit
3bf473517d
|
@ -765,7 +765,6 @@ class WC_Cart extends WC_Legacy_Cart {
|
|||
public function check_cart_item_stock() {
|
||||
$error = new WP_Error();
|
||||
$product_qty_in_cart = $this->get_cart_item_quantities();
|
||||
$hold_stock_minutes = (int) get_option( 'woocommerce_hold_stock_minutes', 0 );
|
||||
$current_session_order_id = isset( WC()->session->order_awaiting_payment ) ? absint( WC()->session->order_awaiting_payment ) : 0;
|
||||
|
||||
foreach ( $this->get_cart() as $cart_item_key => $values ) {
|
||||
|
@ -784,7 +783,7 @@ class WC_Cart extends WC_Legacy_Cart {
|
|||
}
|
||||
|
||||
// Check stock based on all items in the cart and consider any held stock within pending orders.
|
||||
$held_stock = ( $hold_stock_minutes > 0 ) ? wc_get_held_stock_quantity( $product, $current_session_order_id ) : 0;
|
||||
$held_stock = wc_get_held_stock_quantity( $product, $current_session_order_id );
|
||||
$required_stock = $product_qty_in_cart[ $product->get_stock_managed_by_id() ];
|
||||
|
||||
if ( $product->get_stock_quantity() < ( $held_stock + $required_stock ) ) {
|
||||
|
|
|
@ -388,12 +388,30 @@ class WC_Checkout {
|
|||
// Save the order.
|
||||
$order_id = $order->save();
|
||||
|
||||
/**
|
||||
* Action hook fired after an order is created used to add custom meta to the order.
|
||||
*
|
||||
* @since 3.0.0
|
||||
*/
|
||||
do_action( 'woocommerce_checkout_update_order_meta', $order_id, $data );
|
||||
|
||||
/**
|
||||
* Action hook fired after an order is created.
|
||||
*
|
||||
* @since 4.1.0
|
||||
*/
|
||||
do_action( 'woocommerce_checkout_order_created', $order );
|
||||
|
||||
return $order_id;
|
||||
} catch ( Exception $e ) {
|
||||
if ( $order && $order instanceof WC_Order ) {
|
||||
$order->get_data_store()->release_held_coupons( $order );
|
||||
/**
|
||||
* Action hook fired when an order is discarded due to Exception.
|
||||
*
|
||||
* @since 4.1.0
|
||||
*/
|
||||
do_action( 'woocommerce_checkout_order_exception', $order );
|
||||
}
|
||||
return new WP_Error( 'checkout-error', $e->getMessage() );
|
||||
}
|
||||
|
|
|
@ -946,6 +946,14 @@ CREATE TABLE {$wpdb->prefix}wc_tax_rate_classes (
|
|||
slug varchar(200) NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (tax_rate_class_id),
|
||||
UNIQUE KEY slug (slug($max_index_length))
|
||||
) $collate;
|
||||
CREATE TABLE {$wpdb->prefix}wc_reserved_stock (
|
||||
`order_id` bigint(20) NOT NULL,
|
||||
`product_id` bigint(20) NOT NULL,
|
||||
`stock_quantity` double NOT NULL DEFAULT 0,
|
||||
`timestamp` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`expires` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`order_id`, `product_id`)
|
||||
) $collate;
|
||||
";
|
||||
|
||||
|
@ -980,6 +988,7 @@ CREATE TABLE {$wpdb->prefix}wc_tax_rate_classes (
|
|||
"{$wpdb->prefix}woocommerce_shipping_zones",
|
||||
"{$wpdb->prefix}woocommerce_tax_rate_locations",
|
||||
"{$wpdb->prefix}woocommerce_tax_rates",
|
||||
"{$wpdb->prefix}wc_reserved_stock",
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
|
@ -246,6 +246,7 @@ final class WooCommerce {
|
|||
'order_itemmeta' => 'woocommerce_order_itemmeta',
|
||||
'wc_product_meta_lookup' => 'wc_product_meta_lookup',
|
||||
'wc_tax_rate_classes' => 'wc_tax_rate_classes',
|
||||
'wc_reserved_stock' => 'wc_reserved_stock',
|
||||
);
|
||||
|
||||
foreach ( $tables as $name => $table ) {
|
||||
|
|
|
@ -881,8 +881,8 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
|
|||
$outofstock_where = ' AND exclude_join.object_id IS NULL';
|
||||
}
|
||||
|
||||
return $wpdb->get_results(
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
return $wpdb->get_results(
|
||||
"
|
||||
SELECT posts.ID as id, posts.post_parent as parent_id
|
||||
FROM {$wpdb->posts} AS posts
|
||||
|
@ -900,8 +900,8 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
|
|||
)
|
||||
GROUP BY posts.ID
|
||||
"
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
);
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1603,7 +1603,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
|
|||
|
||||
foreach ( $search_terms as $search_term ) {
|
||||
$like = '%' . $wpdb->esc_like( $search_term ) . '%';
|
||||
$term_group_query .= $wpdb->prepare( " {$searchand} ( ( posts.post_title LIKE %s) OR ( posts.post_excerpt LIKE %s) OR ( posts.post_content LIKE %s ) OR ( wc_product_meta_lookup.sku LIKE %s ) )", $like, $like, $like, $like ); // @codingStandardsIgnoreLine.
|
||||
$term_group_query .= $wpdb->prepare( " {$searchand} ( ( posts.post_title LIKE %s) OR ( posts.post_excerpt LIKE %s) OR ( posts.post_content LIKE %s ) OR ( wc_product_meta_lookup.sku LIKE %s ) )", $like, $like, $like, $like ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$searchand = ' AND ';
|
||||
}
|
||||
|
||||
|
@ -2062,4 +2062,23 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
|
|||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns query statement for getting current `_stock` of a product.
|
||||
*
|
||||
* @internal MAX function below is used to make sure result is a scalar.
|
||||
* @param int $product_id Product ID.
|
||||
* @return string|void Query statement.
|
||||
*/
|
||||
public function get_query_for_stock( $product_id ) {
|
||||
global $wpdb;
|
||||
return $wpdb->prepare(
|
||||
"
|
||||
SELECT COALESCE ( MAX( meta_value ), 0 ) FROM $wpdb->postmeta as meta_table
|
||||
WHERE meta_table.meta_key = '_stock'
|
||||
AND meta_table.post_id = %d
|
||||
",
|
||||
$product_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -86,7 +86,6 @@ class WC_Shortcode_Checkout {
|
|||
try {
|
||||
$order_key = isset( $_GET['key'] ) ? wc_clean( wp_unslash( $_GET['key'] ) ) : ''; // WPCS: input var ok, CSRF ok.
|
||||
$order = wc_get_order( $order_id );
|
||||
$hold_stock_minutes = (int) get_option( 'woocommerce_hold_stock_minutes', 0 );
|
||||
|
||||
// Order or payment link is invalid.
|
||||
if ( ! $order || $order->get_id() !== $order_id || ! hash_equals( $order->get_order_key(), $order_key ) ) {
|
||||
|
@ -158,7 +157,7 @@ class WC_Shortcode_Checkout {
|
|||
}
|
||||
|
||||
// Check stock based on all items in the cart and consider any held stock within pending orders.
|
||||
$held_stock = ( $hold_stock_minutes > 0 ) ? wc_get_held_stock_quantity( $product, $order->get_id() ) : 0;
|
||||
$held_stock = wc_get_held_stock_quantity( $product, $order->get_id() );
|
||||
$required_stock = $quantities[ $product->get_stock_managed_by_id() ];
|
||||
|
||||
if ( ! apply_filters( 'woocommerce_pay_order_product_has_enough_stock', ( $product->get_stock_quantity() >= ( $held_stock + $required_stock ) ), $product, $order ) ) {
|
||||
|
|
|
@ -299,33 +299,80 @@ function wc_increase_stock_levels( $order_id ) {
|
|||
* @param integer $exclude_order_id Order ID to exclude.
|
||||
* @return int
|
||||
*/
|
||||
function wc_get_held_stock_quantity( $product, $exclude_order_id = 0 ) {
|
||||
global $wpdb;
|
||||
|
||||
return $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"
|
||||
SELECT SUM( order_item_meta.meta_value ) AS held_qty
|
||||
FROM {$wpdb->posts} AS posts
|
||||
LEFT JOIN {$wpdb->postmeta} as postmeta ON posts.ID = postmeta.post_id
|
||||
LEFT JOIN {$wpdb->prefix}woocommerce_order_items as order_items ON posts.ID = order_items.order_id
|
||||
LEFT JOIN {$wpdb->prefix}woocommerce_order_itemmeta as order_item_meta ON order_items.order_item_id = order_item_meta.order_item_id
|
||||
LEFT JOIN {$wpdb->prefix}woocommerce_order_itemmeta as order_item_meta2 ON order_items.order_item_id = order_item_meta2.order_item_id
|
||||
WHERE order_item_meta.meta_key = '_qty'
|
||||
AND order_item_meta2.meta_key = %s
|
||||
AND order_item_meta2.meta_value = %d
|
||||
AND postmeta.meta_key = '_created_via'
|
||||
AND postmeta.meta_value = 'checkout'
|
||||
AND posts.post_type IN ( '" . implode( "','", wc_get_order_types() ) . "' )
|
||||
AND posts.post_status = 'wc-pending'
|
||||
AND posts.ID != %d;",
|
||||
'product_variation' === get_post_type( $product->get_stock_managed_by_id() ) ? '_variation_id' : '_product_id',
|
||||
$product->get_stock_managed_by_id(),
|
||||
$exclude_order_id
|
||||
)
|
||||
); // WPCS: unprepared SQL ok.
|
||||
function wc_get_held_stock_quantity( WC_Product $product, $exclude_order_id = 0 ) {
|
||||
/**
|
||||
* Filter: woocommerce_hold_stock_for_checkout
|
||||
* Allows enable/disable hold stock functionality on checkout.
|
||||
*
|
||||
* @since 4.1.0
|
||||
* @param bool $enabled Default to true if managing stock globally.
|
||||
*/
|
||||
if ( ! apply_filters( 'woocommerce_hold_stock_for_checkout', wc_string_to_bool( get_option( 'woocommerce_manage_stock', 'yes' ) ) ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ( new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock() )->get_reserved_stock( $product, $exclude_order_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold stock for an order.
|
||||
*
|
||||
* @throws ReserveStockException If reserve stock fails.
|
||||
*
|
||||
* @since 4.1.0
|
||||
* @param \WC_Order|int $order Order ID or instance.
|
||||
*/
|
||||
function wc_reserve_stock_for_order( $order ) {
|
||||
/**
|
||||
* Filter: woocommerce_hold_stock_for_checkout
|
||||
* Allows enable/disable hold stock functionality on checkout.
|
||||
*
|
||||
* @since @since 4.1.0
|
||||
* @param bool $enabled Default to true if managing stock globally.
|
||||
*/
|
||||
if ( ! apply_filters( 'woocommerce_hold_stock_for_checkout', wc_string_to_bool( get_option( 'woocommerce_manage_stock', 'yes' ) ) ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order = $order instanceof WC_Order ? $order : wc_get_order( $order );
|
||||
|
||||
if ( $order ) {
|
||||
( new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock() )->reserve_stock_for_order( $order );
|
||||
}
|
||||
}
|
||||
add_action( 'woocommerce_checkout_order_created', 'wc_reserve_stock_for_order' );
|
||||
|
||||
/**
|
||||
* Release held stock for an order.
|
||||
*
|
||||
* @since 4.1.0
|
||||
* @param \WC_Order|int $order Order ID or instance.
|
||||
*/
|
||||
function wc_release_stock_for_order( $order ) {
|
||||
/**
|
||||
* Filter: woocommerce_hold_stock_for_checkout
|
||||
* Allows enable/disable hold stock functionality on checkout.
|
||||
*
|
||||
* @since 4.1.0
|
||||
* @param bool $enabled Default to true if managing stock globally.
|
||||
*/
|
||||
if ( ! apply_filters( 'woocommerce_hold_stock_for_checkout', wc_string_to_bool( get_option( 'woocommerce_manage_stock', 'yes' ) ) ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order = $order instanceof WC_Order ? $order : wc_get_order( $order );
|
||||
|
||||
if ( $order ) {
|
||||
( new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock() )->release_stock_for_order( $order );
|
||||
}
|
||||
}
|
||||
add_action( 'woocommerce_checkout_order_exception', 'wc_release_stock_for_order' );
|
||||
add_action( 'woocommerce_payment_complete', 'wc_release_stock_for_order', 11 );
|
||||
add_action( 'woocommerce_order_status_cancelled', 'wc_release_stock_for_order', 11 );
|
||||
add_action( 'woocommerce_order_status_completed', 'wc_release_stock_for_order', 11 );
|
||||
add_action( 'woocommerce_order_status_processing', 'wc_release_stock_for_order', 11 );
|
||||
add_action( 'woocommerce_order_status_on-hold', 'wc_release_stock_for_order', 11 );
|
||||
|
||||
/**
|
||||
* Return low stock amount to determine if notification needs to be sent
|
||||
*
|
||||
|
|
|
@ -0,0 +1,176 @@
|
|||
<?php
|
||||
/**
|
||||
* Handle product stock reservation during checkout.
|
||||
*
|
||||
* @package Automattic/WooCommerce
|
||||
*/
|
||||
|
||||
namespace Automattic\WooCommerce\Checkout\Helpers;
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Stock Reservation class.
|
||||
*/
|
||||
final class ReserveStock {
|
||||
/**
|
||||
* Query for any existing holds on stock for this item.
|
||||
*
|
||||
* @param \WC_Product $product Product to get reserved stock for.
|
||||
* @param integer $exclude_order_id Optional order to exclude from the results.
|
||||
*
|
||||
* @return integer Amount of stock already reserved.
|
||||
*/
|
||||
public function get_reserved_stock( \WC_Product $product, $exclude_order_id = 0 ) {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
|
||||
return (int) $wpdb->get_var( $this->get_query_for_reserved_stock( $product->get_stock_managed_by_id(), $exclude_order_id ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a temporary hold on stock for an order if enough is available.
|
||||
*
|
||||
* @throws ReserveStockException If stock cannot be reserved.
|
||||
*
|
||||
* @param \WC_Order $order Order object.
|
||||
* @param int $minutes How long to reserve stock in minutes. Defaults to woocommerce_hold_stock_minutes.
|
||||
*/
|
||||
public function reserve_stock_for_order( \WC_Order $order, $minutes = 0 ) {
|
||||
$minutes = $minutes ? $minutes : (int) get_option( 'woocommerce_hold_stock_minutes', 60 );
|
||||
|
||||
if ( ! $minutes ) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$items = array_filter(
|
||||
$order->get_items(),
|
||||
function( $item ) {
|
||||
return $item->is_type( 'line_item' ) && $item->get_product() instanceof \WC_Product && $item->get_quantity() > 0;
|
||||
}
|
||||
);
|
||||
$rows = array();
|
||||
|
||||
foreach ( $items as $item ) {
|
||||
$product = $item->get_product();
|
||||
|
||||
if ( ! $product->is_in_stock() ) {
|
||||
throw new ReserveStockException(
|
||||
'woocommerce_product_out_of_stock',
|
||||
sprintf(
|
||||
/* translators: %s: product name */
|
||||
__( '"%s" is out of stock and cannot be purchased.', 'woocommerce' ),
|
||||
$product->get_name()
|
||||
),
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
// If stock management is off, no need to reserve any stock here.
|
||||
if ( ! $product->managing_stock() || $product->backorders_allowed() ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$managed_by_id = $product->get_stock_managed_by_id();
|
||||
$rows[ $managed_by_id ] = isset( $rows[ $managed_by_id ] ) ? $rows[ $managed_by_id ] + $item->get_quantity() : $item->get_quantity();
|
||||
}
|
||||
|
||||
if ( ! empty( $rows ) ) {
|
||||
foreach ( $rows as $product_id => $quantity ) {
|
||||
$this->reserve_stock_for_product( $product_id, $quantity, $order, $minutes );
|
||||
}
|
||||
}
|
||||
} catch ( ReserveStockException $e ) {
|
||||
$this->release_stock_for_order( $order );
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a temporary hold on stock for an order.
|
||||
*
|
||||
* @param \WC_Order $order Order object.
|
||||
*/
|
||||
public function release_stock_for_order( \WC_Order $order ) {
|
||||
global $wpdb;
|
||||
|
||||
$wpdb->delete(
|
||||
$wpdb->wc_reserved_stock,
|
||||
array(
|
||||
'order_id' => $order->get_id(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve stock for a product by inserting rows into the DB.
|
||||
*
|
||||
* @throws ReserveStockException If a row cannot be inserted.
|
||||
*
|
||||
* @param int $product_id Product ID which is having stock reserved.
|
||||
* @param int $stock_quantity Stock amount to reserve.
|
||||
* @param \WC_Order $order Order object which contains the product.
|
||||
* @param int $minutes How long to reserve stock in minutes.
|
||||
*/
|
||||
private function reserve_stock_for_product( $product_id, $stock_quantity, \WC_Order $order, $minutes ) {
|
||||
global $wpdb;
|
||||
|
||||
$product_data_store = \WC_Data_Store::load( 'product' );
|
||||
$query_for_stock = $product_data_store->get_query_for_stock( $product_id );
|
||||
$query_for_reserved_stock = $this->get_query_for_reserved_stock( $product_id, $order->get_id() );
|
||||
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
|
||||
$result = $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"
|
||||
REPLACE INTO {$wpdb->wc_reserved_stock} ( order_id, product_id, stock_quantity, expires )
|
||||
SELECT %d, %d, %d, ( NOW() + INTERVAL %d MINUTE ) from DUAL
|
||||
WHERE ( $query_for_stock FOR UPDATE ) - ( $query_for_reserved_stock FOR UPDATE ) >= %d
|
||||
",
|
||||
$order->get_id(),
|
||||
$product_id,
|
||||
$stock_quantity,
|
||||
$minutes,
|
||||
$stock_quantity
|
||||
)
|
||||
);
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
|
||||
|
||||
if ( ! $result ) {
|
||||
$product = wc_get_product( $product_id );
|
||||
throw new ReserveStockException(
|
||||
'woocommerce_product_not_enough_stock',
|
||||
sprintf(
|
||||
/* translators: %s: product name */
|
||||
__( 'Not enough units of %s are available in stock to fulfil this order.', 'woocommerce' ),
|
||||
$product ? $product->get_name() : '#' . $product_id
|
||||
),
|
||||
403
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns query statement for getting reserved stock of a product.
|
||||
*
|
||||
* @param int $product_id Product ID.
|
||||
* @param integer $exclude_order_id Optional order to exclude from the results.
|
||||
* @return string|void Query statement.
|
||||
*/
|
||||
private function get_query_for_reserved_stock( $product_id, $exclude_order_id = 0 ) {
|
||||
global $wpdb;
|
||||
return $wpdb->prepare(
|
||||
"
|
||||
SELECT COALESCE( SUM( stock_table.`stock_quantity` ), 0 ) FROM $wpdb->wc_reserved_stock stock_table
|
||||
LEFT JOIN $wpdb->posts posts ON stock_table.`order_id` = posts.ID
|
||||
WHERE posts.post_status IN ( 'wc-checkout-draft', 'wc-pending' )
|
||||
AND stock_table.`expires` > NOW()
|
||||
AND stock_table.`product_id` = %d
|
||||
AND stock_table.`order_id` != %d
|
||||
",
|
||||
$product_id,
|
||||
$exclude_order_id
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
/**
|
||||
* Exceptions for stock reservation.
|
||||
*
|
||||
* @package Automattic/WooCommerce
|
||||
*/
|
||||
|
||||
namespace Automattic\WooCommerce\Checkout\Helpers;
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* ReserveStockException class.
|
||||
*/
|
||||
class ReserveStockException extends \Exception {
|
||||
/**
|
||||
* Sanitized error code.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $error_code;
|
||||
|
||||
/**
|
||||
* Error extra data.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $error_data;
|
||||
|
||||
/**
|
||||
* Setup exception.
|
||||
*
|
||||
* @param string $code Machine-readable error code, e.g `woocommerce_invalid_product_id`.
|
||||
* @param string $message User-friendly translated error message, e.g. 'Product ID is invalid'.
|
||||
* @param int $http_status_code Proper HTTP status code to respond with, e.g. 400.
|
||||
* @param array $data Extra error data.
|
||||
*/
|
||||
public function __construct( $code, $message, $http_status_code = 400, $data = array() ) {
|
||||
$this->error_code = $code;
|
||||
$this->error_data = $data;
|
||||
|
||||
parent::__construct( $message, $http_status_code );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the error code.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getErrorCode() {
|
||||
return $this->error_code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns error data.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getErrorData() {
|
||||
return $this->error_data;
|
||||
}
|
||||
}
|
|
@ -9,9 +9,8 @@
|
|||
* Class WC_Checkout
|
||||
*/
|
||||
class WC_Tests_Checkout extends WC_Unit_Test_Case {
|
||||
|
||||
/**
|
||||
* TearDown for tests.
|
||||
* TearDown.
|
||||
*/
|
||||
public function tearDown() {
|
||||
parent::tearDown();
|
||||
|
@ -19,7 +18,7 @@ class WC_Tests_Checkout extends WC_Unit_Test_Case {
|
|||
}
|
||||
|
||||
/**
|
||||
* Setup for tests.
|
||||
* Setup.
|
||||
*/
|
||||
public function setUp() {
|
||||
parent::setUp();
|
||||
|
@ -188,4 +187,120 @@ class WC_Tests_Checkout extends WC_Unit_Test_Case {
|
|||
return 0.01;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to create a managed product and a order for that product.
|
||||
*
|
||||
* @return array
|
||||
* @throws Exception When unable to create an order .
|
||||
*/
|
||||
protected function create_order_for_managed_inventory_product() {
|
||||
$product = WC_Helper_Product::create_simple_product();
|
||||
$product->set_props( array( 'manage_stock' => true ) );
|
||||
$product->set_stock_quantity( 10 );
|
||||
$product->save();
|
||||
|
||||
WC()->cart->add_to_cart( $product->get_id(), 9 );
|
||||
$this->assertEquals( true, WC()->cart->check_cart_items() );
|
||||
|
||||
$checkout = WC_Checkout::instance();
|
||||
$order_id = $checkout->create_order(
|
||||
array(
|
||||
'payment_method' => 'cod',
|
||||
'billing_email' => 'a@b.com',
|
||||
)
|
||||
);
|
||||
|
||||
// Assertions whether the order was created successfully.
|
||||
$this->assertNotWPError( $order_id );
|
||||
$order = wc_get_order( $order_id );
|
||||
|
||||
return array( $product, $order );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test when order is out stock because it is held by an order in pending status.
|
||||
*
|
||||
* @throws Exception When unable to create order.
|
||||
*/
|
||||
public function test_create_order_when_out_of_stock() {
|
||||
list( $product, $order ) = $this->create_order_for_managed_inventory_product();
|
||||
|
||||
$this->assertEquals( 9, $order->get_item_count() );
|
||||
$this->assertEquals( 'pending', $order->get_status() );
|
||||
$this->assertEquals( 9, wc_get_held_stock_quantity( $product ) );
|
||||
|
||||
WC()->cart->empty_cart();
|
||||
WC()->cart->add_to_cart( $product->get_stock_managed_by_id(), 2 );
|
||||
|
||||
$this->assertEquals( false, WC()->cart->check_cart_items() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if pending stock is cleared when order is cancelled.
|
||||
*
|
||||
* @throws Exception When unable to create order.
|
||||
*/
|
||||
public function test_pending_is_cleared_when_order_is_cancelled() {
|
||||
list( $product, $order ) = $this->create_order_for_managed_inventory_product();
|
||||
|
||||
$this->assertEquals( 9, wc_get_held_stock_quantity( $product ) );
|
||||
$order->set_status( 'cancelled' );
|
||||
$order->save();
|
||||
|
||||
$this->assertEquals( 0, wc_get_held_stock_quantity( $product ) );
|
||||
$this->assertEquals( 10, $product->get_stock_quantity() );
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if pending stock is cleared when order is processing.
|
||||
*
|
||||
* @throws Exception When unable to create order.
|
||||
*/
|
||||
public function test_pending_is_cleared_when_order_processed() {
|
||||
list( $product, $order ) = $this->create_order_for_managed_inventory_product();
|
||||
|
||||
$this->assertEquals( 9, wc_get_held_stock_quantity( $product ) );
|
||||
$order->set_status( 'processing' );
|
||||
$order->save();
|
||||
|
||||
$this->assertEquals( 0, wc_get_held_stock_quantity( $product ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test creating order from managed stock for variable product.
|
||||
*
|
||||
* @throws Exception When unable to create an order.
|
||||
*/
|
||||
public function test_create_order_for_variation_product() {
|
||||
$parent_product = WC_Helper_Product::create_variation_product();
|
||||
$variation = $parent_product->get_available_variations()[0];
|
||||
$variation = wc_get_product( $variation['variation_id'] );
|
||||
$variation->set_manage_stock( true );
|
||||
$variation->set_stock_quantity( 10 );
|
||||
$variation->save();
|
||||
WC()->cart->add_to_cart( $variation->get_id(), 9 );
|
||||
$this->assertEquals( true, WC()->cart->check_cart_items() );
|
||||
|
||||
$checkout = WC_Checkout::instance();
|
||||
$order_id = $checkout->create_order(
|
||||
array(
|
||||
'payment_method' => 'cod',
|
||||
'billing_email' => 'a@b.com',
|
||||
)
|
||||
);
|
||||
|
||||
// Assertions whether the first order was created successfully.
|
||||
$this->assertNotWPError( $order_id );
|
||||
$order = wc_get_order( $order_id );
|
||||
|
||||
$this->assertEquals( 9, $order->get_item_count() );
|
||||
$this->assertEquals( 'pending', $order->get_status() );
|
||||
$this->assertEquals( 9, wc_get_held_stock_quantity( $variation ) );
|
||||
|
||||
WC()->cart->empty_cart();
|
||||
WC()->cart->add_to_cart( $variation->get_stock_managed_by_id(), 2 );
|
||||
|
||||
$this->assertEquals( false, WC()->cart->check_cart_items() );
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
/**
|
||||
* Class WC_Tests_Order.
|
||||
*/
|
||||
class WC_Tests_Order extends WC_Unit_Test_Case {
|
||||
class WC_Tests_Orders extends WC_Unit_Test_Case {
|
||||
|
||||
/**
|
||||
* Test for total when round at subtotal is enabled.
|
||||
|
|
Loading…
Reference in New Issue