PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.16
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.16
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 1.9.14 1.9.13 All 162 releases
woocommerce-pos / includes / Services / Stock_Validator.php

Stock_Validator.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.16, at includes/Services/Stock_Validator.php

714 lines 23.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * POS stock validation.
4 *
5 * @package WCPOS\WooCommercePOS
6 */
7
8 namespace WCPOS\WooCommercePOS\Services;
9
10 use Automattic\WooCommerce\Utilities\OrderUtil;
11 use WC_Order;
12 use WC_Order_Item_Product;
13 use WC_Product;
14 use WP_Error;
15 use WP_REST_Request;
16
17 /**
18 * Rejects paid POS orders whose quantities exceed available stock.
19 */
20 class Stock_Validator {
21 /** POS order quantities are serialized to six decimal places. */
22 private const STOCK_PRECISION = 6;
23
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 /**
43 * Register the REST order validation hook.
44 */
45 private function __construct() {
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' ) );
48 }
49
50 /**
51 * Validate stock before a POS REST order is written.
52 *
53 * @param WC_Order|WP_Error $order Prepared order object.
54 * @param WP_REST_Request $request REST request.
55 * @param bool $creating Whether the order is being created.
56 *
57 * @return WC_Order|WP_Error
58 */
59 public function validate_stock( $order, WP_REST_Request $request, bool $creating ) {
60 if ( is_wp_error( $order ) || ! $order instanceof WC_Order ) {
61 return $order;
62 }
63
64 if ( ! \wcpos_request() || ! Settings::instance()->prevent_overselling_enabled() ) {
65 return $order;
66 }
67
68 if ( ! $this->should_validate_status( $order, $request, $creating ) ) {
69 return $order;
70 }
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
249 $failures = array();
250 $managed_stock = array();
251
252 $line_index = 0;
253 foreach ( $order->get_items( 'line_item' ) as $item ) {
254 if ( ! $item instanceof WC_Order_Item_Product ) {
255 ++$line_index;
256 continue;
257 }
258
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 ) {
263 ++$line_index;
264 continue;
265 }
266
267 $product = $item->get_product();
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 );
278 ++$line_index;
279 continue;
280 }
281
282 $line = $this->line_data( $item, $product, $this->stock_quantity( $quantity_units ) );
283 $stock_owner = $this->stock_owner( $product );
284
285 if ( $stock_owner instanceof WC_Product ) {
286 $owner_id = $stock_owner->get_id();
287 if ( ! isset( $managed_stock[ $owner_id ] ) ) {
288 $managed_stock[ $owner_id ] = array(
289 'owner' => $stock_owner,
290 'requested' => 0,
291 'lines' => array(),
292 );
293 }
294
295 $managed_stock[ $owner_id ]['requested'] += $quantity_units;
296 $managed_stock[ $owner_id ]['lines'][ $line_index ] = $line;
297 ++$line_index;
298 continue;
299 }
300
301 if ( 'outofstock' === $product->get_stock_status() && 'no' === $product->get_backorders() ) {
302 $failures[ $line_index ] = array_merge(
303 $line,
304 array(
305 'available' => null,
306 'reason' => 'out_of_stock_status',
307 'backorders' => $product->get_backorders(),
308 )
309 );
310 }
311
312 ++$line_index;
313 }
314
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();
329
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 }
361 }
362 } catch ( \Throwable $exception ) {
363 if ( $persisted ) {
364 $this->restore_reservations( $order, $prior_reservations );
365 }
366 throw $exception;
367 }
368
369 if ( $persisted && ! empty( $failures ) ) {
370 $this->restore_reservations( $order, $prior_reservations );
371 }
372
373 if ( empty( $failures ) ) {
374 if ( $persisted ) {
375 $this->prune_reservations( $order->get_id(), array_keys( $managed_stock ) );
376 }
377
378 return $order;
379 }
380 \ksort( $failures );
381 $failures = \array_values( $failures );
382
383 return new WP_Error(
384 'wcpos_insufficient_stock',
385 \sprintf(
386 /* translators: %d: Number of order line items without enough stock. */
387 __( 'Cannot complete order: %d item(s) exceed available stock.', 'woocommerce-pos' ),
388 \count( $failures )
389 ),
390 array(
391 'status' => 400,
392 'items' => $failures,
393 )
394 );
395 }
396
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 /**
626 * Whether the prepared order is leaving draft-land for a sale status.
627 *
628 * Draft/cart syncs must always succeed; validation fires only on the
629 * checkout transition. The gate is an exempt-list rather than
630 * wc_get_is_paid_statuses() because a gateway's configured order_status
631 * may be any status, including custom ones.
632 *
633 * @param WC_Order $order Prepared order object.
634 * @param WP_REST_Request $request REST request.
635 * @param bool $creating Whether the order is being created.
636 */
637 private function should_validate_status( WC_Order $order, WP_REST_Request $request, bool $creating ): bool {
638 $exempt_statuses = $this->exempt_statuses();
639 $target_status = $request->has_param( 'status' ) ? (string) $request->get_param( 'status' ) : $order->get_status();
640 $target_status = 0 === strpos( $target_status, 'wc-' ) ? substr( $target_status, 3 ) : $target_status;
641 $set_paid = $request->has_param( 'set_paid' ) && rest_sanitize_boolean( $request->get_param( 'set_paid' ) );
642 if ( ! $set_paid && \in_array( $target_status, $exempt_statuses, true ) ) {
643 return false;
644 }
645
646 if ( $creating || 0 === $order->get_id() ) {
647 return true;
648 }
649
650 $stored_order = wc_get_order( $order->get_id() );
651
652 return ! $stored_order instanceof WC_Order
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 );
673 }
674
675 /**
676 * Resolve the product whose quantity owns stock for a line.
677 *
678 * @param WC_Product $product Line item product or variation.
679 */
680 private function stock_owner( WC_Product $product ): ?WC_Product {
681 if ( $product->is_type( 'variation' ) ) {
682 if ( true === $product->get_manage_stock( 'edit' ) && $product->managing_stock() ) {
683 return $product;
684 }
685
686 $parent = wc_get_product( $product->get_parent_id() );
687
688 return $parent instanceof WC_Product && $parent->managing_stock() ? $parent : null;
689 }
690
691 return $product->managing_stock() ? $product : null;
692 }
693
694 /**
695 * Build the line-specific part of an error item.
696 *
697 * @param WC_Order_Item_Product $item Order line item.
698 * @param WC_Product $product Line item product or variation.
699 * @param float $quantity Requested quantity.
700 *
701 * @return array<string,int|float|string>
702 */
703 private function line_data( WC_Order_Item_Product $item, WC_Product $product, float $quantity ): array {
704 $name = $item->get_name();
705
706 return array(
707 'product_id' => (int) $item->get_product_id(),
708 'variation_id' => (int) $item->get_variation_id(),
709 'name' => $name ? $name : $product->get_name(),
710 'requested' => $quantity,
711 );
712 }
713 }
714