PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.20
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.20
1.10.20 1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 All 164 releases
← All changes | includes/Services/Stock_Validator.php +523 -28 1.10.2 → 1.10.20 View file →
@@ -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.