| @@ -6,8 +6,9 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | namespace WCPOS\WooCommercePOS\Services; |
| 9 | 9 | |
| 10 | +use Automattic\WooCommerce\Utilities\OrderUtil; | |
| 10 | 11 | use WC_Order; |
| 11 | 12 | use WC_Order_Item_Product; |
| 12 | 13 | use WC_Product; |
| 13 | 14 | use WP_Error; |
| @@ -16,13 +17,35 @@ | ||
| 16 | 17 | /** |
| 17 | 18 | * Rejects paid POS orders whose quantities exceed available stock. |
| 18 | 19 | */ |
| 19 | 20 | class Stock_Validator { |
| 21 | + /** POS order quantities are serialized to six decimal places. */ | |
| 22 | + private const STOCK_PRECISION = 6; | |
| 23 | + | |
| 20 | 24 | /** |
| 25 | + * Registered validator instance. | |
| 26 | + * | |
| 27 | + * @var self|null | |
| 28 | + */ | |
| 29 | + private static $instance; | |
| 30 | + | |
| 31 | + /** | |
| 32 | + * Get the registered validator. | |
| 33 | + */ | |
| 34 | + public static function instance(): self { | |
| 35 | + if ( null === self::$instance ) { | |
| 36 | + self::$instance = new self(); | |
| 37 | + } | |
| 38 | + | |
| 39 | + return self::$instance; | |
| 40 | + } | |
| 41 | + | |
| 42 | + /** | |
| 21 | 43 | * Register the REST order validation hook. |
| 22 | 44 | */ |
| 23 | - public function __construct() { | |
| 45 | + private function __construct() { | |
| 24 | 46 | add_filter( 'woocommerce_rest_pre_insert_shop_order_object', array( $this, 'validate_stock' ), 10, 3 ); |
| 47 | + add_filter( 'woocommerce_query_for_reserved_stock', array( $this, 'include_pos_draft_reservations' ) ); | |
| 25 | 48 | } |
| 26 | 49 | |
| 27 | 50 | /** |
| 28 | 51 | * Validate stock before a POS REST order is written. |
| @@ -45,8 +68,185 @@ | ||
| 45 | 68 | if ( ! $this->should_validate_status( $order, $request, $creating ) ) { |
| 46 | 69 | return $order; |
| 47 | 70 | } |
| 48 | 71 | |
| 72 | + return $this->validate_order( $order ); | |
| 73 | + } | |
| 74 | + | |
| 75 | + /** | |
| 76 | + * Validate stock for the direct POS checkout action. | |
| 77 | + * | |
| 78 | + * @param WC_Order $order Order being checked out. | |
| 79 | + * @return WC_Order|WP_Error | |
| 80 | + */ | |
| 81 | + public function validate_checkout( WC_Order $order ) { | |
| 82 | + if ( ! \wcpos_request() || ! Settings::instance()->prevent_overselling_enabled() ) { | |
| 83 | + return $order; | |
| 84 | + } | |
| 85 | + | |
| 86 | + $data_store = $order->get_data_store(); | |
| 87 | + if ( $order->is_paid() || ( method_exists( $data_store, 'get_stock_reduced' ) && $data_store->get_stock_reduced( $order->get_id() ) ) ) { | |
| 88 | + // payment_complete() reduced stock and released the hold; do not reserve sold lines again. | |
| 89 | + return $order; | |
| 90 | + } | |
| 91 | + | |
| 92 | + return $this->validate_order( $order ); | |
| 93 | + } | |
| 94 | + | |
| 95 | + /** | |
| 96 | + * Release reservations created for a POS checkout. | |
| 97 | + * | |
| 98 | + * @param WC_Order $order Order whose reservation should be released. | |
| 99 | + */ | |
| 100 | + public function release_checkout_stock( WC_Order $order ): void { | |
| 101 | + global $wpdb; | |
| 102 | + | |
| 103 | + if ( ! Settings::instance()->prevent_overselling_enabled() ) { | |
| 104 | + return; | |
| 105 | + } | |
| 106 | + | |
| 107 | + $wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 108 | + $wpdb->wc_reserved_stock, | |
| 109 | + array( 'order_id' => $order->get_id() ), | |
| 110 | + array( '%d' ) | |
| 111 | + ); | |
| 112 | + } | |
| 113 | + | |
| 114 | + /** | |
| 115 | + * Make POS draft reservations visible to WooCommerce checkout holds. | |
| 116 | + * | |
| 117 | + * @param string $query WooCommerce reserved-stock query. | |
| 118 | + */ | |
| 119 | + public function include_pos_draft_reservations( string $query ): string { | |
| 120 | + if ( ! Settings::instance()->prevent_overselling_enabled() ) { | |
| 121 | + return $query; | |
| 122 | + } | |
| 123 | + | |
| 124 | + return str_replace( | |
| 125 | + "IN ( 'wc-checkout-draft', 'wc-pending' )", | |
| 126 | + "IN ( 'wc-checkout-draft', 'wc-pending', 'wc-pos-open', 'wc-pos-partial' )", | |
| 127 | + $query | |
| 128 | + ); | |
| 129 | + } | |
| 130 | + | |
| 131 | + /** | |
| 132 | + * Run a paid order create as create-pending -> reserve -> complete. | |
| 133 | + * | |
| 134 | + * Reservations in `wc_reserved_stock` are keyed by order id, and the only | |
| 135 | + * hook that fires before an order is written — `pre_insert` — has no id yet, | |
| 136 | + * so a filter can compare availability but can never take a lock. Nor can a | |
| 137 | + * hook INSIDE WC_Order::save() reject: that method catches every exception | |
| 138 | + * and `handle_exception()` only writes it to the log, so a throw there is | |
| 139 | + * swallowed and the sale proceeds. The order therefore has to exist, in a | |
| 140 | + * status that reduces no stock, before it can be validated — which is | |
| 141 | + * exactly what WC_Checkout::process_checkout() does for the storefront | |
| 142 | + * (create pending, wc_reserve_stock_for_order(), then take payment). | |
| 143 | + * | |
| 144 | + * Both write lanes route through here so the sequence exists once: wcpos/v1 | |
| 145 | + * wraps parent::save_object(), and the v2 push surface wraps its wc/v3 | |
| 146 | + * forward. $write receives the neutralised intent and must return the | |
| 147 | + * created WC_Order (or a WP_Error). | |
| 148 | + * | |
| 149 | + * @param array $intent Requested status, set_paid flag and transaction id. | |
| 150 | + * @param callable $write Performs the create; receives the neutralised intent. | |
| 151 | + * @return WC_Order|WP_Error | |
| 152 | + * @throws \Throwable Re-thrown after the provisional order is removed. | |
| 153 | + */ | |
| 154 | + public function around_paid_create( array $intent, callable $write ) { | |
| 155 | + $order = $write( | |
| 156 | + array( | |
| 157 | + 'status' => 'pending', | |
| 158 | + 'set_paid' => false, | |
| 159 | + ) | |
| 160 | + ); | |
| 161 | + | |
| 162 | + if ( is_wp_error( $order ) || ! $order instanceof WC_Order ) { | |
| 163 | + return $order; | |
| 164 | + } | |
| 165 | + | |
| 166 | + try { | |
| 167 | + $validation = $this->validate_checkout( $order ); | |
| 168 | + } catch ( \Throwable $exception ) { | |
| 169 | + // The provisional order is ours; nothing has been paid or reduced at | |
| 170 | + // `pending`, so remove it rather than leaving a ghost behind. | |
| 171 | + $order->delete( true ); | |
| 172 | + | |
| 173 | + throw $exception; | |
| 174 | + } | |
| 175 | + | |
| 176 | + if ( is_wp_error( $validation ) ) { | |
| 177 | + $order->delete( true ); | |
| 178 | + | |
| 179 | + return $validation; | |
| 180 | + } | |
| 181 | + | |
| 182 | + // WooCommerce's own save_object() applies the requested status and THEN | |
| 183 | + // calls payment_complete(). Keeping that sequence is what makes a POS sale | |
| 184 | + // behave identically to the same payload through wp-admin or wc/v3, and | |
| 185 | + // stops the result depending on whether prevent-overselling is enabled. | |
| 186 | + try { | |
| 187 | + $target_status = isset( $intent['status'] ) ? (string) $intent['status'] : ''; | |
| 188 | + if ( '' !== $target_status ) { | |
| 189 | + $order->set_status( $target_status ); | |
| 190 | + $order->save(); | |
| 191 | + } | |
| 192 | + if ( ! empty( $intent['set_paid'] ) ) { | |
| 193 | + $order->payment_complete( isset( $intent['transaction_id'] ) ? (string) $intent['transaction_id'] : '' ); | |
| 194 | + } | |
| 195 | + } catch ( \Throwable $exception ) { | |
| 196 | + wc_maybe_increase_stock_levels( $order->get_id() ); | |
| 197 | + $this->release_checkout_stock( $order ); | |
| 198 | + $order->delete( true ); | |
| 199 | + | |
| 200 | + throw $exception; | |
| 201 | + } | |
| 202 | + | |
| 203 | + return wc_get_order( $order->get_id() ); | |
| 204 | + } | |
| 205 | + | |
| 206 | + /** | |
| 207 | + * Whether a new-order request is attempting checkout rather than draft sync. | |
| 208 | + * | |
| 209 | + * @param WP_REST_Request $request REST request. | |
| 210 | + */ | |
| 211 | + /** | |
| 212 | + * Payload-shaped twin of should_validate_create_request(). | |
| 213 | + * | |
| 214 | + * The v2 push lane holds a decoded payload rather than a WP_REST_Request, so | |
| 215 | + * both callers share this predicate instead of each deciding for itself what | |
| 216 | + * counts as a checkout rather than a draft sync. | |
| 217 | + * | |
| 218 | + * @param string $status Requested order status ('' when absent). | |
| 219 | + * @param bool $set_paid Whether the payload asks to mark the order paid. | |
| 220 | + */ | |
| 221 | + public function should_validate_create_payload( string $status, bool $set_paid ): bool { | |
| 222 | + $target_status = '' === $status ? 'pending' : $status; | |
| 223 | + $target_status = 0 === strpos( $target_status, 'wc-' ) ? substr( $target_status, 3 ) : $target_status; | |
| 224 | + | |
| 225 | + return $set_paid || ! \in_array( $target_status, $this->exempt_statuses(), true ); | |
| 226 | + } | |
| 227 | + | |
| 228 | + /** | |
| 229 | + * Whether a new-order request is attempting checkout rather than draft sync. | |
| 230 | + * | |
| 231 | + * @param WP_REST_Request $request REST request. | |
| 232 | + */ | |
| 233 | + public function should_validate_create_request( WP_REST_Request $request ): bool { | |
| 234 | + return $this->should_validate_create_payload( | |
| 235 | + $request->has_param( 'status' ) ? (string) $request->get_param( 'status' ) : '', | |
| 236 | + $request->has_param( 'set_paid' ) && rest_sanitize_boolean( $request->get_param( 'set_paid' ) ) | |
| 237 | + ); | |
| 238 | + } | |
| 239 | + | |
| 240 | + /** | |
| 241 | + * Validate and reserve stock for an order. | |
| 242 | + * | |
| 243 | + * @param WC_Order $order Order being checked out. | |
| 244 | + * @return WC_Order|WP_Error | |
| 245 | + * @throws \Throwable If the atomic reservation query cannot be completed. | |
| 246 | + */ | |
| 247 | + private function validate_order( WC_Order $order ) { | |
| 248 | + | |
| 49 | 249 | $failures = array(); |
| 50 | 250 | $managed_stock = array(); |
| 51 | 251 | |
| 52 | 252 | $line_index = 0; |
| @@ -55,11 +255,12 @@ | ||
| 55 | 255 | ++$line_index; |
| 56 | 256 | continue; |
| 57 | 257 | } |
| 58 | 258 | |
| 59 | - $product_id = (int) $item->get_product_id(); | |
| 60 | - $quantity = (float) $item->get_quantity(); | |
| 61 | - if ( 0 === $product_id || $quantity <= 0 ) { | |
| 259 | + $product_id = (int) $item->get_product_id(); | |
| 260 | + $quantity = (float) $item->get_quantity(); | |
| 261 | + $quantity_units = $this->stock_units( $quantity ); | |
| 262 | + if ( 0 === $product_id || $quantity_units <= 0 ) { | |
| 62 | 263 | ++$line_index; |
| 63 | 264 | continue; |
| 64 | 265 | } |
| 65 | 266 | |
| @@ -64,13 +265,22 @@ | ||
| 64 | 265 | } |
| 65 | 266 | |
| 66 | 267 | $product = $item->get_product(); |
| 67 | 268 | if ( ! $product instanceof WC_Product ) { |
| 269 | + $failures[ $line_index ] = array( | |
| 270 | + 'product_id' => $product_id, | |
| 271 | + 'variation_id' => (int) $item->get_variation_id(), | |
| 272 | + 'name' => $item->get_name(), | |
| 273 | + 'requested' => $this->stock_quantity( $quantity_units ), | |
| 274 | + 'available' => null, | |
| 275 | + 'reason' => 'product_not_found', | |
| 276 | + 'backorders' => 'no', | |
| 277 | + ); | |
| 68 | 278 | ++$line_index; |
| 69 | 279 | continue; |
| 70 | 280 | } |
| 71 | 281 | |
| 72 | - $line = $this->line_data( $item, $product, $quantity ); | |
| 282 | + $line = $this->line_data( $item, $product, $this->stock_quantity( $quantity_units ) ); | |
| 73 | 283 | $stock_owner = $this->stock_owner( $product ); |
| 74 | 284 | |
| 75 | 285 | if ( $stock_owner instanceof WC_Product ) { |
| 76 | 286 | $owner_id = $stock_owner->get_id(); |
| @@ -76,20 +286,20 @@ | ||
| 76 | 286 | $owner_id = $stock_owner->get_id(); |
| 77 | 287 | if ( ! isset( $managed_stock[ $owner_id ] ) ) { |
| 78 | 288 | $managed_stock[ $owner_id ] = array( |
| 79 | 289 | 'owner' => $stock_owner, |
| 80 | - 'requested' => 0.0, | |
| 290 | + 'requested' => 0, | |
| 81 | 291 | 'lines' => array(), |
| 82 | 292 | ); |
| 83 | 293 | } |
| 84 | 294 | |
| 85 | - $managed_stock[ $owner_id ]['requested'] += $quantity; | |
| 295 | + $managed_stock[ $owner_id ]['requested'] += $quantity_units; | |
| 86 | 296 | $managed_stock[ $owner_id ]['lines'][ $line_index ] = $line; |
| 87 | 297 | ++$line_index; |
| 88 | 298 | continue; |
| 89 | 299 | } |
| 90 | 300 | |
| 91 | - if ( 'outofstock' === $product->get_stock_status() ) { | |
| 301 | + if ( 'outofstock' === $product->get_stock_status() && 'no' === $product->get_backorders() ) { | |
| 92 | 302 | $failures[ $line_index ] = array_merge( |
| 93 | 303 | $line, |
| 94 | 304 | array( |
| 95 | 305 | 'available' => null, |
| @@ -101,30 +311,71 @@ | ||
| 101 | 311 | |
| 102 | 312 | ++$line_index; |
| 103 | 313 | } |
| 104 | 314 | |
| 105 | - foreach ( $managed_stock as $group ) { | |
| 106 | - $owner = $group['owner']; | |
| 107 | - $available = (float) $owner->get_stock_quantity(); | |
| 108 | - $backorders = $owner->get_backorders(); | |
| 315 | + \ksort( $managed_stock ); | |
| 316 | + $persisted = 0 < $order->get_id(); | |
| 317 | + $reserving = $persisted && ! empty( $managed_stock ); | |
| 318 | + // Each reserve_stock() call is atomic on its own — a single | |
| 319 | + // INSERT ... SELECT ... FOR UPDATE, the same shape WooCommerce's own | |
| 320 | + // ReserveStock uses. What still has to be all-or-nothing is the set of | |
| 321 | + // reservations across stock owners: reserving line A and then failing on | |
| 322 | + // line B must leave A unreserved. An explicit START TRANSACTION cannot do | |
| 323 | + // that job here, because MySQL does not nest transactions — it would | |
| 324 | + // implicitly COMMIT whatever transaction the caller already had open | |
| 325 | + // (including the one the WP test framework wraps every test in). So the | |
| 326 | + // group is undone by compensation instead: snapshot this order's rows | |
| 327 | + // first, then reacquire them on failure when stock is still available. | |
| 328 | + $prior_reservations = $persisted ? $this->existing_reservations( $order->get_id() ) : array(); | |
| 109 | 329 | |
| 110 | - if ( 'no' !== $backorders || $group['requested'] <= $available ) { | |
| 111 | - continue; | |
| 330 | + try { | |
| 331 | + foreach ( $managed_stock as $group ) { | |
| 332 | + $owner = $group['owner']; | |
| 333 | + $backorders = $owner->get_backorders(); | |
| 334 | + | |
| 335 | + if ( 'no' !== $backorders ) { | |
| 336 | + continue; | |
| 337 | + } | |
| 338 | + | |
| 339 | + if ( $reserving && $this->reserve_stock( $order, $owner, $group['requested'] ) ) { | |
| 340 | + continue; | |
| 341 | + } | |
| 342 | + if ( $reserving ) { | |
| 343 | + $available = $this->available_stock( $owner, $order->get_id() ); | |
| 344 | + } else { | |
| 345 | + $available = $this->available_stock( $owner, $order->get_id() ); | |
| 346 | + if ( $group['requested'] <= $this->stock_units( $available ) ) { | |
| 347 | + continue; | |
| 348 | + } | |
| 349 | + } | |
| 350 | + | |
| 351 | + foreach ( $group['lines'] as $index => $line ) { | |
| 352 | + $failures[ $index ] = array_merge( | |
| 353 | + $line, | |
| 354 | + array( | |
| 355 | + 'available' => $available, | |
| 356 | + 'reason' => 'insufficient_stock', | |
| 357 | + 'backorders' => $backorders, | |
| 358 | + ) | |
| 359 | + ); | |
| 360 | + } | |
| 112 | 361 | } |
| 362 | + } catch ( \Throwable $exception ) { | |
| 363 | + if ( $persisted ) { | |
| 364 | + $this->restore_reservations( $order, $prior_reservations ); | |
| 365 | + } | |
| 366 | + throw $exception; | |
| 367 | + } | |
| 113 | 368 | |
| 114 | - foreach ( $group['lines'] as $index => $line ) { | |
| 115 | - $failures[ $index ] = array_merge( | |
| 116 | - $line, | |
| 117 | - array( | |
| 118 | - 'available' => $available, | |
| 119 | - 'reason' => 'insufficient_stock', | |
| 120 | - 'backorders' => $backorders, | |
| 121 | - ) | |
| 122 | - ); | |
| 123 | - } | |
| 369 | + if ( $persisted && ! empty( $failures ) ) { | |
| 370 | + $this->restore_reservations( $order, $prior_reservations ); | |
| 124 | 371 | } |
| 125 | 372 | |
| 126 | 373 | if ( empty( $failures ) ) { |
| 374 | + if ( $persisted ) { | |
| 375 | + $this->prune_reservations( $order->get_id(), array_keys( $managed_stock ) ); | |
| 376 | + } | |
| 377 | + | |
| 127 | 378 | return $order; |
| 128 | 379 | } |
| 129 | 380 | \ksort( $failures ); |
| 130 | 381 | $failures = \array_values( $failures ); |
| @@ -143,8 +394,236 @@ | ||
| 143 | 394 | ); |
| 144 | 395 | } |
| 145 | 396 | |
| 146 | 397 | /** |
| 398 | + * Return sellable stock after active holds. | |
| 399 | + * | |
| 400 | + * @param WC_Product $owner Product that owns stock. | |
| 401 | + * @param int $order_id Current order ID. | |
| 402 | + */ | |
| 403 | + private function available_stock( WC_Product $owner, int $order_id ): float { | |
| 404 | + global $wpdb; | |
| 405 | + | |
| 406 | + /** | |
| 407 | + * Product stock data store. | |
| 408 | + * | |
| 409 | + * @var \WC_Product_Data_Store_CPT $data_store | |
| 410 | + */ | |
| 411 | + $data_store = \WC_Data_Store::load( 'product' ); | |
| 412 | + $stock_query = $data_store->get_query_for_stock( $owner->get_id() ); | |
| 413 | + $stock = (float) $wpdb->get_var( $stock_query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared | |
| 414 | + $available = $stock - (float) wc_get_held_stock_quantity( $owner, $order_id ); | |
| 415 | + | |
| 416 | + return $this->stock_quantity( $this->stock_units( $available ) ); | |
| 417 | + } | |
| 418 | + | |
| 419 | + /** | |
| 420 | + * Snapshot the reserved-stock rows this order already holds. | |
| 421 | + * | |
| 422 | + * @param int $order_id Order ID. | |
| 423 | + * @return array<int,array<string,mixed>> | |
| 424 | + */ | |
| 425 | + private function existing_reservations( int $order_id ): array { | |
| 426 | + global $wpdb; | |
| 427 | + | |
| 428 | + $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 429 | + $wpdb->prepare( | |
| 430 | + "SELECT product_id, stock_quantity, timestamp, expires FROM {$wpdb->wc_reserved_stock} WHERE order_id = %d AND expires > NOW()", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared | |
| 431 | + $order_id | |
| 432 | + ), | |
| 433 | + ARRAY_A | |
| 434 | + ); | |
| 435 | + | |
| 436 | + return \is_array( $rows ) ? $rows : array(); | |
| 437 | + } | |
| 438 | + | |
| 439 | + /** | |
| 440 | + * Drop this order's holds for stock owners it no longer contains. | |
| 441 | + * | |
| 442 | + * @param int $order_id Order ID. | |
| 443 | + * @param array<int> $keep_ids Stock-owner IDs still on the order. | |
| 444 | + */ | |
| 445 | + private function prune_reservations( int $order_id, array $keep_ids ): void { | |
| 446 | + global $wpdb; | |
| 447 | + | |
| 448 | + if ( empty( $keep_ids ) ) { | |
| 449 | + $wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 450 | + $wpdb->wc_reserved_stock, | |
| 451 | + array( 'order_id' => $order_id ), | |
| 452 | + array( '%d' ) | |
| 453 | + ); | |
| 454 | + | |
| 455 | + return; | |
| 456 | + } | |
| 457 | + | |
| 458 | + $placeholders = implode( ', ', array_fill( 0, \count( $keep_ids ), '%d' ) ); | |
| 459 | + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared | |
| 460 | + $wpdb->prepare( | |
| 461 | + "DELETE FROM {$wpdb->wc_reserved_stock} WHERE order_id = %d AND product_id NOT IN ( {$placeholders} )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared | |
| 462 | + array_merge( array( $order_id ), array_map( 'intval', $keep_ids ) ) | |
| 463 | + ) | |
| 464 | + ); | |
| 465 | + } | |
| 466 | + | |
| 467 | + /** | |
| 468 | + * Restore prior reservation quantities when stock is still available. | |
| 469 | + * | |
| 470 | + * @param WC_Order $order Order receiving the reservations. | |
| 471 | + * @param array<int,array<string,mixed>> $rows Snapshot from existing_reservations(). | |
| 472 | + * @throws \RuntimeException When a reservation cannot be restored. | |
| 473 | + */ | |
| 474 | + private function restore_reservations( WC_Order $order, array $rows ): void { | |
| 475 | + global $wpdb; | |
| 476 | + | |
| 477 | + foreach ( $rows as $row ) { | |
| 478 | + $owner = wc_get_product( (int) $row['product_id'] ); | |
| 479 | + if ( | |
| 480 | + ! $owner instanceof WC_Product | |
| 481 | + || ! $this->reserve_stock( $order, $owner, $this->stock_units( (float) $row['stock_quantity'] ) ) | |
| 482 | + ) { | |
| 483 | + throw new \RuntimeException( 'Unable to restore stock reservation.' ); | |
| 484 | + } | |
| 485 | + | |
| 486 | + $restored = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 487 | + $wpdb->wc_reserved_stock, | |
| 488 | + array( | |
| 489 | + 'stock_quantity' => $row['stock_quantity'], | |
| 490 | + 'timestamp' => $row['timestamp'], | |
| 491 | + 'expires' => $row['expires'], | |
| 492 | + ), | |
| 493 | + array( | |
| 494 | + 'order_id' => $order->get_id(), | |
| 495 | + 'product_id' => (int) $row['product_id'], | |
| 496 | + ), | |
| 497 | + array( '%s', '%s', '%s' ), | |
| 498 | + array( '%d', '%d' ) | |
| 499 | + ); | |
| 500 | + if ( false === $restored ) { | |
| 501 | + throw new \RuntimeException( 'Unable to restore stock reservation.' ); | |
| 502 | + } | |
| 503 | + } | |
| 504 | + | |
| 505 | + $this->prune_reservations( $order->get_id(), array_column( $rows, 'product_id' ) ); | |
| 506 | + } | |
| 507 | + | |
| 508 | + /** | |
| 509 | + * Atomically reserve a fixed-precision stock quantity. | |
| 510 | + * | |
| 511 | + * @param WC_Order $order Order receiving the reservation. | |
| 512 | + * @param WC_Product $owner Product that owns stock. | |
| 513 | + * @param int $requested_units Requested fixed-precision units. | |
| 514 | + */ | |
| 515 | + private function reserve_stock( WC_Order $order, WC_Product $owner, int $requested_units ): bool { | |
| 516 | + global $wpdb; | |
| 517 | + | |
| 518 | + $owner_id = $owner->get_id(); | |
| 519 | + /** | |
| 520 | + * Product stock data store. | |
| 521 | + * | |
| 522 | + * @var \WC_Product_Data_Store_CPT $data_store | |
| 523 | + */ | |
| 524 | + $data_store = \WC_Data_Store::load( 'product' ); | |
| 525 | + $stock_query = $data_store->get_query_for_stock( $owner_id ); | |
| 526 | + $reserved_query = $this->reserved_stock_query( $owner_id, $order->get_id() ); | |
| 527 | + $minutes = max( 1, (int) get_option( 'woocommerce_hold_stock_minutes', 60 ) ); | |
| 528 | + $precision = self::STOCK_PRECISION; | |
| 529 | + $scale = 10 ** $precision; | |
| 530 | + $quantity = wc_format_decimal( $this->stock_quantity( $requested_units ), $precision ); | |
| 531 | + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WooCommerce supplies the stock subquery; all values are prepared. | |
| 532 | + $sql = $wpdb->prepare( | |
| 533 | + " | |
| 534 | + INSERT INTO {$wpdb->wc_reserved_stock} ( order_id, product_id, stock_quantity, timestamp, expires ) | |
| 535 | + SELECT %d, %d, %s, NOW(), ( NOW() + INTERVAL %d MINUTE ) FROM DUAL | |
| 536 | + WHERE ROUND( ( ( $stock_query FOR UPDATE ) - ( $reserved_query LOCK IN SHARE MODE ) ) * $scale ) >= %d | |
| 537 | + ON DUPLICATE KEY UPDATE expires = VALUES( expires ), stock_quantity = VALUES( stock_quantity ) | |
| 538 | + ", | |
| 539 | + $order->get_id(), | |
| 540 | + $owner_id, | |
| 541 | + $quantity, | |
| 542 | + $minutes, | |
| 543 | + $requested_units | |
| 544 | + ); | |
| 545 | + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared | |
| 546 | + | |
| 547 | + $result = $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared | |
| 548 | + | |
| 549 | + return false !== $result && ( 0 < $result || $this->has_reservation( $order->get_id(), $owner_id, $requested_units ) ); | |
| 550 | + } | |
| 551 | + | |
| 552 | + /** | |
| 553 | + * Build the status-scoped held-stock query used by the atomic reservation. | |
| 554 | + * | |
| 555 | + * @param int $owner_id Stock owner ID. | |
| 556 | + * @param int $exclude_order_id Current order ID. | |
| 557 | + */ | |
| 558 | + private function reserved_stock_query( int $owner_id, int $exclude_order_id ): string { | |
| 559 | + global $wpdb; | |
| 560 | + | |
| 561 | + if ( class_exists( OrderUtil::class ) && OrderUtil::custom_orders_table_usage_is_enabled() ) { | |
| 562 | + $join = "{$wpdb->prefix}wc_orders orders ON stock_table.order_id = orders.id"; | |
| 563 | + $where_status = "orders.status IN ( 'wc-checkout-draft', 'wc-pending' )"; | |
| 564 | + } else { | |
| 565 | + $join = "{$wpdb->posts} posts ON stock_table.order_id = posts.ID"; | |
| 566 | + $where_status = "posts.post_status IN ( 'wc-checkout-draft', 'wc-pending' )"; | |
| 567 | + } | |
| 568 | + | |
| 569 | + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names and status clause are selected above; values are prepared. | |
| 570 | + $query = $wpdb->prepare( | |
| 571 | + "SELECT COALESCE( SUM( stock_table.stock_quantity ), 0 ) FROM {$wpdb->wc_reserved_stock} stock_table LEFT JOIN $join WHERE $where_status AND stock_table.expires > NOW() AND stock_table.product_id = %d AND stock_table.order_id != %d", | |
| 572 | + $owner_id, | |
| 573 | + $exclude_order_id | |
| 574 | + ); | |
| 575 | + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared | |
| 576 | + | |
| 577 | + return apply_filters( | |
| 578 | + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- This is a WooCommerce core hook. | |
| 579 | + 'woocommerce_query_for_reserved_stock', | |
| 580 | + $query, | |
| 581 | + $owner_id, | |
| 582 | + $exclude_order_id | |
| 583 | + ); | |
| 584 | + } | |
| 585 | + | |
| 586 | + /** | |
| 587 | + * Check for an idempotent reservation update. | |
| 588 | + * | |
| 589 | + * @param int $order_id Order ID. | |
| 590 | + * @param int $owner_id Stock owner ID. | |
| 591 | + * @param int $requested_units Requested fixed-precision units. | |
| 592 | + */ | |
| 593 | + private function has_reservation( int $order_id, int $owner_id, int $requested_units ): bool { | |
| 594 | + global $wpdb; | |
| 595 | + | |
| 596 | + $held = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 597 | + $wpdb->prepare( | |
| 598 | + "SELECT stock_quantity FROM {$wpdb->wc_reserved_stock} WHERE order_id = %d AND product_id = %d AND expires > NOW()", | |
| 599 | + $order_id, | |
| 600 | + $owner_id | |
| 601 | + ) | |
| 602 | + ); | |
| 603 | + | |
| 604 | + return null !== $held && $this->stock_units( (float) $held ) === $requested_units; | |
| 605 | + } | |
| 606 | + | |
| 607 | + /** | |
| 608 | + * Convert a stock amount to fixed-precision units. | |
| 609 | + * | |
| 610 | + * @param float $quantity Stock quantity. | |
| 611 | + */ | |
| 612 | + private function stock_units( float $quantity ): int { | |
| 613 | + return (int) \round( $quantity * ( 10 ** self::STOCK_PRECISION ) ); | |
| 614 | + } | |
| 615 | + | |
| 616 | + /** | |
| 617 | + * Convert fixed-precision units to a stock amount. | |
| 618 | + * | |
| 619 | + * @param int $units Fixed-precision stock units. | |
| 620 | + */ | |
| 621 | + private function stock_quantity( int $units ): float { | |
| 622 | + return $units / ( 10 ** self::STOCK_PRECISION ); | |
| 623 | + } | |
| 624 | + | |
| 625 | + /** | |
| 147 | 626 | * Whether the prepared order is leaving draft-land for a sale status. |
| 148 | 627 | * |
| 149 | 628 | * Draft/cart syncs must always succeed; validation fires only on the |
| 150 | 629 | * checkout transition. The gate is an exempt-list rather than |
| @@ -155,12 +634,9 @@ | ||
| 155 | 634 | * @param WP_REST_Request $request REST request. |
| 156 | 635 | * @param bool $creating Whether the order is being created. |
| 157 | 636 | */ |
| 158 | 637 | private function should_validate_status( WC_Order $order, WP_REST_Request $request, bool $creating ): bool { |
| 159 | - $exempt_statuses = apply_filters( | |
| 160 | - 'woocommerce_pos_stock_validation_exempt_statuses', | |
| 161 | - array( 'pos-open', 'pos-partial', 'pending', 'auto-draft', 'checkout-draft', 'draft', 'cancelled', 'refunded', 'failed', 'trash' ) | |
| 162 | - ); | |
| 638 | + $exempt_statuses = $this->exempt_statuses(); | |
| 163 | 639 | $target_status = $request->has_param( 'status' ) ? (string) $request->get_param( 'status' ) : $order->get_status(); |
| 164 | 640 | $target_status = 0 === strpos( $target_status, 'wc-' ) ? substr( $target_status, 3 ) : $target_status; |
| 165 | 641 | $set_paid = $request->has_param( 'set_paid' ) && rest_sanitize_boolean( $request->get_param( 'set_paid' ) ); |
| 166 | 642 | if ( ! $set_paid && \in_array( $target_status, $exempt_statuses, true ) ) { |
| @@ -174,8 +650,27 @@ | ||
| 174 | 650 | $stored_order = wc_get_order( $order->get_id() ); |
| 175 | 651 | |
| 176 | 652 | return ! $stored_order instanceof WC_Order |
| 177 | 653 | || \in_array( $stored_order->get_status(), $exempt_statuses, true ); |
| 654 | + } | |
| 655 | + | |
| 656 | + /** | |
| 657 | + * Draft and terminal statuses exempt from checkout validation. | |
| 658 | + * | |
| 659 | + * @return string[] | |
| 660 | + */ | |
| 661 | + private function exempt_statuses(): array { | |
| 662 | + $statuses = apply_filters( | |
| 663 | + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Backward-compatible public filter. | |
| 664 | + 'woocommerce_pos_stock_validation_exempt_statuses', | |
| 665 | + array( 'pos-open', 'pos-partial', 'pending', 'auto-draft', 'checkout-draft', 'draft', 'cancelled', 'refunded', 'failed', 'trash' ) | |
| 666 | + ); | |
| 667 | + | |
| 668 | + return apply_filters( | |
| 669 | + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Intentional public WCPOS hook. | |
| 670 | + 'wcpos_stock_validation_exempt_statuses', | |
| 671 | + $statuses | |
| 672 | + ); | |
| 178 | 673 | } |
| 179 | 674 | |
| 180 | 675 | /** |
| 181 | 676 | * Resolve the product whose quantity owns stock for a line. |