PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 10.9.3
WooCommerce v10.9.3
10.9.4 10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 7.8.0-beta.1 7.8.0-beta.2 7.8.0-rc.1 7.8.0-rc.2 7.8.1 7.8.2 7.8.3 7.8.4 7.9.0 7.9.0-beta.1 7.9.0-beta.2 7.9.0-rc.2 7.9.0-rc.3 7.9.1 7.9.2 8.0.0 8.0.0-beta.1 8.0.0-beta.2 8.0.0-rc.1 8.0.0-rc.2 8.0.1 8.0.2 8.0.3 8.0.4 8.0.5 8.1.0 8.1.0-beta.1 8.1.0-rc.1 8.1.0-rc.2 8.1.1 8.1.2 8.1.3 8.1.4 8.2.0 8.2.0-beta.1 8.2.0-rc.1 8.2.0-rc.2 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.3.0 8.3.0-beta.1 8.3.0-rc.1 8.3.0-rc.2 8.3.1 8.3.2 8.3.3 8.3.4 8.4.0 8.4.0-beta.1 8.4.0-rc.1 8.4.1 8.4.2 8.4.3 8.5.0 8.5.0-beta.1 8.5.0-rc.1 8.5.1 8.5.2 8.5.3 8.5.4 8.5.5 8.6.0 8.6.0-beta.1 8.6.0-rc.1 8.6.1 8.6.2 8.6.3 8.6.4 8.7.0 8.7.0-beta.1 8.7.0-beta.2 8.7.0-rc.1 8.7.1 8.7.2 8.7.3 8.8.0 8.8.0-beta.1 8.8.0-rc.1 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.8.6 8.8.7 8.9.0 8.9.0-beta.1 8.9.0-rc.1 8.9.1 8.9.2 8.9.3 8.9.4 8.9.5 9.0.0 9.0.0-beta.1 9.0.0-beta.2 9.0.0-rc.1 9.0.1 9.0.2 9.0.3 9.0.4 9.1.0 9.1.0-beta.1 9.1.0-rc.1 9.1.1 9.1.2 9.1.3 9.1.4 9.1.5 9.1.6 9.2.0 9.2.0-beta.1 9.2.0-rc.1 9.2.1 9.2.2 9.2.3 9.2.4 9.2.5 9.3.0 9.3.0-beta.1 9.3.0-rc.1 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.3.6 9.4.0 9.4.0-beta.1 9.4.0-beta.2 9.4.0-rc.1 9.4.0-rc.2 9.4.0-rc.3 9.4.0-rc.4 9.4.1 9.4.2 9.4.3 9.4.4 9.4.5 9.5.0 9.5.0-beta.1 9.5.0-beta.2 9.5.0-rc.1 9.5.1 9.5.2 9.5.3 9.5.4 9.6.0 9.6.0-beta.1 9.6.0-beta.2 9.6.0-rc.1 9.6.1 9.6.2 9.6.3 9.6.4 9.7.0 9.7.0-beta.1 9.7.0-rc.1 9.7.1 9.7.2 9.7.3 9.8.0 9.8.0-beta.1 9.8.0-rc.1 9.8.1 9.8.2 9.8.3 9.8.4 9.8.5 9.8.6 9.8.7 9.9.0 9.9.0-beta.1 9.9.0-rc.1 9.9.1 9.9.2 9.9.3 9.9.4 9.9.5 9.9.6 9.9.7 3.7.3 7.1.2 3.8.0 7.2.0 3.8.0-beta.1 7.2.0-beta.1 3.8.0-rc.1 7.2.0-beta.2 3.8.0-rc.2 7.2.0-rc.1 3.8.1 7.2.0-rc.2 3.8.2 7.2.1 3.8.3 7.2.2 3.9.0 7.2.3 3.9.0-beta.1 7.2.4 3.9.0-beta.2 7.3.0 3.9.0-rc.1 7.3.0-beta.1 3.9.0-rc.2 7.3.0-beta.2 3.9.0-rc.3 7.3.0-rc.1 3.9.0-rc.4 7.3.0-rc.2 3.9.1 7.3.1 3.9.2 7.4.0 3.9.3 7.4.0-beta.1 3.9.4 7.4.0-beta.2 3.9.5 7.4.0-rc.1 4.0.0 7.4.0-rc.2 4.0.0-beta.1 7.4.1 4.0.0-rc.1 7.4.2 4.0.0-rc.2 7.5.0 4.0.1 7.5.0-beta.1 4.0.2 7.5.0-beta.2 4.0.3 7.5.0-rc.1 4.0.4 7.5.1 4.1.0 7.5.2 4.1.0-beta.1 7.6.0 4.1.0-beta.2 7.6.0-beta.1 4.1.0-rc.1 7.6.0-beta.2 4.1.0-rc.2 7.6.0-rc.1 4.1.1 7.6.0-rc.2 4.1.2 7.6.0-rc.3 4.1.3 7.6.1 4.1.4 7.6.2 4.2.0 7.7.0 4.2.0-RC.1 7.7.0-beta.1 4.2.0-RC.2 7.7.0-beta.2 4.2.0-beta.1 7.7.0-rc.1 4.2.1 7.7.1 4.2.2 7.7.2 4.2.3 7.7.3 4.2.4 7.8.0 4.2.5 4.3.0 4.3.0-beta.1 4.3.0-rc.1 4.3.0-rc.2 4.3.0-rc.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.3.6 4.4.0 4.4.0-beta.1 4.4.0-rc.1 4.4.1 4.4.2 4.4.3 4.4.4 4.5.0 4.5.0-beta.1 4.5.0-rc.1 4.5.0-rc.3 4.5.1 4.5.2 4.5.3 4.5.4 4.5.5 4.6.0 4.6.0-beta.1 4.6.0-rc.1 4.6.1 4.6.2 4.6.3 4.6.4 4.6.5 4.7.0 4.7.0-beta.1 4.7.0-beta.2 4.7.0-rc.1 4.7.1 4.7.1-beta.1 4.7.2 4.7.3 4.7.4 4.8.0 4.8.0-beta.1 4.8.0-rc.1 4.8.0-rc.2 4.8.1 4.8.2 4.8.3 4.9.0 4.9.0-beta.1 4.9.0-rc.1 4.9.0-rc.2 4.9.1 4.9.2 4.9.3 4.9.4 4.9.5 5.0.0 5.0.0-beta.1 5.0.0-beta.2 5.0.0-rc.1 5.0.0-rc.2 5.0.0-rc.3 5.0.1 5.0.2 5.0.3 5.1.0 5.1.0-beta.1 5.1.0-rc.1 trunk 5.1.1 10.0.0 5.1.2 10.0.0-rc.1 5.1.3 10.0.0-rc.2 5.2.0 10.0.1 5.2.0-beta.1 10.0.2 5.2.0-rc.1 10.0.3 5.2.0-rc.2 10.0.4 5.2.1 10.0.5 5.2.2 10.0.6 5.2.3 10.1.0 5.2.4 10.1.0-rc.1 5.2.5 10.1.0-rc.2 5.3.0 10.1.0-rc.3 5.3.0-beta.1 10.1.0-rc.4 5.3.0-rc.1 10.1.1 5.3.0-rc.2 10.1.2 5.3.1 10.1.3 5.3.2 10.1.4 5.3.3 10.2.0 5.4.0 10.2.0-beta.1 5.4.0-beta.1 10.2.0-beta.2 5.4.0-rc.1 10.2.0-rc.1 5.4.1 10.2.1 5.4.2 10.2.2 5.4.3 10.2.3 5.4.4 10.2.4 5.4.5 10.3.0 5.5.0 10.3.0-beta.1 5.5.0-beta.1 10.3.0-beta.2 5.5.0-rc.1 10.3.0-rc.1 5.5.0-rc.2 10.3.0-rc.2 5.5.1 10.3.1 5.5.2 10.3.2 5.5.3 10.3.3 5.5.4 10.3.4 5.5.5 10.3.5 5.6.0 10.3.6 5.6.0-beta.1 10.3.7 5.6.0-rc.1 10.3.8 5.6.0-rc.2 10.4.0 5.6.1 10.4.0-beta.1 5.6.2 10.4.0-beta.2 5.6.3 10.4.0-rc.1 5.7.0 10.4.1 5.7.0-beta.1 10.4.2 5.7.0-rc.1 10.4.3 5.7.1 10.4.4 5.7.2 10.5.0 5.7.3 10.5.0-beta.1 5.8.0 10.5.0-beta.2 5.8.0-beta.1 10.5.0-rc.1 5.8.0-beta.2 10.5.0-rc.2 5.8.0-rc.1 10.5.0-rc.3 5.8.1 10.5.1 5.8.2 10.5.2 5.9.0 10.5.3 5.9.0-beta.1 10.6.0 5.9.0-rc.1 10.6.0-beta.1 5.9.0-rc.2 10.6.0-beta.2 5.9.1 10.6.0-rc.1 5.9.2 10.6.1 6.0.0 10.6.2 6.0.0-beta.1 10.7.0 6.0.0-rc.1 10.7.0-beta.1 6.0.1 10.7.0-beta.2 6.0.2 10.7.0-rc.1 6.1.0 3.0.0 6.1.0-beta.1 3.0.1 6.1.0-rc.1 3.0.2 6.1.0-rc.2 3.0.3 6.1.1 3.0.4 6.1.2 3.0.5 6.1.3 3.0.6 6.2.0 3.0.7 6.2.0-beta.1 3.0.8 6.2.0-rc.1 3.0.9 6.2.0-rc.2 3.1.0 6.2.1 3.1.1 6.2.2 3.1.2 6.2.3 3.2.0 6.3.0 3.2.1 6.3.0-beta.1 3.2.2 6.3.0-rc.1 3.2.3 6.3.0-rc.2 3.2.4 6.3.1 3.2.5 6.3.2 3.2.6 6.4.0 3.3.0 6.4.0-beta.1 3.3.1 6.4.0-rc.1 3.3.2 6.4.1 3.3.2-rc.1 6.4.2 3.3.3 6.5.0 3.3.4 6.5.0-beta.1 3.3.5 6.5.0-rc.1 3.3.6 6.5.0-rc.2 3.4.0 6.5.1 3.4.0-beta.1 6.5.2 3.4.0-rc.2 6.6.0 3.4.1 6.6.0-beta.1 3.4.2 6.6.0-rc.1 3.4.3 6.6.0-rc.2 3.4.4 6.6.1 3.4.5 6.6.2 3.4.6 6.7.0 3.4.7 6.7.0-beta.1 3.4.8 6.7.0-beta.2 3.5.0 6.7.0-rc.1 3.5.0-beta.1 6.7.1 3.5.0-rc.1 6.8.0 3.5.0-rc.2 6.8.0-beta.1 3.5.1 6.8.0-beta.2 3.5.10 6.8.0-rc.1 3.5.2 6.8.1 3.5.3 6.8.2 3.5.4 6.8.3 3.5.5 6.9.0 3.5.6 6.9.0-beta.1 3.5.7 6.9.0-beta.2 3.5.8 6.9.0-rc.1 3.5.9 6.9.1 3.6.0 6.9.2 3.6.0-beta.1 6.9.3 3.6.0-rc.1 6.9.4 3.6.0-rc.2 6.9.5 3.6.0-rc.3 7.0.0 3.6.1 7.0.0-beta.1 3.6.2 7.0.0-beta.2 3.6.3 7.0.0-beta.3 3.6.4 7.0.0-rc.1 3.6.5 7.0.0-rc.2 3.6.6 7.0.1 3.6.7 7.0.2 3.7.0 7.1.0 3.7.0-beta.1 7.1.0-beta.1 3.7.0-rc.1 7.1.0-beta.2 3.7.0-rc.2 7.1.0-rc.1 3.7.1 7.1.0-rc.2 3.7.2 7.1.1
woocommerce / src / Checkout / Helpers / ReserveStock.php
woocommerce / src / Checkout / Helpers Last commit date
ReserveStock.php 4 weeks ago ReserveStockException.php 5 years ago
ReserveStock.php
307 lines
1 <?php
2 /**
3 * Handle product stock reservation during checkout.
4 */
5
6 namespace Automattic\WooCommerce\Checkout\Helpers;
7
8 use Automattic\WooCommerce\Enums\OrderInternalStatus;
9 use Automattic\WooCommerce\Enums\OrderItemType;
10 use Automattic\WooCommerce\Utilities\OrderUtil;
11 use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
12
13 defined( 'ABSPATH' ) || exit;
14
15 /**
16 * Stock Reservation class.
17 */
18 final class ReserveStock {
19
20 /**
21 * Is stock reservation enabled?
22 *
23 * @var boolean
24 */
25 private $enabled = true;
26
27 /**
28 * Constructor
29 */
30 public function __construct() {
31 // Table needed for this feature are added in 4.3.
32 $this->enabled = get_option( 'woocommerce_schema_version', 0 ) >= 430;
33 }
34
35 /**
36 * Is stock reservation enabled?
37 *
38 * @return boolean
39 */
40 protected function is_enabled() {
41 return $this->enabled;
42 }
43
44 /**
45 * Query for any existing holds on stock for this item.
46 *
47 * @param \WC_Product $product Product to get reserved stock for.
48 * @param int $exclude_order_id Optional order to exclude from the results.
49 *
50 * @return int|float Amount of stock already reserved.
51 */
52 public function get_reserved_stock( $product, $exclude_order_id = 0 ) {
53 global $wpdb;
54
55 if ( ! $this->is_enabled() ) {
56 return 0;
57 }
58
59 return wc_stock_amount(
60 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
61 $wpdb->get_var( $this->get_query_for_reserved_stock( $product->get_stock_managed_by_id(), $exclude_order_id ) )
62 );
63 }
64
65 /**
66 * Put a temporary hold on stock for an order if enough is available.
67 *
68 * @throws ReserveStockException If stock cannot be reserved.
69 *
70 * @param \WC_Order $order Order object.
71 * @param int $minutes How long to reserve stock in minutes. Defaults to woocommerce_hold_stock_minutes.
72 */
73 public function reserve_stock_for_order( $order, $minutes = 0 ) {
74 if ( ! $this->is_enabled() ) {
75 return;
76 }
77
78 $minutes = $minutes ? $minutes : (int) get_option( 'woocommerce_hold_stock_minutes', 60 );
79 /**
80 * Filters the number of minutes an order should reserve stock for.
81 *
82 * This hook allows the number of minutes that stock in an order should be reserved for to be filtered, useful for third party developers to increase/reduce the number of minutes if the order meets certain criteria, or to exclude an order from stock reservation using a zero value.
83 *
84 * @since 8.8.0
85 *
86 * @param int $minutes How long to reserve stock for the order in minutes. Defaults to woocommerce_hold_stock_minutes or 10 if block checkout entry.
87 * @param \WC_Order $order Order object.
88 */
89 $minutes = (int) apply_filters( 'woocommerce_order_hold_stock_minutes', $minutes, $order );
90 if ( ! $minutes ) {
91 return;
92 }
93
94 $held_stock_notes = array();
95 try {
96 $rows = array();
97
98 foreach ( $order->get_items() as $item ) {
99 $is_target_item = $item->is_type( OrderItemType::LINE_ITEM ) && $item->get_quantity() > 0;
100 if ( ! $is_target_item ) {
101 continue;
102 }
103
104 /** @var \WC_Order_Item_Product $item */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
105 $product = $item->get_product();
106 if ( ! $product instanceof \WC_Product ) {
107 continue;
108 }
109
110 if ( ! $product->is_in_stock() ) {
111 throw new ReserveStockException(
112 'woocommerce_product_out_of_stock',
113 sprintf(
114 /* translators: %s: product name */
115 __( '&quot;%s&quot; is out of stock and cannot be purchased.', 'woocommerce' ),
116 $product->get_name()
117 ),
118 403
119 );
120 }
121
122 // If stock management is off, no need to reserve any stock here.
123 if ( ! $product->managing_stock() || $product->backorders_allowed() ) {
124 continue;
125 }
126
127 $managed_by_id = $product->get_stock_managed_by_id();
128
129 /**
130 * Filter order item quantity.
131 *
132 * @since 4.5.0
133 * @param int|float $quantity Quantity.
134 * @param \WC_Order $order Order data.
135 * @param \WC_Order_Item_Product $item Order item data.
136 */
137 $item_quantity = apply_filters( 'woocommerce_order_item_quantity', $item->get_quantity(), $order, $item );
138
139 $rows[ $managed_by_id ] = $item_quantity + ( $rows[ $managed_by_id ] ?? 0 );
140
141 if ( count( $held_stock_notes ) < 5 ) {
142 // translators: %1$s is a product's formatted name, %2$d: is the quantity of said product to which the stock hold applied.
143 $held_stock_notes[] = sprintf( _x( '- %1$s &times; %2$d', 'held stock note', 'woocommerce' ), $product->get_formatted_name(), $rows[ $managed_by_id ] );
144 }
145 }
146
147 if ( ! empty( $rows ) ) {
148 // Reliability: consistent lock order = no cross-product ordering deadlocks from concurrent orders with same products added in different sequences.
149 ksort( $rows );
150 foreach ( $rows as $product_id => $quantity ) {
151 $this->reserve_stock_for_product( $product_id, $quantity, $order, $minutes );
152 }
153 }
154 } catch ( ReserveStockException $e ) {
155 $this->release_stock_for_order( $order );
156 throw $e;
157 }
158
159 // Add order note after successfully holding the stock.
160 if ( ! empty( $held_stock_notes ) ) {
161 $remaining_count = count( $rows ) - count( $held_stock_notes );
162 if ( $remaining_count > 0 ) {
163 $held_stock_notes[] = sprintf(
164 // translators: %d is the remaining order items count.
165 _nx( '- ...and %d more item.', '- ... and %d more items.', $remaining_count, 'held stock note', 'woocommerce' ),
166 $remaining_count
167 );
168 }
169
170 $order->add_order_note(
171 sprintf(
172 // translators: %1$s is a time in minutes, %2$s is a list of products and quantities.
173 _x( 'Stock hold of %1$s minutes applied to: %2$s', 'held stock note', 'woocommerce' ),
174 $minutes,
175 '<br>' . implode( '<br>', $held_stock_notes )
176 ),
177 false,
178 false,
179 array(
180 'note_group' => OrderNoteGroup::PRODUCT_STOCK,
181 )
182 );
183 }
184 }
185
186 /**
187 * Release a temporary hold on stock for an order.
188 *
189 * @param \WC_Order $order Order object.
190 */
191 public function release_stock_for_order( $order ) {
192 global $wpdb;
193
194 if ( ! $this->is_enabled() ) {
195 return;
196 }
197
198 $wpdb->delete(
199 $wpdb->wc_reserved_stock,
200 array(
201 'order_id' => $order->get_id(),
202 )
203 );
204 }
205
206 /**
207 * Reserve stock for a product by inserting rows into the DB.
208 *
209 * @throws ReserveStockException If a row cannot be inserted.
210 *
211 * @param int $product_id Product ID which is having stock reserved.
212 * @param int $stock_quantity Stock amount to reserve.
213 * @param \WC_Order $order Order object which contains the product.
214 * @param int $minutes How long to reserve stock in minutes.
215 */
216 private function reserve_stock_for_product( $product_id, $stock_quantity, $order, $minutes ) {
217 global $wpdb;
218
219 $query_for_stock = \WC_Data_Store::load( 'product' )->get_query_for_stock( $product_id );
220 $query_for_reserved_stock = $this->get_query_for_reserved_stock( $product_id, $order->get_id() );
221
222 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
223 $sql = $wpdb->prepare(
224 "
225 INSERT INTO {$wpdb->wc_reserved_stock} ( `order_id`, `product_id`, `stock_quantity`, `timestamp`, `expires` )
226 SELECT %d, %d, %d, NOW(), ( NOW() + INTERVAL %d MINUTE ) FROM DUAL
227 WHERE ( $query_for_stock FOR UPDATE ) - ( $query_for_reserved_stock FOR UPDATE ) >= %d
228 ON DUPLICATE KEY UPDATE `expires` = VALUES( `expires` ), `stock_quantity` = VALUES( `stock_quantity` )
229 ",
230 $order->get_id(),
231 $product_id,
232 $stock_quantity,
233 $minutes,
234 $stock_quantity
235 );
236 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
237
238 // Reliability: high concurrency on the same product reservation can trigger deadlocks (error codes 1213 and 1205).
239 // We currently do not have a reliable method to identify lock errors. The $wpdb interface does not consistently provide
240 // error codes, and error messages can vary by database locale. Previously, we matched messages to 'try restarting transaction'.
241 for ( $attempt = 0; $attempt < 3; ++$attempt ) {
242 $result = $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
243 if ( false !== $result ) {
244 break;
245 }
246 }
247
248 if ( ! $result ) {
249 $product = wc_get_product( $product_id );
250 throw new ReserveStockException(
251 'woocommerce_product_not_enough_stock',
252 sprintf(
253 /* translators: %s: product name */
254 __( 'Not enough units of %s are available in stock to fulfil this order.', 'woocommerce' ),
255 $product ? $product->get_name() : '#' . $product_id
256 ),
257 403
258 );
259 }
260 }
261
262 /**
263 * Returns query statement for getting reserved stock of a product.
264 *
265 * @param int $product_id Product ID.
266 * @param int $exclude_order_id Optional order to exclude from the results.
267 * @return string|void Query statement.
268 */
269 private function get_query_for_reserved_stock( $product_id, $exclude_order_id = 0 ) {
270 global $wpdb;
271
272 if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
273 $join = "{$wpdb->prefix}wc_orders orders ON stock_table.`order_id` = orders.id";
274 $where_status = "orders.status IN ( 'wc-checkout-draft', '" . OrderInternalStatus::PENDING . "' )";
275 } else {
276 $join = "{$wpdb->posts} posts ON stock_table.`order_id` = posts.ID";
277 $where_status = "posts.post_status IN ( 'wc-checkout-draft', '" . OrderInternalStatus::PENDING . "' )";
278 }
279
280 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
281 $query = $wpdb->prepare(
282 "
283 SELECT COALESCE( SUM( stock_table.`stock_quantity` ), 0 ) FROM $wpdb->wc_reserved_stock stock_table
284 LEFT JOIN $join
285 WHERE $where_status
286 AND stock_table.`expires` > NOW()
287 AND stock_table.`product_id` = %d
288 AND stock_table.`order_id` != %d
289 ",
290 $product_id,
291 $exclude_order_id
292 );
293 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
294
295 /**
296 * Filter: woocommerce_query_for_reserved_stock
297 * Allows to filter the query for getting reserved stock of a product.
298 *
299 * @since 4.5.0
300 * @param string $query The query for getting reserved stock of a product.
301 * @param int $product_id Product ID.
302 * @param int $exclude_order_id Order to exclude from the results.
303 */
304 return apply_filters( 'woocommerce_query_for_reserved_stock', $query, $product_id, $exclude_order_id );
305 }
306 }
307