PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.2.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.2.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / classes / stock-manager.php

stock-manager.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.2.0, at includes/classes/stock-manager.php

632 lines 21.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace StoreEngine\Classes;
4
5 use StoreEngine\Classes\OrderStatus\OrderStatus;
6 use StoreEngine\Utils\Helper;
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 class StockManager {
13
14 public const TYPE_SALE = 'sale';
15 public const TYPE_RESTOCK = 'restock';
16 public const TYPE_MANUAL = 'manual';
17 public const TYPE_RETURN = 'return';
18 public const TYPE_DAMAGED = 'damaged';
19 public const TYPE_RECEIVED = 'received';
20 public const TYPE_CORRECTION = 'correction';
21
22 public static function init() {
23 add_action( 'storeengine/payment_complete', [ __CLASS__, 'on_payment_complete' ], 20 );
24 add_action( 'storeengine/order/status_changed', [ __CLASS__, 'on_status_changed' ], 20, 4 );
25 add_action( 'storeengine/stock/release_expired_reservations', [ __CLASS__, 'release_expired_reservations' ] );
26 add_action( 'storeengine/stock/low_stock_digest', [ __CLASS__, 'send_low_stock_digest' ] );
27
28 if ( function_exists( 'as_next_scheduled_action' ) ) {
29 add_action( 'init', [ __CLASS__, 'schedule_sweeper' ] );
30 }
31 }
32
33 public static function schedule_sweeper() {
34 if ( ! function_exists( 'as_next_scheduled_action' ) || ! function_exists( 'as_schedule_recurring_action' ) ) {
35 return;
36 }
37
38 if ( false === as_next_scheduled_action( 'storeengine/stock/release_expired_reservations' ) ) {
39 as_schedule_recurring_action( time() + 60, 15 * MINUTE_IN_SECONDS, 'storeengine/stock/release_expired_reservations' );
40 }
41
42 if ( false === as_next_scheduled_action( 'storeengine/stock/low_stock_digest' ) ) {
43 as_schedule_recurring_action( strtotime( 'tomorrow 09:00' ), DAY_IN_SECONDS, 'storeengine/stock/low_stock_digest' );
44 }
45 }
46
47 public static function send_low_stock_digest(): void {
48 $recipient = get_option( 'storeengine_low_stock_recipient' );
49 if ( ! $recipient ) {
50 $recipient = get_option( 'admin_email' );
51 }
52
53 if ( ! $recipient || ! is_email( $recipient ) ) {
54 return;
55 }
56
57 global $wpdb;
58
59 $threshold = (int) get_option( 'storeengine_low_stock_threshold', 0 );
60
61 $rows = [];
62
63 // Variation-level low stock.
64 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
65 $variation_rows = $wpdb->get_results( $wpdb->prepare(
66 "SELECT v.id, v.sku, v.product_id, v.stock_quantity, v.low_stock_threshold
67 FROM {$wpdb->prefix}storeengine_product_variations v
68 WHERE v.manage_stock = 1
69 AND v.stock_quantity IS NOT NULL
70 AND v.stock_quantity > 0
71 AND v.stock_quantity <= COALESCE(v.low_stock_threshold, %d)",
72 $threshold
73 ) );
74 // phpcs:enable
75
76 foreach ( $variation_rows as $row ) {
77 $rows[] = sprintf(
78 '#%d %s — variation %d (qty %d)',
79 (int) $row->product_id,
80 get_the_title( (int) $row->product_id ),
81 (int) $row->id,
82 (int) $row->stock_quantity
83 );
84 }
85
86 // Simple-product low stock via post meta. Cap to a reasonable result count.
87 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
88 $product_rows = $wpdb->get_results( $wpdb->prepare(
89 "SELECT p.ID, qty.meta_value AS stock_quantity, t.meta_value AS threshold
90 FROM {$wpdb->posts} p
91 INNER JOIN {$wpdb->postmeta} ms ON ms.post_id = p.ID AND ms.meta_key = '_storeengine_manage_stock' AND ms.meta_value = '1'
92 INNER JOIN {$wpdb->postmeta} qty ON qty.post_id = p.ID AND qty.meta_key = '_storeengine_stock_quantity'
93 LEFT JOIN {$wpdb->postmeta} t ON t.post_id = p.ID AND t.meta_key = '_storeengine_low_stock_threshold'
94 WHERE p.post_type = 'storeengine_product' AND p.post_status = 'publish'
95 AND CAST(qty.meta_value AS SIGNED) > 0
96 AND CAST(qty.meta_value AS SIGNED) <= COALESCE(NULLIF(CAST(t.meta_value AS SIGNED), 0), %d)
97 LIMIT 200",
98 $threshold
99 ) );
100 // phpcs:enable
101
102 foreach ( $product_rows as $row ) {
103 $rows[] = sprintf(
104 '#%d %s (qty %d)',
105 (int) $row->ID,
106 get_the_title( (int) $row->ID ),
107 (int) $row->stock_quantity
108 );
109 }
110
111 if ( empty( $rows ) ) {
112 return;
113 }
114
115 $body = __( 'The following products are running low on stock:', 'storeengine' ) . "\n\n";
116 $body .= implode( "\n", $rows );
117
118 wp_mail(
119 $recipient,
120 __( '[StoreEngine] Daily low-stock digest', 'storeengine' ),
121 $body
122 );
123 }
124
125 public static function on_payment_complete( int $order_id ) {
126 self::maybe_reduce_stock_levels( $order_id );
127 self::release_reserved_stock( $order_id );
128 }
129
130 public static function on_status_changed( $order_id, $status_from, $status_to, $order = null ) {
131 $restore_states = [ 'cancelled', 'refunded', 'failed' ];
132 $reduce_states = array_merge(
133 OrderStatus::get_is_paid_statuses(),
134 [ OrderStatus::PAYMENT_CONFIRMED, 'shipping' ]
135 );
136
137 if ( in_array( $status_to, $restore_states, true ) ) {
138 self::maybe_restore_stock_levels( $order_id );
139 self::release_reserved_stock( $order_id );
140 } elseif ( in_array( $status_to, $reduce_states, true ) ) {
141 self::maybe_reduce_stock_levels( $order_id );
142 self::release_reserved_stock( $order_id );
143 }
144 }
145
146 public static function maybe_reduce_stock_levels( int $order_id ): void {
147 global $wpdb;
148
149 $order = Helper::get_order( $order_id );
150
151 if ( ! $order || is_wp_error( $order ) ) {
152 return;
153 }
154
155 // Atomically claim the "reduce stock" step at the DB row level instead
156 // of trusting a possibly cached/stale in-memory order object. Multiple
157 // triggers can race close together — `storeengine/payment_complete` and
158 // a `storeengine/order/status_changed` transition (e.g. processing then
159 // later completed) both call into here — and a plain
160 // read-flag-then-reduce-then-write-flag check has a gap where a second
161 // caller can read "not reduced yet" before the first caller's save()
162 // commits, causing stock to be deducted twice for one order. This
163 // UPDATE only succeeds for whichever caller gets there first.
164 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
165 $claimed = $wpdb->query( $wpdb->prepare(
166 "UPDATE {$wpdb->prefix}storeengine_order_operational_data
167 SET order_stock_reduced = 1
168 WHERE order_id = %d AND ( order_stock_reduced IS NULL OR order_stock_reduced = 0 )",
169 $order_id
170 ) );
171
172 if ( ! $claimed ) {
173 return;
174 }
175
176 // Keep the in-memory (possibly cached) object in sync with the DB row
177 // we just claimed, without a redundant save() — the flag is already
178 // persisted above.
179 $order->set_order_stock_reduced( true );
180
181 foreach ( $order->get_items( 'line_item' ) as $item ) {
182 $product_id = method_exists( $item, 'get_product_id' ) ? (int) $item->get_product_id() : 0;
183 $variation_id = method_exists( $item, 'get_variation_id' ) ? (int) $item->get_variation_id() : 0;
184 $qty = method_exists( $item, 'get_quantity' ) ? (int) $item->get_quantity() : 0;
185
186 if ( ! $product_id || $qty <= 0 ) {
187 continue;
188 }
189
190 $target = null;
191
192 if ( $variation_id ) {
193 $variation = Helper::get_product_variation( $variation_id );
194
195 if ( $variation && method_exists( $variation, 'manages_stock' ) && $variation->manages_stock() ) {
196 $target = $variation;
197 }
198 }
199
200 if ( ! $target ) {
201 $product = Helper::get_product( $product_id );
202
203 if ( $product && $product->manages_stock() ) {
204 $target = $product;
205 }
206 }
207
208 if ( ! $target ) {
209 continue;
210 }
211
212 $qty_before = (int) $target->get_stock_quantity();
213 $target instanceof \StoreEngine\Classes\Variation
214 ? $target->reduce_stock( $qty )
215 : $target->reduce_stock( $qty, $variation_id ?: null );
216 $qty_after = (int) $target->get_stock_quantity();
217
218 self::record_movement( [
219 'product_id' => $product_id,
220 'variation_id' => $variation_id,
221 'type' => self::TYPE_SALE,
222 'reason' => 'order',
223 'qty_change' => -$qty,
224 'qty_before' => $qty_before,
225 'qty_after' => $qty_after,
226 'note' => sprintf( 'Order #%d', $order_id ),
227 'order_id' => $order_id,
228 ] );
229
230 /**
231 * Fires after free-core stock-quantity has been reduced for an order
232 * line. Multi-location addons hook this to mirror the deduction at
233 * the default location.
234 *
235 * @param int $product_id
236 * @param int $variation_id
237 * @param int $qty
238 * @param int $order_id
239 */
240 do_action( 'storeengine/order/stock_reduced', $product_id, $variation_id, $qty, $order_id );
241 }
242 }
243
244 public static function maybe_restore_stock_levels( int $order_id ): void {
245 global $wpdb;
246
247 $order = Helper::get_order( $order_id );
248
249 if ( ! $order || is_wp_error( $order ) ) {
250 return;
251 }
252
253 // Same atomic-claim pattern as maybe_reduce_stock_levels(): only flip
254 // the flag back to "not reduced" for whichever caller gets there first,
255 // so a cancellation and a refund landing close together can't both
256 // pass the check and restock the same order twice.
257 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
258 $claimed = $wpdb->query( $wpdb->prepare(
259 "UPDATE {$wpdb->prefix}storeengine_order_operational_data
260 SET order_stock_reduced = 0
261 WHERE order_id = %d AND order_stock_reduced = 1",
262 $order_id
263 ) );
264
265 if ( ! $claimed ) {
266 return;
267 }
268
269 $order->set_order_stock_reduced( false );
270
271 foreach ( $order->get_items( 'line_item' ) as $item ) {
272 $product_id = method_exists( $item, 'get_product_id' ) ? (int) $item->get_product_id() : 0;
273 $variation_id = method_exists( $item, 'get_variation_id' ) ? (int) $item->get_variation_id() : 0;
274 $qty = method_exists( $item, 'get_quantity' ) ? (int) $item->get_quantity() : 0;
275
276 if ( ! $product_id || $qty <= 0 ) {
277 continue;
278 }
279
280 $target = null;
281
282 if ( $variation_id ) {
283 $variation = Helper::get_product_variation( $variation_id );
284
285 if ( $variation && method_exists( $variation, 'manages_stock' ) && $variation->manages_stock() ) {
286 $target = $variation;
287 }
288 }
289
290 if ( ! $target ) {
291 $product = Helper::get_product( $product_id );
292
293 if ( $product && $product->manages_stock() ) {
294 $target = $product;
295 }
296 }
297
298 if ( ! $target ) {
299 continue;
300 }
301
302 $qty_before = (int) $target->get_stock_quantity();
303 $target instanceof \StoreEngine\Classes\Variation
304 ? $target->increase_stock( $qty )
305 : $target->increase_stock( $qty, $variation_id ?: null );
306 $qty_after = (int) $target->get_stock_quantity();
307
308 self::record_movement( [
309 'product_id' => $product_id,
310 'variation_id' => $variation_id,
311 'type' => self::TYPE_RESTOCK,
312 'reason' => 'order_cancelled',
313 'qty_change' => $qty,
314 'qty_before' => $qty_before,
315 'qty_after' => $qty_after,
316 'note' => sprintf( 'Restored from order #%d', $order_id ),
317 'order_id' => $order_id,
318 ] );
319
320 /**
321 * Fires after free-core stock-quantity has been restored from a
322 * cancelled/refunded order. Multi-location addons mirror the
323 * restock at the default location.
324 *
325 * @param int $product_id
326 * @param int $variation_id
327 * @param int $qty
328 * @param int $order_id
329 */
330 do_action( 'storeengine/order/stock_restored', $product_id, $variation_id, $qty, $order_id );
331 }
332 }
333
334 /**
335 * Apply a manual stock adjustment + log a movement.
336 *
337 * @param int $product_id
338 * @param int $variation_id 0 if simple product
339 * @param string $action add | remove | set
340 * @param int $qty positive integer
341 * @param string $reason received | damaged | return | correction | manual | other
342 * @param string $note optional free-text note
343 *
344 * @return array{ok:bool, qty_before:?int, qty_after:?int, message?:string}
345 */
346 public static function adjust_stock( int $product_id, int $variation_id, string $action, int $qty, string $reason = 'manual', string $note = '' ): array {
347 global $wpdb;
348
349 $action = in_array( $action, [ 'add', 'remove', 'set' ], true ) ? $action : 'set';
350 $qty = max( 0, $qty );
351
352 if ( $variation_id ) {
353 $table = $wpdb->prefix . 'storeengine_product_variations';
354 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct read on a custom StoreEngine variations table; prepared with %i/%d placeholders.
355 $row = $wpdb->get_row( $wpdb->prepare( 'SELECT id, manage_stock, stock_quantity, backorders FROM %i WHERE id = %d', $table, $variation_id ) );
356
357 if ( ! $row ) {
358 return [ 'ok' => false, 'qty_before' => null, 'qty_after' => null, 'message' => 'variation_not_found' ];
359 }
360
361 $qty_before = null === $row->stock_quantity ? 0 : (int) $row->stock_quantity;
362 $qty_after = self::compute_new_qty( $action, $qty, $qty_before );
363 $qty_change = $qty_after - $qty_before;
364 $backorders = $row->backorders ?: 'no';
365 $new_status = self::derive_stock_status( $qty_after, $backorders );
366
367 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
368 $wpdb->update(
369 $table,
370 [
371 'manage_stock' => 1,
372 'stock_quantity' => $qty_after,
373 'stock_status' => $new_status,
374 ],
375 [ 'id' => $variation_id ],
376 [ '%d', '%d', '%s' ],
377 [ '%d' ]
378 );
379 // phpcs:enable
380 wp_cache_delete( \StoreEngine\Classes\Variation::CACHE_KEY . $variation_id, \StoreEngine\Classes\Variation::CACHE_GROUP );
381 wp_cache_flush_group( \StoreEngine\Classes\AbstractProduct::CACHE_GROUP );
382
383 $old_status = $row->stock_quantity > 0 ? 'instock' : ( in_array( $backorders, [ 'yes', 'notify' ], true ) ? 'onbackorder' : 'outofstock' );
384 if ( $old_status !== $new_status ) {
385 do_action( 'storeengine/stock_status_changed', $product_id, $variation_id, $old_status, $new_status );
386 }
387 } else {
388 // Simple product (or variable-product fallback) — operate on post meta.
389 if ( ! $product_id ) {
390 return [ 'ok' => false, 'qty_before' => null, 'qty_after' => null, 'message' => 'product_not_found' ];
391 }
392
393 $existing_qty = get_post_meta( $product_id, '_storeengine_stock_quantity', true );
394 $qty_before = ( '' === $existing_qty || null === $existing_qty ) ? 0 : (int) $existing_qty;
395 $qty_after = self::compute_new_qty( $action, $qty, $qty_before );
396 $qty_change = $qty_after - $qty_before;
397 $backorders = get_post_meta( $product_id, '_storeengine_backorders', true ) ?: 'no';
398 $new_status = self::derive_stock_status( $qty_after, $backorders );
399 $old_status_raw = get_post_meta( $product_id, '_storeengine_stock_status', true );
400
401 update_post_meta( $product_id, '_storeengine_manage_stock', true );
402 update_post_meta( $product_id, '_storeengine_stock_quantity', $qty_after );
403 update_post_meta( $product_id, '_storeengine_stock_status', $new_status );
404
405 wp_cache_flush_group( \StoreEngine\Classes\AbstractProduct::CACHE_GROUP );
406
407 if ( $old_status_raw && $old_status_raw !== $new_status ) {
408 do_action( 'storeengine/stock_status_changed', $product_id, 0, $old_status_raw, $new_status );
409 }
410 }
411
412 $movement_type = self::TYPE_MANUAL;
413 if ( in_array( $reason, [ 'received', 'damaged', 'return', 'correction' ], true ) ) {
414 $movement_type = $reason;
415 }
416
417 self::record_movement( [
418 'product_id' => $product_id,
419 'variation_id' => $variation_id,
420 'type' => $movement_type,
421 'reason' => $reason,
422 'qty_change' => $qty_change,
423 'qty_before' => $qty_before,
424 'qty_after' => $qty_after,
425 'note' => $note,
426 'user_id' => get_current_user_id() ?: null,
427 ] );
428
429 return [
430 'ok' => true,
431 'qty_before' => $qty_before,
432 'qty_after' => $qty_after,
433 'qty_change' => $qty_change,
434 'product_id' => $product_id,
435 'variation_id' => $variation_id,
436 ];
437 }
438
439 protected static function compute_new_qty( string $action, int $qty, int $current ): int {
440 if ( 'add' === $action ) {
441 return $current + $qty;
442 }
443 if ( 'remove' === $action ) {
444 return $current - $qty;
445 }
446 return $qty; // set
447 }
448
449 protected static function derive_stock_status( int $qty, string $backorders ): string {
450 $allows_backorder = in_array( $backorders, [ 'yes', 'notify' ], true );
451
452 if ( $qty <= 0 && ! $allows_backorder ) {
453 return 'outofstock';
454 }
455 if ( $qty <= 0 && $allows_backorder ) {
456 return 'onbackorder';
457 }
458 return 'instock';
459 }
460
461 public static function record_movement( array $args ): int {
462 global $wpdb;
463
464 $defaults = [
465 'product_id' => 0,
466 'variation_id' => 0,
467 'type' => self::TYPE_MANUAL,
468 'reason' => null,
469 'qty_change' => 0,
470 'qty_before' => null,
471 'qty_after' => null,
472 'note' => null,
473 'user_id' => null,
474 'order_id' => null,
475 'vendor_id' => null,
476 ];
477
478 $args = array_merge( $defaults, $args );
479 $args['created_at'] = current_time( 'mysql' );
480
481 // Backfill vendor_id from product post_author so the stock log carries
482 // vendor identity at write-time. Avoids a brittle reverse-join later.
483 if ( empty( $args['vendor_id'] ) && ! empty( $args['product_id'] ) ) {
484 $author = (int) get_post_field( 'post_author', (int) $args['product_id'] );
485 if ( $author > 0 ) {
486 $args['vendor_id'] = $author;
487 }
488 }
489
490 /**
491 * Filter movement-row args before insert. Multi-location addons hook
492 * this to inject `location_id` so the column (added by inventory-pro's
493 * installer) gets populated.
494 *
495 * @param array $args
496 */
497 $args = apply_filters( 'storeengine/stock/record_movement_args', $args );
498
499 $table = $wpdb->prefix . 'storeengine_stock_movements';
500
501 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
502 $wpdb->insert( $table, $args );
503 // phpcs:enable
504
505 return (int) $wpdb->insert_id;
506 }
507
508 public static function get_movements( int $product_id, int $variation_id = 0, int $limit = 50, int $offset = 0 ): array {
509 return self::query_movements( [
510 'product_id' => $product_id,
511 'variation_id' => $variation_id,
512 'limit' => $limit,
513 'offset' => $offset,
514 ] );
515 }
516
517 /**
518 * Filterable movement query used by the inventory-pro addon.
519 *
520 * @param array{product_id?:int,variation_id?:int,location_id?:int,type?:string,from?:string,to?:string,limit?:int,offset?:int} $args
521 * @return array<int,object>
522 */
523 public static function query_movements( array $args ): array {
524 global $wpdb;
525
526 $table = $wpdb->prefix . 'storeengine_stock_movements';
527
528 $where = [];
529 $values = [];
530
531 if ( ! empty( $args['product_id'] ) ) {
532 $where[] = 'product_id = %d';
533 $values[] = (int) $args['product_id'];
534 }
535
536 if ( ! empty( $args['variation_id'] ) ) {
537 $where[] = 'variation_id = %d';
538 $values[] = (int) $args['variation_id'];
539 }
540
541 if ( ! empty( $args['location_id'] ) ) {
542 $where[] = 'location_id = %d';
543 $values[] = (int) $args['location_id'];
544 }
545
546 if ( ! empty( $args['vendor_id'] ) ) {
547 $where[] = 'vendor_id = %d';
548 $values[] = (int) $args['vendor_id'];
549 }
550
551 if ( ! empty( $args['type'] ) ) {
552 $where[] = 'type = %s';
553 $values[] = (string) $args['type'];
554 }
555
556 if ( ! empty( $args['from'] ) ) {
557 $where[] = 'created_at >= %s';
558 $values[] = (string) $args['from'];
559 }
560
561 if ( ! empty( $args['to'] ) ) {
562 $where[] = 'created_at <= %s';
563 $values[] = (string) $args['to'];
564 }
565
566 $where_sql = $where ? ( ' WHERE ' . implode( ' AND ', $where ) ) : '';
567 $limit = max( 1, (int) ( $args['limit'] ?? 50 ) );
568 $offset = max( 0, (int) ( $args['offset'] ?? 0 ) );
569
570 // Table via %i, dynamic WHERE built from literal fragments + %d/%s value
571 // placeholders (see $where above); the LIMIT/OFFSET are appended as %d.
572 $sql = 'SELECT * FROM %i' . $where_sql . ' ORDER BY id DESC LIMIT %d OFFSET %d';
573
574 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Direct query on a custom StoreEngine table; $sql is literal-only with %i/%d/%s placeholders passed to prepare().
575 $rows = $wpdb->get_results( $wpdb->prepare( $sql, ...array_merge( [ $table ], $values, [ $limit, $offset ] ) ) );
576
577 return is_array( $rows ) ? $rows : [];
578 }
579
580 public static function reserve_stock_for_order( int $order_id, array $items, int $minutes = 60 ): void {
581 global $wpdb;
582
583 $table = $wpdb->prefix . 'storeengine_reserved_stock';
584 $expires = gmdate( 'Y-m-d H:i:s', time() + ( $minutes * MINUTE_IN_SECONDS ) );
585
586 // Clear any prior reservation rows for this order to avoid stale entries.
587 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Row delete on a custom StoreEngine reservations table; no cache layer applies.
588 $wpdb->delete( $table, [ 'order_id' => $order_id ], [ '%d' ] );
589
590 foreach ( $items as $item ) {
591 $product_id = (int) ( $item['product_id'] ?? 0 );
592 $variation_id = (int) ( $item['variation_id'] ?? 0 );
593 $qty = (int) ( $item['quantity'] ?? 0 );
594
595 if ( ! $product_id || $qty <= 0 ) {
596 continue;
597 }
598
599 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Row upsert on a custom StoreEngine reservations table; no cache layer applies.
600 $wpdb->replace(
601 $table,
602 [
603 'order_id' => $order_id,
604 'product_id' => $product_id,
605 'variation_id' => $variation_id,
606 'stock_quantity' => $qty,
607 'expires' => $expires,
608 ],
609 [ '%d', '%d', '%d', '%d', '%s' ]
610 );
611 }
612 }
613
614 public static function release_reserved_stock( int $order_id ): void {
615 global $wpdb;
616
617 $table = $wpdb->prefix . 'storeengine_reserved_stock';
618
619 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Row delete on a custom StoreEngine reservations table; no cache layer applies.
620 $wpdb->delete( $table, [ 'order_id' => $order_id ], [ '%d' ] );
621 }
622
623 public static function release_expired_reservations(): void {
624 global $wpdb;
625
626 $table = $wpdb->prefix . 'storeengine_reserved_stock';
627
628 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup delete on a custom StoreEngine reservations table; prepared with %i/%s placeholders.
629 $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE expires <= %s', $table, current_time( 'mysql', true ) ) );
630 }
631 }
632