From 2ebd86f2dcf898453e03c2ed4f4aca3fa1d82c51 Mon Sep 17 00:00:00 2001 From: Claudio Sanches Date: Wed, 4 Dec 2019 16:04:30 -0300 Subject: [PATCH 01/18] Revert "Revert "Add support for '_held_for_checkout` records to prevent race conditions."" --- includes/class-wc-checkout.php | 2 + includes/class-wc-order.php | 131 +++++++++++++++ .../class-wc-product-data-store-cpt.php | 50 +++++- .../class-wc-shortcode-checkout.php | 2 +- includes/wc-order-functions.php | 19 +++ includes/wc-stock-functions.php | 55 +++++-- tests/unit-tests/checkout/checkout.php | 152 ++++++++++++++++-- .../unit-tests/order/class-wc-tests-order.php | 93 +++++++++++ 8 files changed, 471 insertions(+), 33 deletions(-) create mode 100644 tests/unit-tests/order/class-wc-tests-order.php diff --git a/includes/class-wc-checkout.php b/includes/class-wc-checkout.php index c3016649051..910d3492317 100644 --- a/includes/class-wc-checkout.php +++ b/includes/class-wc-checkout.php @@ -367,6 +367,7 @@ class WC_Checkout { } $order->hold_applied_coupons( $data['billing_email'] ); + $order->hold_stock_for_checkout( WC()->cart ); $order->set_created_via( 'checkout' ); $order->set_cart_hash( $cart_hash ); $order->set_customer_id( apply_filters( 'woocommerce_checkout_customer_id', get_current_user_id() ) ); @@ -406,6 +407,7 @@ class WC_Checkout { } catch ( Exception $e ) { if ( $order && $order instanceof WC_Order ) { $order->get_data_store()->release_held_coupons( $order ); + $order->release_held_stock(); } return new WP_Error( 'checkout-error', $e->getMessage() ); } diff --git a/includes/class-wc-order.php b/includes/class-wc-order.php index b2ab190c506..760d378f16d 100644 --- a/includes/class-wc-order.php +++ b/includes/class-wc-order.php @@ -2070,4 +2070,135 @@ class WC_Order extends WC_Abstract_Order { public function is_created_via( $modus ) { return apply_filters( 'woocommerce_order_is_created_via', $modus === $this->get_created_via(), $this, $modus ); } + + /** + * Adds a '_held_for_checkout` record for all products in cart. + * + * @since 3.9.0 + * @param WC_Cart $cart Cart instance. + * @throws Exception When unable to hold stock for checkout. + */ + public function hold_stock_for_checkout( $cart ) { + /** + * Filter: woocommerce_hold_stock_for_checkout + * Allows enable/disable hold stock functionality on checkout. + * + * @since 3.9.0 + * @param bool $enabled Default to true. + */ + if ( ! apply_filters( 'woocommerce_hold_stock_for_checkout', true ) ) { + return; + } + + if ( ! get_option( 'woocommerce_manage_stock' ) ) { + return; + } + + $hold_stock_minutes = (int) get_option( 'woocommerce_hold_stock_minutes', 0 ); + if ( 0 >= $hold_stock_minutes ) { + return; + } + + $product_qty_in_cart = $cart->get_cart_item_quantities(); + $stock_held_keys = array(); + $error = null; + + try { + foreach ( $cart->get_cart() as $cart_item_key => $values ) { + $product = wc_get_product( $values['data'] ); + if ( ! $product ) { + // Unsupported product! + continue; + } + $product_id = $product->get_stock_managed_by_id(); + $result = $this->hold_product_for_checkout( $product, $product_qty_in_cart[ $product_id ] ); + if ( false === $result ) { + // translators: Name of the product. + throw new Exception( sprintf( __( 'Something changed during checkout. %s is no longer available.', 'woocommerce' ), $product->get_name() ) ); + } + if ( null === $result ) { + continue; + } + $stock_held_keys[ $product_id ] = $result; + } + } catch ( Exception $e ) { + $error = $e; + } finally { + if ( 0 < count( $stock_held_keys ) ) { + $this->record_held_stock( $stock_held_keys ); + } + if ( $error instanceof Exception ) { + throw $error; + } + } + } + + /** + * Adds a `_held_for_checkout` record for a product in checkout. + * + * @since 3.9.0 + * @param WC_Product $product Instance of product. + * @param int $quantity Quantity of product to hold. + * @return bool|string|null Returns `false` when unable to hold stock, meta key when stock was held successfully, `null` when holding stock is not needed. + */ + protected function hold_product_for_checkout( $product, $quantity ) { + global $wpdb; + if ( ! $product->managing_stock() || $product->backorders_allowed() ) { + return null; + } + $product_id = $product->get_stock_managed_by_id(); + $product_data_store = WC_Data_Store::load( 'product' ); + $held_unique_str = wp_generate_password( 6, false ); + $db_timestamp = $wpdb->get_var( 'SELECT UNIX_TIMESTAMP() FROM DUAL' ); + $hold_stock_minutes = (int) get_option( 'woocommerce_hold_stock_minutes', 60 ); + $expire_timestamp = (int) $db_timestamp + ( $hold_stock_minutes * 60 ); + $held_key = "_held_for_checkout_${expire_timestamp}_$held_unique_str"; + $query_for_held_stock = $product_data_store->get_query_for_held_stock( $product_id ); + $query_for_stock = $product_data_store->get_query_for_stock( $product_id ); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $insert_statement = $wpdb->prepare( + " + INSERT INTO $wpdb->postmeta ( post_id, meta_key, meta_value ) + SELECT %d, %s, %d from DUAL + WHERE ( $query_for_stock ) - ( $query_for_held_stock ) >= %d + ", + $product->get_stock_managed_by_id(), + $held_key, + $quantity, + $quantity + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + $result = $wpdb->query( $insert_statement ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + + return $result > 0 ? $held_key : false; + } + + /** + * Save keys used to held stock to DB. + * + * @since 3.9.0 + * @param array $keys Array of keys to save. + */ + public function record_held_stock( $keys ) { + if ( is_array( $keys ) && 0 < count( $keys ) ) { + $this->update_meta_data( '_stock_held_keys', $keys ); + } + } + + /** + * Releases held stock, also deletes keys for the order. + * + * @since 3.9.0 + */ + public function release_held_stock() { + $stock_held_keys = $this->get_meta( '_stock_held_keys' ); + if ( is_array( $stock_held_keys ) ) { + foreach ( $stock_held_keys as $product_managed_id => $meta_key ) { + delete_post_meta( $product_managed_id, $meta_key ); + } + } + $this->delete_meta_data( '_stock_held_keys' ); + } } diff --git a/includes/data-stores/class-wc-product-data-store-cpt.php b/includes/data-stores/class-wc-product-data-store-cpt.php index 28eb05aae9d..b05203283a3 100644 --- a/includes/data-stores/class-wc-product-data-store-cpt.php +++ b/includes/data-stores/class-wc-product-data-store-cpt.php @@ -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'; } + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared return $wpdb->get_results( - // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared " 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 } /** @@ -1596,7 +1596,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 '; } @@ -2055,4 +2055,48 @@ 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. + * + * @since 3.9.0 + * @param int $product_id Product ID. + * @return string|void Query statement. + */ + public function get_query_for_stock( $product_id ) { + global $wpdb; + return $wpdb->prepare( + // MAX function below is used to make sure result is a scalar. + " + SELECT COALESCE ( MAX( meta_value ), 0 ) FROM $wpdb->postmeta + WHERE {$wpdb->postmeta}.meta_key = '_stock' + AND {$wpdb->postmeta}.post_id = %d + FOR UPDATE + ", + $product_id + ); + } + + /** + * Returns query statement for getting quantity of stock held by orders in checkout. + * + * @since 3.9.0 + * @param int $product_id Product ID. + * @return string|void Query statement. + */ + public function get_query_for_held_stock( $product_id ) { + global $wpdb; + return $wpdb->prepare( + " + SELECT COALESCE ( SUM( meta_value ), 0 ) FROM $wpdb->postmeta + WHERE {$wpdb->postmeta}.meta_key like %s + AND {$wpdb->postmeta}.meta_key > CONCAT( %s, UNIX_TIMESTAMP() ) + AND {$wpdb->postmeta}.post_id = %d + FOR UPDATE + ", + '_held_for_checkout_%', + '_held_for_checkout_', + $product_id + ); + } } diff --git a/includes/shortcodes/class-wc-shortcode-checkout.php b/includes/shortcodes/class-wc-shortcode-checkout.php index 6316d07e2f8..e9ab602aff8 100644 --- a/includes/shortcodes/class-wc-shortcode-checkout.php +++ b/includes/shortcodes/class-wc-shortcode-checkout.php @@ -158,7 +158,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 = ( $hold_stock_minutes > 0 ) ? wc_get_held_stock_quantity( $product ) : 0; $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 ) ) { diff --git a/includes/wc-order-functions.php b/includes/wc-order-functions.php index 26e98163717..aea4ae92906 100644 --- a/includes/wc-order-functions.php +++ b/includes/wc-order-functions.php @@ -807,6 +807,8 @@ function wc_order_search( $term ) { function wc_update_total_sales_counts( $order_id ) { $order = wc_get_order( $order_id ); + $order->release_held_stock(); + if ( ! $order || $order->get_data_store()->get_recorded_sales( $order ) ) { return; } @@ -885,6 +887,23 @@ function wc_update_coupon_usage_counts( $order_id ) { $order->get_data_store()->release_held_coupons( $order, true ); } } + +/** + * Release held stock if any for an order. + * + * @since 3.9.0 + * @param int $order_id Order ID. + */ +function wc_release_held_stock( $order_id ) { + $order = wc_get_order( $order_id ); + if ( ! $order ) { + return; + } + + $order->release_held_stock(); +} +add_action( 'woocommerce_order_status_cancelled', 'wc_release_held_stock' ); + add_action( 'woocommerce_order_status_pending', 'wc_update_coupon_usage_counts' ); add_action( 'woocommerce_order_status_completed', 'wc_update_coupon_usage_counts' ); add_action( 'woocommerce_order_status_processing', 'wc_update_coupon_usage_counts' ); diff --git a/includes/wc-stock-functions.php b/includes/wc-stock-functions.php index 2deb1580e4c..e3e3d0f61e7 100644 --- a/includes/wc-stock-functions.php +++ b/includes/wc-stock-functions.php @@ -302,28 +302,53 @@ function wc_increase_stock_levels( $order_id ) { function wc_get_held_stock_quantity( $product, $exclude_order_id = 0 ) { global $wpdb; + /** + * Filter: woocommerce_hold_stock_for_checkout + * Allows enable/disable hold stock functionality on checkout. + * + * @since 3.9.0 + * @param bool $enabled Default to true. + */ + if ( ! apply_filters( 'woocommerce_hold_stock_for_checkout', true ) ) { + /** + * DOES NOT SUPPORT `exclude_order_id` param, which was primarily being used to exclude the current order! + * While creating an order during checkout flow, this logic relies on the fact that order is not yet saved and therefore will be excluded. + * + * Query for calculating held stock which uses '_held_for_checkout' records instead of actually querying all orders with wc-pending status. + * This is introduced to handle race conditions. + * + * @since 3.9.0 + */ + $product_data_store = WC_Data_Store::load( 'product' ); + $product_id = $product->get_stock_managed_by_id(); + return $wpdb->get_var( $product_data_store->get_query_for_held_stock( $product_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } + return $wpdb->get_var( + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared $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;", + 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. + // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared + ); } /** diff --git a/tests/unit-tests/checkout/checkout.php b/tests/unit-tests/checkout/checkout.php index 874dd648844..8be848caebe 100644 --- a/tests/unit-tests/checkout/checkout.php +++ b/tests/unit-tests/checkout/checkout.php @@ -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(); @@ -32,24 +31,24 @@ class WC_Tests_Checkout extends WC_Unit_Test_Case { * @throws Exception When unable to create order. */ public function test_create_order_with_limited_coupon() { - $coupon_code = 'coupon4one'; + $coupon_code = 'coupon4one'; $coupon_data_store = WC_Data_Store::load( 'coupon' ); - $coupon = WC_Helper_Coupon::create_coupon( + $coupon = WC_Helper_Coupon::create_coupon( $coupon_code, array( 'usage_limit' => 1 ) ); - $product = WC_Helper_Product::create_simple_product( true ); + $product = WC_Helper_Product::create_simple_product( true ); WC()->cart->add_to_cart( $product->get_id(), 1 ); WC()->cart->add_discount( $coupon->get_code() ); $checkout = WC_Checkout::instance(); $order_id = $checkout->create_order( array( - 'billing_email' => 'a@b.com', + 'billing_email' => 'a@b.com', 'payment_method' => 'dummy_payment_gateway', ) ); $this->assertNotWPError( $order_id ); - $order = new WC_Order( $order_id ); + $order = new WC_Order( $order_id ); $coupon_held_key = $order->get_data_store()->get_coupon_held_keys( $order ); $this->assertEquals( count( $coupon_held_key ), 1 ); $this->assertEquals( array_keys( $coupon_held_key )[0], $coupon->get_id() ); @@ -61,7 +60,7 @@ class WC_Tests_Checkout extends WC_Unit_Test_Case { WC()->cart->add_discount( $coupon->get_code() ); $order2_id = $checkout->create_order( array( - 'billing_email' => 'a@c.com', + 'billing_email' => 'a@c.com', 'payment_method' => 'dummy_payment_gateway', ) ); @@ -75,8 +74,8 @@ class WC_Tests_Checkout extends WC_Unit_Test_Case { * @throws Exception When unable to create an order. */ public function test_create_order_with_multiple_limited_coupons() { - $coupon_code1 = 'coupon1'; - $coupon_code2 = 'coupon2'; + $coupon_code1 = 'coupon1'; + $coupon_code2 = 'coupon2'; $coupon_data_store = WC_Data_Store::load( 'coupon' ); $coupon1 = WC_Helper_Coupon::create_coupon( @@ -91,10 +90,10 @@ class WC_Tests_Checkout extends WC_Unit_Test_Case { WC()->cart->add_to_cart( $product->get_id(), 1 ); WC()->cart->add_discount( $coupon_code1 ); WC()->cart->add_discount( $coupon_code2 ); - $checkout = WC_Checkout::instance(); + $checkout = WC_Checkout::instance(); $order_id1 = $checkout->create_order( array( - 'billing_email' => 'a@b.com', + 'billing_email' => 'a@b.com', 'payment_method' => 'dummy_payment_gateway', ) ); @@ -110,7 +109,7 @@ class WC_Tests_Checkout extends WC_Unit_Test_Case { $order2_id = $checkout->create_order( array( - 'billing_email' => 'a@b.com', + 'billing_email' => 'a@b.com', 'payment_method' => 'dummy_payment_gateway', ) ); @@ -158,4 +157,129 @@ 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( WC_Product, WC_Order ) 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() ); + } + + /** + * Legacy version for test `test_create_order_when_out_of_stock` above. + * + * @throws Exception When unable to create order. + */ + public function test_create_order_when_out_of_stock_legacy() { + add_filter( 'woocommerce_hold_stock_for_checkout', '__return_false' ); + $this->test_create_order_when_out_of_stock(); + } + + /** + * 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() ); + } } diff --git a/tests/unit-tests/order/class-wc-tests-order.php b/tests/unit-tests/order/class-wc-tests-order.php new file mode 100644 index 00000000000..efbee3333b9 --- /dev/null +++ b/tests/unit-tests/order/class-wc-tests-order.php @@ -0,0 +1,93 @@ +cart->empty_cart(); + } + + /** + * Test pending stock and `release_held_stock` and `record_held_stock` methods as well. + * @throws Exception When unable to create an order. + */ + public function test_pending_stock_for_order_with_multiple_product() { + $product1 = WC_Helper_Product::create_simple_product(); + $product1->set_props( array( 'manage_stock' => true ) ); + $product1->set_stock_quantity( 10 ); + $product1->save(); + + $product2 = WC_Helper_Product::create_simple_product(); + $product2->set_props( array( 'manage_stock' => true ) ); + $product2->set_stock_quantity( 10 ); + $product2->save(); + + WC()->cart->add_to_cart( $product1->get_id(), 9 ); + WC()->cart->add_to_cart( $product2->get_id(), 5 ); + $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', + ) + ); + + $this->assertNotWPError( $order_id ); + $order_held_stock_keys = get_post_meta( $order_id, '_stock_held_keys', true ); + + $product1_id = $product1->get_stock_managed_by_id(); + $product2_id = $product2->get_stock_managed_by_id(); + + $this->assertEquals( true, in_array( $product1_id, array_keys( $order_held_stock_keys ) ) ); + $this->assertEquals( true, in_array( $product2_id, array_keys( $order_held_stock_keys ) ) ); + + $this->assertEquals( 9, wc_get_held_stock_quantity( $product1 ) ); + $this->assertEquals( 5, wc_get_held_stock_quantity( $product2 ) ); + + $order = wc_get_order( $order_id ); + $order->release_held_stock(); + + $this->assertEquals( 0, wc_get_held_stock_quantity( $product1 ) ); + $this->assertEquals( 0, wc_get_held_stock_quantity( $product2 ) ); + } + + /** + * Test `release_held_stock` function of unmanaged product. + * @throws Exception When unable to create an order. + */ + public function test_release_held_of_unmanaged_product() { + $product = WC_Helper_Product::create_simple_product(); + + 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', + ) + ); + + $this->assertNotWPError( $order_id ); + $order = wc_get_order( $order_id ); + $this->assertEquals( 0, wc_get_held_stock_quantity( $product ) ); + + $order->release_held_stock(); + $this->assertEquals( 0, wc_get_held_stock_quantity( $product ) ); + } +} From 3bd34184e414de21b8884d21fd861ebc843e9972 Mon Sep 17 00:00:00 2001 From: vedanshujain Date: Thu, 12 Dec 2019 15:46:39 +0530 Subject: [PATCH 02/18] Fixed bool error on when to disable feature --- includes/wc-stock-functions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/wc-stock-functions.php b/includes/wc-stock-functions.php index e3e3d0f61e7..76bb38e31a8 100644 --- a/includes/wc-stock-functions.php +++ b/includes/wc-stock-functions.php @@ -309,7 +309,7 @@ function wc_get_held_stock_quantity( $product, $exclude_order_id = 0 ) { * @since 3.9.0 * @param bool $enabled Default to true. */ - if ( ! apply_filters( 'woocommerce_hold_stock_for_checkout', true ) ) { + if ( apply_filters( 'woocommerce_hold_stock_for_checkout', true ) ) { /** * DOES NOT SUPPORT `exclude_order_id` param, which was primarily being used to exclude the current order! * While creating an order during checkout flow, this logic relies on the fact that order is not yet saved and therefore will be excluded. From facfb161310c2214d0b1f2521217be027a1f059b Mon Sep 17 00:00:00 2001 From: vedanshujain Date: Thu, 12 Dec 2019 16:00:26 +0530 Subject: [PATCH 03/18] Changed class name to match file name --- tests/unit-tests/order/class-wc-tests-orders.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit-tests/order/class-wc-tests-orders.php b/tests/unit-tests/order/class-wc-tests-orders.php index 34d7227143d..c51c3271aea 100644 --- a/tests/unit-tests/order/class-wc-tests-orders.php +++ b/tests/unit-tests/order/class-wc-tests-orders.php @@ -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. From 7228d67799cb45909fb015b4aea07dc56282f073 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Wed, 19 Feb 2020 11:54:06 +0000 Subject: [PATCH 04/18] Remove helpers from order functions --- includes/wc-order-functions.php | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/includes/wc-order-functions.php b/includes/wc-order-functions.php index aea4ae92906..26e98163717 100644 --- a/includes/wc-order-functions.php +++ b/includes/wc-order-functions.php @@ -807,8 +807,6 @@ function wc_order_search( $term ) { function wc_update_total_sales_counts( $order_id ) { $order = wc_get_order( $order_id ); - $order->release_held_stock(); - if ( ! $order || $order->get_data_store()->get_recorded_sales( $order ) ) { return; } @@ -887,23 +885,6 @@ function wc_update_coupon_usage_counts( $order_id ) { $order->get_data_store()->release_held_coupons( $order, true ); } } - -/** - * Release held stock if any for an order. - * - * @since 3.9.0 - * @param int $order_id Order ID. - */ -function wc_release_held_stock( $order_id ) { - $order = wc_get_order( $order_id ); - if ( ! $order ) { - return; - } - - $order->release_held_stock(); -} -add_action( 'woocommerce_order_status_cancelled', 'wc_release_held_stock' ); - add_action( 'woocommerce_order_status_pending', 'wc_update_coupon_usage_counts' ); add_action( 'woocommerce_order_status_completed', 'wc_update_coupon_usage_counts' ); add_action( 'woocommerce_order_status_processing', 'wc_update_coupon_usage_counts' ); From 51aca598b94fad0b02df3d31c3956d7e23b3b769 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Wed, 19 Feb 2020 11:54:29 +0000 Subject: [PATCH 05/18] Only implement get_query_for_stock in data store --- .../class-wc-product-data-store-cpt.php | 33 +++---------------- 1 file changed, 4 insertions(+), 29 deletions(-) diff --git a/includes/data-stores/class-wc-product-data-store-cpt.php b/includes/data-stores/class-wc-product-data-store-cpt.php index b05203283a3..6295343e856 100644 --- a/includes/data-stores/class-wc-product-data-store-cpt.php +++ b/includes/data-stores/class-wc-product-data-store-cpt.php @@ -2059,44 +2059,19 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da /** * Returns query statement for getting current `_stock` of a product. * - * @since 3.9.0 + * @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( - // MAX function below is used to make sure result is a scalar. " - SELECT COALESCE ( MAX( meta_value ), 0 ) FROM $wpdb->postmeta - WHERE {$wpdb->postmeta}.meta_key = '_stock' - AND {$wpdb->postmeta}.post_id = %d - FOR UPDATE + 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 ); } - - /** - * Returns query statement for getting quantity of stock held by orders in checkout. - * - * @since 3.9.0 - * @param int $product_id Product ID. - * @return string|void Query statement. - */ - public function get_query_for_held_stock( $product_id ) { - global $wpdb; - return $wpdb->prepare( - " - SELECT COALESCE ( SUM( meta_value ), 0 ) FROM $wpdb->postmeta - WHERE {$wpdb->postmeta}.meta_key like %s - AND {$wpdb->postmeta}.meta_key > CONCAT( %s, UNIX_TIMESTAMP() ) - AND {$wpdb->postmeta}.post_id = %d - FOR UPDATE - ", - '_held_for_checkout_%', - '_held_for_checkout_', - $product_id - ); - } } From 02cf328afebb19635ef2baedca6b7c81eeea5cea Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Wed, 19 Feb 2020 11:56:11 +0000 Subject: [PATCH 06/18] Remove check for held stock minutes during stock checks - this will be handled elsewhere --- includes/class-wc-cart.php | 3 +-- includes/shortcodes/class-wc-shortcode-checkout.php | 7 +++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/includes/class-wc-cart.php b/includes/class-wc-cart.php index 0e2e5db7dd6..c194df401e4 100644 --- a/includes/class-wc-cart.php +++ b/includes/class-wc-cart.php @@ -764,7 +764,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 ) { @@ -783,7 +782,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 ) ) { diff --git a/includes/shortcodes/class-wc-shortcode-checkout.php b/includes/shortcodes/class-wc-shortcode-checkout.php index e9ab602aff8..31413213a72 100644 --- a/includes/shortcodes/class-wc-shortcode-checkout.php +++ b/includes/shortcodes/class-wc-shortcode-checkout.php @@ -84,9 +84,8 @@ class WC_Shortcode_Checkout { // Pay for existing order. if ( isset( $_GET['pay_for_order'], $_GET['key'] ) && $order_id ) { // WPCS: input var ok, CSRF ok. 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_key = isset( $_GET['key'] ) ? wc_clean( wp_unslash( $_GET['key'] ) ) : ''; // WPCS: input var ok, CSRF ok. + $order = wc_get_order( $order_id ); // 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 ) : 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 ) ) { From 910a13f5444c73df7de8cbdb542e2e5810bb9f87 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Wed, 19 Feb 2020 11:57:02 +0000 Subject: [PATCH 07/18] Fire action hooks rather than call stock holding functions directly --- includes/class-wc-checkout.php | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/includes/class-wc-checkout.php b/includes/class-wc-checkout.php index 910d3492317..676c64d2b1f 100644 --- a/includes/class-wc-checkout.php +++ b/includes/class-wc-checkout.php @@ -367,7 +367,6 @@ class WC_Checkout { } $order->hold_applied_coupons( $data['billing_email'] ); - $order->hold_stock_for_checkout( WC()->cart ); $order->set_created_via( 'checkout' ); $order->set_cart_hash( $cart_hash ); $order->set_customer_id( apply_filters( 'woocommerce_checkout_customer_id', get_current_user_id() ) ); @@ -401,13 +400,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.0.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 ); - $order->release_held_stock(); + /** + * Action hook fired when an order is discarded due to Exception. + * + * @since 4.0.0 + */ + do_action( 'woocommerce_checkout_order_exception', $order ); } return new WP_Error( 'checkout-error', $e->getMessage() ); } From 802fff94c21af66ebe609128b4c6c79dec4be72f Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Wed, 19 Feb 2020 11:57:51 +0000 Subject: [PATCH 08/18] Remove stock methods from order class --- includes/class-wc-order.php | 131 ------------------------------------ 1 file changed, 131 deletions(-) diff --git a/includes/class-wc-order.php b/includes/class-wc-order.php index 760d378f16d..b2ab190c506 100644 --- a/includes/class-wc-order.php +++ b/includes/class-wc-order.php @@ -2070,135 +2070,4 @@ class WC_Order extends WC_Abstract_Order { public function is_created_via( $modus ) { return apply_filters( 'woocommerce_order_is_created_via', $modus === $this->get_created_via(), $this, $modus ); } - - /** - * Adds a '_held_for_checkout` record for all products in cart. - * - * @since 3.9.0 - * @param WC_Cart $cart Cart instance. - * @throws Exception When unable to hold stock for checkout. - */ - public function hold_stock_for_checkout( $cart ) { - /** - * Filter: woocommerce_hold_stock_for_checkout - * Allows enable/disable hold stock functionality on checkout. - * - * @since 3.9.0 - * @param bool $enabled Default to true. - */ - if ( ! apply_filters( 'woocommerce_hold_stock_for_checkout', true ) ) { - return; - } - - if ( ! get_option( 'woocommerce_manage_stock' ) ) { - return; - } - - $hold_stock_minutes = (int) get_option( 'woocommerce_hold_stock_minutes', 0 ); - if ( 0 >= $hold_stock_minutes ) { - return; - } - - $product_qty_in_cart = $cart->get_cart_item_quantities(); - $stock_held_keys = array(); - $error = null; - - try { - foreach ( $cart->get_cart() as $cart_item_key => $values ) { - $product = wc_get_product( $values['data'] ); - if ( ! $product ) { - // Unsupported product! - continue; - } - $product_id = $product->get_stock_managed_by_id(); - $result = $this->hold_product_for_checkout( $product, $product_qty_in_cart[ $product_id ] ); - if ( false === $result ) { - // translators: Name of the product. - throw new Exception( sprintf( __( 'Something changed during checkout. %s is no longer available.', 'woocommerce' ), $product->get_name() ) ); - } - if ( null === $result ) { - continue; - } - $stock_held_keys[ $product_id ] = $result; - } - } catch ( Exception $e ) { - $error = $e; - } finally { - if ( 0 < count( $stock_held_keys ) ) { - $this->record_held_stock( $stock_held_keys ); - } - if ( $error instanceof Exception ) { - throw $error; - } - } - } - - /** - * Adds a `_held_for_checkout` record for a product in checkout. - * - * @since 3.9.0 - * @param WC_Product $product Instance of product. - * @param int $quantity Quantity of product to hold. - * @return bool|string|null Returns `false` when unable to hold stock, meta key when stock was held successfully, `null` when holding stock is not needed. - */ - protected function hold_product_for_checkout( $product, $quantity ) { - global $wpdb; - if ( ! $product->managing_stock() || $product->backorders_allowed() ) { - return null; - } - $product_id = $product->get_stock_managed_by_id(); - $product_data_store = WC_Data_Store::load( 'product' ); - $held_unique_str = wp_generate_password( 6, false ); - $db_timestamp = $wpdb->get_var( 'SELECT UNIX_TIMESTAMP() FROM DUAL' ); - $hold_stock_minutes = (int) get_option( 'woocommerce_hold_stock_minutes', 60 ); - $expire_timestamp = (int) $db_timestamp + ( $hold_stock_minutes * 60 ); - $held_key = "_held_for_checkout_${expire_timestamp}_$held_unique_str"; - $query_for_held_stock = $product_data_store->get_query_for_held_stock( $product_id ); - $query_for_stock = $product_data_store->get_query_for_stock( $product_id ); - - // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared - $insert_statement = $wpdb->prepare( - " - INSERT INTO $wpdb->postmeta ( post_id, meta_key, meta_value ) - SELECT %d, %s, %d from DUAL - WHERE ( $query_for_stock ) - ( $query_for_held_stock ) >= %d - ", - $product->get_stock_managed_by_id(), - $held_key, - $quantity, - $quantity - ); - // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared - - $result = $wpdb->query( $insert_statement ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared - - return $result > 0 ? $held_key : false; - } - - /** - * Save keys used to held stock to DB. - * - * @since 3.9.0 - * @param array $keys Array of keys to save. - */ - public function record_held_stock( $keys ) { - if ( is_array( $keys ) && 0 < count( $keys ) ) { - $this->update_meta_data( '_stock_held_keys', $keys ); - } - } - - /** - * Releases held stock, also deletes keys for the order. - * - * @since 3.9.0 - */ - public function release_held_stock() { - $stock_held_keys = $this->get_meta( '_stock_held_keys' ); - if ( is_array( $stock_held_keys ) ) { - foreach ( $stock_held_keys as $product_managed_id => $meta_key ) { - delete_post_meta( $product_managed_id, $meta_key ); - } - } - $this->delete_meta_data( '_stock_held_keys' ); - } } From d2d0967ac06678ec825d01df61199d6b62750d4b Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Wed, 19 Feb 2020 11:59:30 +0000 Subject: [PATCH 09/18] Introduce class to handle stock reservation --- src/Checkout/Helpers/ReserveStock.php | 206 ++++++++++++++++++ .../Helpers/ReserveStockException.php | 60 +++++ 2 files changed, 266 insertions(+) create mode 100644 src/Checkout/Helpers/ReserveStock.php create mode 100644 src/Checkout/Helpers/ReserveStockException.php diff --git a/src/Checkout/Helpers/ReserveStock.php b/src/Checkout/Helpers/ReserveStock.php new file mode 100644 index 00000000000..b823569d2c4 --- /dev/null +++ b/src/Checkout/Helpers/ReserveStock.php @@ -0,0 +1,206 @@ +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 ) { + /** + * Filter: woocommerce_should_reserve_stock_for_orders + * Allows enable/disable hold stock functionality on checkout. + * + * @param bool $enabled Default to true. + */ + if ( ! apply_filters( 'woocommerce_should_reserve_stock_for_orders', true ) ) { + return; + } + + $minutes = $minutes ? $minutes : (int) get_option( 'woocommerce_hold_stock_minutes', 10 ); + + if ( ! $minutes || ! wc_string_to_bool( get_option( 'woocommerce_manage_stock', 'yes' ) ) ) { + 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( + '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( + '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 current `_stock` of a product. + * + * @todo Once merged to woo core data store, this method can be removed. + * @internal MAX function below is used to make sure result is a scalar. + * @param int $product_id Product ID. + * @return string|void Query statement. + */ + private 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 + ); + } + + /** + * 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 + ); + } +} diff --git a/src/Checkout/Helpers/ReserveStockException.php b/src/Checkout/Helpers/ReserveStockException.php new file mode 100644 index 00000000000..c55ad0e464f --- /dev/null +++ b/src/Checkout/Helpers/ReserveStockException.php @@ -0,0 +1,60 @@ +error_code = $code; + $this->error_data = array_merge( array( 'status' => $http_status_code ), $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; + } +} From c983677a6f59f441218f20f28e29b48a2001f326 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Wed, 19 Feb 2020 12:13:10 +0000 Subject: [PATCH 10/18] Implement stock functions --- includes/wc-stock-functions.php | 107 +++++++++++++++----------- src/Checkout/Helpers/ReserveStock.php | 14 +--- 2 files changed, 66 insertions(+), 55 deletions(-) diff --git a/includes/wc-stock-functions.php b/includes/wc-stock-functions.php index 76bb38e31a8..34a1f576b51 100644 --- a/includes/wc-stock-functions.php +++ b/includes/wc-stock-functions.php @@ -299,58 +299,79 @@ 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; - +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 3.9.0 - * @param bool $enabled Default to true. + * @since 4.0.0 + * @param bool $enabled Default to true if managing stock globally. */ - if ( apply_filters( 'woocommerce_hold_stock_for_checkout', true ) ) { - /** - * DOES NOT SUPPORT `exclude_order_id` param, which was primarily being used to exclude the current order! - * While creating an order during checkout flow, this logic relies on the fact that order is not yet saved and therefore will be excluded. - * - * Query for calculating held stock which uses '_held_for_checkout' records instead of actually querying all orders with wc-pending status. - * This is introduced to handle race conditions. - * - * @since 3.9.0 - */ - $product_data_store = WC_Data_Store::load( 'product' ); - $product_id = $product->get_stock_managed_by_id(); - return $wpdb->get_var( $product_data_store->get_query_for_held_stock( $product_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + if ( ! apply_filters( 'woocommerce_hold_stock_for_checkout', wc_string_to_bool( get_option( 'woocommerce_manage_stock', 'yes' ) ) ) ) { + return 0; } - return $wpdb->get_var( - // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared - $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 - ) - // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared - ); + return ( new \Automattic\WooCommerce\Checkout\Helpers() )->get_reserved_stock( $product, $exclude_order_id ); } +/** + * Hold stock for an order. + * + * @throws ReserveStockException If reserve stock fails. + * + * @since 4.0.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 4.0.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 = wc_get_order( $order ); + + if ( $order ) { + ( new \Automattic\WooCommerce\Checkout\Helpers() )->reserve_stock_for_order( $order ); + } +} +add_action( 'woocommerce_checkout_order_created', 'wc_reserve_stock_for_order' ); + +/** + * Release held stock if any for an order. + * + * @since 4.0.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.0.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 = wc_get_order( $order ); + + if ( $order ) { + ( new \Automattic\WooCommerce\Checkout\Helpers() )->release_stock_for_order( $order ); + } +} +add_action( 'woocommerce_checkout_order_exception', 'wc_release_stock_for_order' ); +add_action( 'woocommerce_order_status_cancelled', 'wc_release_stock_for_order' ); +add_action( 'woocommerce_order_status_completed', 'wc_release_stock_for_order' ); +add_action( 'woocommerce_order_status_processing', 'wc_release_stock_for_order' ); +add_action( 'woocommerce_order_status_on-hold', 'wc_release_stock_for_order' ); + /** * Return low stock amount to determine if notification needs to be sent * diff --git a/src/Checkout/Helpers/ReserveStock.php b/src/Checkout/Helpers/ReserveStock.php index b823569d2c4..5660c1ed3f2 100644 --- a/src/Checkout/Helpers/ReserveStock.php +++ b/src/Checkout/Helpers/ReserveStock.php @@ -37,19 +37,9 @@ final class ReserveStock { * @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 ) { - /** - * Filter: woocommerce_should_reserve_stock_for_orders - * Allows enable/disable hold stock functionality on checkout. - * - * @param bool $enabled Default to true. - */ - if ( ! apply_filters( 'woocommerce_should_reserve_stock_for_orders', true ) ) { - return; - } + $minutes = $minutes ? $minutes : (int) get_option( 'woocommerce_hold_stock_minutes', 60 ); - $minutes = $minutes ? $minutes : (int) get_option( 'woocommerce_hold_stock_minutes', 10 ); - - if ( ! $minutes || ! wc_string_to_bool( get_option( 'woocommerce_manage_stock', 'yes' ) ) ) { + if ( ! $minutes ) { return; } From 62e099bd847a16b17cb6461afe67d02bc1bec059 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Wed, 19 Feb 2020 12:15:54 +0000 Subject: [PATCH 11/18] Add table schema --- includes/class-wc-install.php | 9 +++++++++ includes/class-woocommerce.php | 1 + 2 files changed, 10 insertions(+) diff --git a/includes/class-wc-install.php b/includes/class-wc-install.php index 782b28b7854..d44e1b4491f 100644 --- a/includes/class-wc-install.php +++ b/includes/class-wc-install.php @@ -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", ); /** diff --git a/includes/class-woocommerce.php b/includes/class-woocommerce.php index 6fdb8ce21bd..91b4931738c 100644 --- a/includes/class-woocommerce.php +++ b/includes/class-woocommerce.php @@ -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 ) { From e965f854d60faaf7f150393d3461d53a7f67b6fb Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Wed, 19 Feb 2020 12:29:12 +0000 Subject: [PATCH 12/18] Missing class name --- includes/wc-stock-functions.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/includes/wc-stock-functions.php b/includes/wc-stock-functions.php index 34a1f576b51..42537ea5fe6 100644 --- a/includes/wc-stock-functions.php +++ b/includes/wc-stock-functions.php @@ -311,7 +311,7 @@ function wc_get_held_stock_quantity( WC_Product $product, $exclude_order_id = 0 return 0; } - return ( new \Automattic\WooCommerce\Checkout\Helpers() )->get_reserved_stock( $product, $exclude_order_id ); + return ( new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock() )->get_reserved_stock( $product, $exclude_order_id ); } /** @@ -337,7 +337,7 @@ function wc_reserve_stock_for_order( $order ) { $order = wc_get_order( $order ); if ( $order ) { - ( new \Automattic\WooCommerce\Checkout\Helpers() )->reserve_stock_for_order( $order ); + ( new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock() )->reserve_stock_for_order( $order ); } } add_action( 'woocommerce_checkout_order_created', 'wc_reserve_stock_for_order' ); @@ -363,7 +363,7 @@ function wc_release_stock_for_order( $order ) { $order = wc_get_order( $order ); if ( $order ) { - ( new \Automattic\WooCommerce\Checkout\Helpers() )->release_stock_for_order( $order ); + ( new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock() )->release_stock_for_order( $order ); } } add_action( 'woocommerce_checkout_order_exception', 'wc_release_stock_for_order' ); From 107879950e668e3d3d6f364eceaa2bbf3576bf3f Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Wed, 19 Feb 2020 12:46:22 +0000 Subject: [PATCH 13/18] Remove get_query_for_stock as it's part of data-store --- src/Checkout/Helpers/ReserveStock.php | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/src/Checkout/Helpers/ReserveStock.php b/src/Checkout/Helpers/ReserveStock.php index 5660c1ed3f2..13b0851230a 100644 --- a/src/Checkout/Helpers/ReserveStock.php +++ b/src/Checkout/Helpers/ReserveStock.php @@ -151,26 +151,6 @@ final class ReserveStock { } } - /** - * Returns query statement for getting current `_stock` of a product. - * - * @todo Once merged to woo core data store, this method can be removed. - * @internal MAX function below is used to make sure result is a scalar. - * @param int $product_id Product ID. - * @return string|void Query statement. - */ - private 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 - ); - } - /** * Returns query statement for getting reserved stock of a product. * From 4faea42c234f8958a33b9a9472cb34c52cd8a102 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Wed, 19 Feb 2020 13:12:45 +0000 Subject: [PATCH 14/18] Change priorities --- includes/wc-stock-functions.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/includes/wc-stock-functions.php b/includes/wc-stock-functions.php index 42537ea5fe6..9a4c8fbc731 100644 --- a/includes/wc-stock-functions.php +++ b/includes/wc-stock-functions.php @@ -334,7 +334,7 @@ function wc_reserve_stock_for_order( $order ) { return; } - $order = wc_get_order( $order ); + $order = $order instanceof WC_Order ? $order : wc_get_order( $order ); if ( $order ) { ( new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock() )->reserve_stock_for_order( $order ); @@ -360,17 +360,17 @@ function wc_release_stock_for_order( $order ) { return; } - $order = wc_get_order( $order ); + $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_order_status_cancelled', 'wc_release_stock_for_order' ); -add_action( 'woocommerce_order_status_completed', 'wc_release_stock_for_order' ); -add_action( 'woocommerce_order_status_processing', 'wc_release_stock_for_order' ); -add_action( 'woocommerce_order_status_on-hold', 'wc_release_stock_for_order' ); +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 From 9b5189dc7a1ee2299c8c70202bb8966dee4cecbb Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Wed, 19 Feb 2020 14:06:12 +0000 Subject: [PATCH 15/18] Remove tests for order methods which were removed --- tests/unit-tests/checkout/checkout.php | 17 +--- .../unit-tests/order/class-wc-tests-order.php | 93 ------------------- 2 files changed, 4 insertions(+), 106 deletions(-) delete mode 100644 tests/unit-tests/order/class-wc-tests-order.php diff --git a/tests/unit-tests/checkout/checkout.php b/tests/unit-tests/checkout/checkout.php index 8be848caebe..d8f6ca53ab3 100644 --- a/tests/unit-tests/checkout/checkout.php +++ b/tests/unit-tests/checkout/checkout.php @@ -157,11 +157,12 @@ 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 . + /** + * Helper method to create a managed product and a order for that product. * - * @return array( WC_Product, WC_Order ) array + * @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 ) ); @@ -204,16 +205,6 @@ class WC_Tests_Checkout extends WC_Unit_Test_Case { $this->assertEquals( false, WC()->cart->check_cart_items() ); } - /** - * Legacy version for test `test_create_order_when_out_of_stock` above. - * - * @throws Exception When unable to create order. - */ - public function test_create_order_when_out_of_stock_legacy() { - add_filter( 'woocommerce_hold_stock_for_checkout', '__return_false' ); - $this->test_create_order_when_out_of_stock(); - } - /** * Test if pending stock is cleared when order is cancelled. * diff --git a/tests/unit-tests/order/class-wc-tests-order.php b/tests/unit-tests/order/class-wc-tests-order.php deleted file mode 100644 index efbee3333b9..00000000000 --- a/tests/unit-tests/order/class-wc-tests-order.php +++ /dev/null @@ -1,93 +0,0 @@ -cart->empty_cart(); - } - - /** - * Test pending stock and `release_held_stock` and `record_held_stock` methods as well. - * @throws Exception When unable to create an order. - */ - public function test_pending_stock_for_order_with_multiple_product() { - $product1 = WC_Helper_Product::create_simple_product(); - $product1->set_props( array( 'manage_stock' => true ) ); - $product1->set_stock_quantity( 10 ); - $product1->save(); - - $product2 = WC_Helper_Product::create_simple_product(); - $product2->set_props( array( 'manage_stock' => true ) ); - $product2->set_stock_quantity( 10 ); - $product2->save(); - - WC()->cart->add_to_cart( $product1->get_id(), 9 ); - WC()->cart->add_to_cart( $product2->get_id(), 5 ); - $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', - ) - ); - - $this->assertNotWPError( $order_id ); - $order_held_stock_keys = get_post_meta( $order_id, '_stock_held_keys', true ); - - $product1_id = $product1->get_stock_managed_by_id(); - $product2_id = $product2->get_stock_managed_by_id(); - - $this->assertEquals( true, in_array( $product1_id, array_keys( $order_held_stock_keys ) ) ); - $this->assertEquals( true, in_array( $product2_id, array_keys( $order_held_stock_keys ) ) ); - - $this->assertEquals( 9, wc_get_held_stock_quantity( $product1 ) ); - $this->assertEquals( 5, wc_get_held_stock_quantity( $product2 ) ); - - $order = wc_get_order( $order_id ); - $order->release_held_stock(); - - $this->assertEquals( 0, wc_get_held_stock_quantity( $product1 ) ); - $this->assertEquals( 0, wc_get_held_stock_quantity( $product2 ) ); - } - - /** - * Test `release_held_stock` function of unmanaged product. - * @throws Exception When unable to create an order. - */ - public function test_release_held_of_unmanaged_product() { - $product = WC_Helper_Product::create_simple_product(); - - 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', - ) - ); - - $this->assertNotWPError( $order_id ); - $order = wc_get_order( $order_id ); - $this->assertEquals( 0, wc_get_held_stock_quantity( $product ) ); - - $order->release_held_stock(); - $this->assertEquals( 0, wc_get_held_stock_quantity( $product ) ); - } -} From f24890245434e526cabd2f308159ac212fad91ae Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Fri, 3 Apr 2020 15:54:37 +0100 Subject: [PATCH 16/18] Update versions --- includes/class-wc-checkout.php | 4 ++-- includes/wc-stock-functions.php | 13 +++++++------ src/Checkout/Helpers/ReserveStock.php | 6 +++--- src/Checkout/Helpers/ReserveStockException.php | 2 +- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/includes/class-wc-checkout.php b/includes/class-wc-checkout.php index 676c64d2b1f..5a985e0362a 100644 --- a/includes/class-wc-checkout.php +++ b/includes/class-wc-checkout.php @@ -410,7 +410,7 @@ class WC_Checkout { /** * Action hook fired after an order is created. * - * @since 4.0.0 + * @since 4.1.0 */ do_action( 'woocommerce_checkout_order_created', $order ); @@ -421,7 +421,7 @@ class WC_Checkout { /** * Action hook fired when an order is discarded due to Exception. * - * @since 4.0.0 + * @since 4.1.0 */ do_action( 'woocommerce_checkout_order_exception', $order ); } diff --git a/includes/wc-stock-functions.php b/includes/wc-stock-functions.php index 9a4c8fbc731..607aab601a4 100644 --- a/includes/wc-stock-functions.php +++ b/includes/wc-stock-functions.php @@ -304,7 +304,7 @@ 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.0.0 + * @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' ) ) ) ) { @@ -319,7 +319,7 @@ function wc_get_held_stock_quantity( WC_Product $product, $exclude_order_id = 0 * * @throws ReserveStockException If reserve stock fails. * - * @since 4.0.0 + * @since 4.1.0 * @param \WC_Order|int $order Order ID or instance. */ function wc_reserve_stock_for_order( $order ) { @@ -327,7 +327,7 @@ function wc_reserve_stock_for_order( $order ) { * Filter: woocommerce_hold_stock_for_checkout * Allows enable/disable hold stock functionality on checkout. * - * @since 4.0.0 + * @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' ) ) ) ) { @@ -343,9 +343,9 @@ function wc_reserve_stock_for_order( $order ) { add_action( 'woocommerce_checkout_order_created', 'wc_reserve_stock_for_order' ); /** - * Release held stock if any for an order. + * Release held stock for an order. * - * @since 4.0.0 + * @since 4.1.0 * @param \WC_Order|int $order Order ID or instance. */ function wc_release_stock_for_order( $order ) { @@ -353,7 +353,7 @@ function wc_release_stock_for_order( $order ) { * Filter: woocommerce_hold_stock_for_checkout * Allows enable/disable hold stock functionality on checkout. * - * @since 4.0.0 + * @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' ) ) ) ) { @@ -367,6 +367,7 @@ function wc_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' ); 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 ); diff --git a/src/Checkout/Helpers/ReserveStock.php b/src/Checkout/Helpers/ReserveStock.php index 13b0851230a..b882cf83906 100644 --- a/src/Checkout/Helpers/ReserveStock.php +++ b/src/Checkout/Helpers/ReserveStock.php @@ -57,10 +57,10 @@ final class ReserveStock { if ( ! $product->is_in_stock() ) { throw new ReserveStockException( - 'product_out_of_stock', + 'woocommerce_product_out_of_stock', sprintf( /* translators: %s: product name */ - __( '%s is out of stock and cannot be purchased.', 'woocommerce' ), + __( '"%s" is out of stock and cannot be purchased.', 'woocommerce' ), $product->get_name() ), 403 @@ -140,7 +140,7 @@ final class ReserveStock { if ( ! $result ) { $product = wc_get_product( $product_id ); throw new ReserveStockException( - 'product_not_enough_stock', + 'woocommerce_product_not_enough_stock', sprintf( /* translators: %s: product name */ __( 'Not enough units of %s are available in stock to fulfil this order.', 'woocommerce' ), diff --git a/src/Checkout/Helpers/ReserveStockException.php b/src/Checkout/Helpers/ReserveStockException.php index c55ad0e464f..8c63b6d498a 100644 --- a/src/Checkout/Helpers/ReserveStockException.php +++ b/src/Checkout/Helpers/ReserveStockException.php @@ -35,7 +35,7 @@ class ReserveStockException extends \Exception { */ public function __construct( $code, $message, $http_status_code = 400, $data = array() ) { $this->error_code = $code; - $this->error_data = array_merge( array( 'status' => $http_status_code ), $data ); + $this->error_data = $data; parent::__construct( $message, $http_status_code ); } From 1acae4ef92ea03410d2e547f73fc7d313a34b403 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Fri, 3 Apr 2020 15:58:59 +0100 Subject: [PATCH 17/18] woocommerce_payment_complete priority --- includes/wc-stock-functions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/wc-stock-functions.php b/includes/wc-stock-functions.php index 607aab601a4..a153a33c32c 100644 --- a/includes/wc-stock-functions.php +++ b/includes/wc-stock-functions.php @@ -367,7 +367,7 @@ function wc_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' ); +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 ); From 0cce3c5c45af7c26aa8908dc7bf47dc7a42d501e Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Fri, 3 Apr 2020 16:16:20 +0100 Subject: [PATCH 18/18] Missing abspath check --- src/Checkout/Helpers/ReserveStockException.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Checkout/Helpers/ReserveStockException.php b/src/Checkout/Helpers/ReserveStockException.php index 8c63b6d498a..e84409b5f1d 100644 --- a/src/Checkout/Helpers/ReserveStockException.php +++ b/src/Checkout/Helpers/ReserveStockException.php @@ -7,6 +7,8 @@ namespace Automattic\WooCommerce\Checkout\Helpers; +defined( 'ABSPATH' ) || exit; + /** * ReserveStockException class. */