PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.27
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.27
1.10.27 1.10.26 1.10.25 1.10.24 1.10.23 1.10.22 1.10.21 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 All 171 releases
woocommerce-pos / includes / Orders.php

Orders.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.27, at includes/Orders.php

1,228 lines 45.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WCPOS Orders Class
4 * Extends WooCommerce Orders.
5 *
6 * @author Paul Kilmurray <[email protected]>
7 *
8 * @see http://wcpos.com
9 * @package WCPOS\WooCommercePOS
10 */
11
12 namespace WCPOS\WooCommercePOS;
13
14 use WC_Abstract_Order;
15 use WC_Coupon;
16 use WC_Order;
17 use WC_Order_Item;
18 use WC_Order_Item_Product;
19 use WC_Order_Item_Shipping;
20 use WC_Payment_Gateway;
21 use WC_Discounts;
22 use WC_Product;
23 use WC_Product_Simple;
24 use WC_Tax;
25 use WCPOS\WooCommercePOS\Payments\Gateway_Contract;
26
27 /**
28 * Orders Class
29 * - runs for all life cycles.
30 */
31 class Orders {
32 /**
33 * Map of temporary product IDs to category IDs for coupon validation.
34 *
35 * Static because multiple Orders instances may register hooks on the same
36 * filter (plugin init + test setUp). All instances must recognize temp IDs
37 * assigned by any other instance to avoid invalid DB reads.
38 *
39 * @var array<int, int[]>
40 */
41 private static $temp_product_categories = array();
42
43 /**
44 * Counter for generating unique temporary product IDs.
45 *
46 * Initialized with a random offset per request to avoid collisions when
47 * a persistent object cache backend (Redis/Memcached) is active and
48 * concurrent requests prime the same cache groups.
49 *
50 * @var int
51 */
52 private static $temp_id_counter = 0;
53
54 /**
55 * Armed date_paid suppressions, keyed by order id — see arm_paid_date_suppression().
56 *
57 * Each entry: `filter` (the callback on woocommerce_payment_complete_order_status),
58 * `from` (the order's status when the suppression was armed) and `to` (the
59 * configured status the gateway is about to write).
60 *
61 * @var array<int, array{filter: callable, from: string, to: string}>
62 */
63 private $paid_date_suppressions = array();
64
65 /**
66 * Constructor.
67 */
68 public function __construct() {
69 $this->register_order_status();
70 add_filter( 'wc_order_statuses', array( $this, 'wc_order_statuses' ), 10, 1 );
71 add_filter( 'woocommerce_order_needs_payment', array( $this, 'order_needs_payment' ), 10, 3 );
72 add_filter( 'woocommerce_valid_order_statuses_for_payment', array( $this, 'valid_order_statuses_for_payment' ), 10, 2 );
73 add_filter( 'woocommerce_valid_order_statuses_for_payment_complete', array( $this, 'valid_order_statuses_for_payment_complete' ), 10, 2 );
74 add_filter( 'woocommerce_payment_complete_order_status', array( $this, 'payment_complete_order_status' ), 10, 3 );
75 add_filter( 'woocommerce_bacs_process_payment_order_status', array( $this, 'offline_process_payment_order_status' ), 10, 2 );
76 add_filter( 'woocommerce_cheque_process_payment_order_status', array( $this, 'offline_process_payment_order_status' ), 10, 2 );
77 add_filter( 'woocommerce_cod_process_payment_order_status', array( $this, 'offline_process_payment_order_status' ), 10, 2 );
78 // PHP_INT_MAX: the redirect is the decision input and gateways rewrite it on
79 // this same filter — Stripe swaps get_return_url() for a #confirm-pi hash at
80 // 99999 when the intent needs 3DS. Reading it earlier closes a sale the
81 // customer is still confirming.
82 add_filter( 'woocommerce_payment_successful_result', array( $this, 'apply_unpaid_gateway_order_status' ), PHP_INT_MAX, 2 );
83 add_filter( 'woocommerce_hidden_order_itemmeta', array( $this, 'hidden_order_itemmeta' ) );
84 add_filter( 'woocommerce_order_item_product', array( $this, 'order_item_product' ), 10, 2 );
85 add_filter( 'woocommerce_order_get_tax_location', array( $this, 'get_tax_location' ), 10, 2 );
86 add_action( 'woocommerce_order_item_after_calculate_taxes', array( $this, 'order_item_after_calculate_taxes' ) );
87 add_action( 'woocommerce_order_item_shipping_after_calculate_taxes', array( $this, 'order_item_after_calculate_taxes' ) );
88 add_action( 'woocommerce_order_item_fee_after_calculate_taxes', array( __CLASS__, 'fee_after_calculate_taxes' ), 10, 2 );
89 add_filter( 'woocommerce_coupon_get_items_to_validate', array( $this, 'coupon_get_items_to_validate' ), 10, 2 );
90 add_filter( 'woocommerce_coupon_is_valid_for_product', array( $this, 'coupon_is_valid_for_product' ), 10, 4 );
91 add_action( 'woocommerce_order_after_calculate_totals', array( __CLASS__, 'cleanup_temp_caches' ), 999 );
92 }
93
94 /**
95 * Add custom POS order statuses.
96 *
97 * @param array $order_statuses Existing order statuses.
98 *
99 * @return array
100 */
101 public function wc_order_statuses( array $order_statuses ): array {
102 $order_statuses['wc-pos-open'] = /* translators: POS order status label. */ _x( 'POS - Open', 'Order status', 'woocommerce-pos' );
103 $order_statuses['wc-pos-partial'] = _x( 'POS - Partial Payment', 'Order status', 'woocommerce-pos' );
104
105 return $order_statuses;
106 }
107
108 /**
109 * WooCommerce order-pay form won't allow processing of orders with total = 0.
110 *
111 * NOTE: $needs_payment is meant to be a boolean, but I have seen it as null.
112 *
113 * @param bool $needs_payment Whether payment is needed.
114 * @param WC_Abstract_Order $order The order object.
115 * @param array $valid_order_statuses Valid order statuses for payment.
116 *
117 * @return bool
118 */
119 public function order_needs_payment( $needs_payment, WC_Abstract_Order $order, array $valid_order_statuses ) {
120 // If the order total is zero and status is a POS status, then allow payment to be taken, ie: Gift Card.
121 if ( 0 == $order->get_total() && \in_array( $order->get_status(), array( 'pos-open', 'pos-partial' ), true ) ) {
122 return true;
123 }
124
125 return $needs_payment;
126 }
127
128 /**
129 * Note: the wc- prefix is not used here because it is added by WooCommerce.
130 *
131 * @param array $order_statuses Valid order statuses.
132 * @param WC_Abstract_Order $order The order object.
133 *
134 * @return array
135 */
136 public function valid_order_statuses_for_payment( array $order_statuses, WC_Abstract_Order $order ): array {
137 $order_statuses[] = 'pos-open';
138 $order_statuses[] = 'pos-partial';
139
140 return $order_statuses;
141 }
142
143 /**
144 * Valid order statuses for payment complete.
145 *
146 * @param array $order_statuses Valid order statuses.
147 * @param WC_Abstract_Order $order The order object.
148 *
149 * @return array
150 */
151 public function valid_order_statuses_for_payment_complete( array $order_statuses, WC_Abstract_Order $order ): array {
152 $order_statuses[] = 'pos-open';
153 $order_statuses[] = 'pos-partial';
154
155 return $order_statuses;
156 }
157
158 /**
159 * Payment complete order status.
160 * POS orders are also matched by order origin because gateway webhooks and
161 * reconciliation crons complete payment outside POS requests.
162 *
163 * A configured status WooCommerce does not itself call paid is not answered
164 * while the order already reads as it with no date_paid. Two callers ask:
165 * WC_Order::maybe_set_date_paid() asks from set_status(), after the status
166 * prop has moved, and stamps date_paid when has_status( <answer> ) is true —
167 * so a gateway landing a POS order on its stored pending, on-hold or failed
168 * status with its own update_status() would book an unpaid sale as revenue.
169 * Answering '' there fails has_status() and nothing is stamped.
170 * payment_complete() asks while the order still reads its previous status,
171 * and has stamped date_paid itself just before, so it gets the configured
172 * status as before and a real payment is still booked.
173 *
174 * @param string $status Order status.
175 * @param int $id Order ID.
176 * @param WC_Abstract_Order $order The order object.
177 *
178 * @return string
179 */
180 public function payment_complete_order_status( string $status, int $id, WC_Abstract_Order $order ): string {
181 if ( woocommerce_pos_request() || woocommerce_pos_is_pos_order( $order ) ) {
182 $configured = $this->normalize_status( $this->get_gateway_order_status( $order->get_payment_method() ), $status );
183
184 if (
185 $order->get_status() === $configured
186 && ! $order->get_date_paid( 'edit' )
187 && ! \in_array( $configured, wc_get_is_paid_statuses(), true )
188 ) {
189 return '';
190 }
191
192 return $configured;
193 }
194
195 return $status;
196 }
197
198 /**
199 * Process payment order status for offline gateways (BACS, cheque, COD).
200 *
201 * @param string $status Order status from gateway.
202 * @param WC_Abstract_Order $order The order object.
203 *
204 * @return string
205 */
206 public function offline_process_payment_order_status( string $status, WC_Abstract_Order $order ): string {
207 if ( ! woocommerce_pos_request() ) {
208 return $status;
209 }
210
211 if ( ! $order->get_id() ) {
212 return $status;
213 }
214
215 if ( ! woocommerce_pos_is_pos_order( $order ) ) {
216 return $status;
217 }
218
219 $configured = $this->normalize_status( $this->get_gateway_order_status( $order->get_payment_method() ), $status );
220
221 /*
222 * The gateway writes this status itself with update_status(), and no money
223 * has been taken at the till. payment_complete_order_status() reports the
224 * configured status as this order's paid status, so set_status() would
225 * stamp date_paid the moment the order lands on it — booking a pending
226 * "pay by invoice" or on-hold bank-transfer order as revenue. Suppress the
227 * stamp for that write when the configured status is one WooCommerce
228 * does not itself call paid; a paid status keeps WooCommerce's own
229 * behaviour (core stamps date_paid for COD landing on processing).
230 *
231 * This filter runs before the gateway's update_status(), so the
232 * suppression is armed here and released once the order object is saved
233 * — see release_paid_date_suppression_after_save().
234 */
235 if ( $order instanceof WC_Order && ! \in_array( $configured, wc_get_is_paid_statuses(), true ) ) {
236 $this->arm_paid_date_suppression( $order, $configured );
237 }
238
239 return $configured;
240 }
241
242 /**
243 * Suppress the date_paid stamp for one order until the gateway's status write.
244 *
245 * Scoped to the order id: a status-transition handler can call
246 * payment_complete() on a *different* order while this filter is live
247 * (subscriptions, bundles and gift-card plugins all do), and an
248 * unconditional '' would reach that order too — set_status() rejects an
249 * unknown status and falls back to 'pending', leaving an order that was
250 * just paid sitting unpaid.
251 *
252 * Released on `woocommerce_after_order_object_save` for that order once the
253 * save is the status write it was armed for (see
254 * release_paid_date_suppression_after_save()), and on `shutdown` as a
255 * backstop so a write that never happens cannot leave it armed.
256 *
257 * @param WC_Order $order The order the gateway is about to write.
258 * @param string $configured The configured status it is about to write.
259 */
260 private function arm_paid_date_suppression( WC_Order $order, string $configured ): void {
261 $order_id = $order->get_id();
262 if ( isset( $this->paid_date_suppressions[ $order_id ] ) ) {
263 return;
264 }
265
266 /*
267 * Only the write that lands the order on the configured status is
268 * suppressed. WC_Order::set_status() has already moved the status prop
269 * when maybe_set_date_paid() asks this filter, so the order reads as the
270 * configured status there; payment_complete() asks it while the order
271 * still sits on its previous status, and is passed through untouched.
272 */
273 $suppress = static function ( $payment_status, $filtered_order_id, $filtered_order = null ) use ( $order_id, $configured ) {
274 if ( (int) $filtered_order_id !== $order_id ) {
275 return $payment_status;
276 }
277
278 if ( $filtered_order instanceof WC_Abstract_Order && $filtered_order->get_status() !== $configured ) {
279 return $payment_status;
280 }
281
282 return '';
283 };
284
285 $this->paid_date_suppressions[ $order_id ] = array(
286 'filter' => $suppress,
287 'from' => $order->get_status(),
288 'to' => $configured,
289 );
290
291 add_filter( 'woocommerce_payment_complete_order_status', $suppress, PHP_INT_MAX, 3 );
292 add_action( 'woocommerce_after_order_object_save', array( $this, 'release_paid_date_suppression_after_save' ), 10, 1 );
293 add_action( 'shutdown', array( $this, 'release_all_paid_date_suppressions' ) );
294 }
295
296 /**
297 * Release the date_paid suppression once the order it was armed for has had
298 * its status written.
299 *
300 * A save that does not land the order on the configured status is not that
301 * write — a later callback on the same process-payment filter persisting
302 * gateway metadata, or an interim status, for instance — and releasing on
303 * it would let the gateway's own update_status() stamp date_paid after all.
304 * Such a save keeps the suppression; the shutdown backstop still clears it
305 * if the write never comes, and the filter itself only suppresses while the
306 * order reads as the configured status. An order already sitting on the
307 * configured status has nothing to transition, so any save releases it.
308 *
309 * @param WC_Abstract_Order $order The saved order.
310 */
311 public function release_paid_date_suppression_after_save( $order ): void {
312 if ( ! $order instanceof WC_Abstract_Order ) {
313 return;
314 }
315
316 $entry = $this->paid_date_suppressions[ $order->get_id() ] ?? null;
317 if ( null === $entry ) {
318 return;
319 }
320
321 if ( $entry['from'] !== $entry['to'] && $order->get_status() !== $entry['to'] ) {
322 return;
323 }
324
325 $this->release_paid_date_suppression( $order->get_id() );
326 }
327
328 /**
329 * Release every armed date_paid suppression.
330 */
331 public function release_all_paid_date_suppressions(): void {
332 foreach ( array_keys( $this->paid_date_suppressions ) as $order_id ) {
333 $this->release_paid_date_suppression( (int) $order_id );
334 }
335 }
336
337 /**
338 * Remove one order's date_paid suppression from the filter.
339 *
340 * @param int $order_id Order ID.
341 */
342 private function release_paid_date_suppression( int $order_id ): void {
343 if ( ! isset( $this->paid_date_suppressions[ $order_id ] ) ) {
344 return;
345 }
346
347 remove_filter( 'woocommerce_payment_complete_order_status', $this->paid_date_suppressions[ $order_id ]['filter'], PHP_INT_MAX );
348 unset( $this->paid_date_suppressions[ $order_id ] );
349
350 if ( empty( $this->paid_date_suppressions ) ) {
351 remove_action( 'woocommerce_after_order_object_save', array( $this, 'release_paid_date_suppression_after_save' ), 10 );
352 remove_action( 'shutdown', array( $this, 'release_all_paid_date_suppressions' ) );
353 }
354 }
355
356 /**
357 * Normalise a configured gateway order status for the WooCommerce status filters.
358 *
359 * Both `woocommerce_payment_complete_order_status` and the offline gateway
360 * `*_process_payment_order_status` filters expect a status *without* the `wc-`
361 * prefix, so the prefix is stripped and the result validated against the
362 * registered order statuses. Anything empty or unrecognised falls back.
363 *
364 * @param string $candidate The configured status, which may carry the `wc-` prefix.
365 * @param string $fallback Status to return when the candidate is empty or unknown.
366 *
367 * @return string
368 */
369 private function normalize_status( string $candidate, string $fallback ): string {
370 $normalized_status = 0 === strpos( $candidate, 'wc-' )
371 ? substr( $candidate, 3 )
372 : $candidate;
373
374 if ( '' === $normalized_status ) {
375 return $fallback;
376 }
377
378 $valid_statuses = array_map(
379 function ( string $order_status ): string {
380 return 0 === strpos( $order_status, 'wc-' )
381 ? substr( $order_status, 3 )
382 : $order_status;
383 },
384 array_keys( wc_get_order_statuses() )
385 );
386
387 return \in_array( $normalized_status, $valid_statuses, true )
388 ? $normalized_status
389 : $fallback;
390 }
391
392 /**
393 * Apply the configured POS order status when a gateway settles without payment.
394 *
395 * The generic form of offline_process_payment_order_status(). Those three
396 * gateways are hooked by name only because BACS, cheque and COD each expose a
397 * `woocommerce_{id}_process_payment_order_status` filter. A gateway that takes
398 * no money at the till and exposes no such filter — a quote, invoice or
399 * purchase-order gateway — returned success while leaving the order at
400 * pos-open, so the configured status was never applied and the till never
401 * finished the sale. `woocommerce_payment_successful_result` is the seam every
402 * gateway passes through: WooCommerce applies it after any successful
403 * process_payment(), including on the POS pay page.
404 *
405 * Deliberately narrow, because "returned success but left the order open" is
406 * also what a gateway awaiting an async confirmation looks like:
407 *
408 * - `pos-open` only, never `pos-partial` — a partial tender is still owed
409 * money, and closing it would lose that.
410 * - no `date_paid` — money moving means the payment_complete path owns the
411 * status.
412 * - the gateway must be enabled for POS *and* carry an explicitly stored
413 * status. The settings view synthesizes `wc-completed` for every installed
414 * gateway it has never seen, so trusting the computed value would mark an
415 * unconfigured third-party gateway Completed with no money taken.
416 * - the gateway's redirect must be the order's own received page. That is
417 * the gateway saying it is finished; any other target means it is still
418 * collecting the money — see redirect_targets_order_received().
419 * - the gateway must not be one that moves money, read from the capability
420 * WooCommerce has every gateway declare: refunds. A gateway that can give
421 * money back takes money; if it hands the customer to the received page
422 * without having taken it, the payment is pending (a bank transfer, an
423 * async method) and its webhook owns the status. A quote, invoice or
424 * purchase-order gateway cannot refund, because nothing was ever paid.
425 *
426 * @param array $result Gateway result, passed through untouched.
427 * @param int $order_id Order ID.
428 *
429 * @return array
430 */
431 public function apply_unpaid_gateway_order_status( $result, $order_id ) {
432 if ( ! woocommerce_pos_request() ) {
433 return $result;
434 }
435
436 $order = wc_get_order( $order_id );
437
438 if ( ! $order instanceof WC_Order || ! woocommerce_pos_is_pos_order( $order ) ) {
439 return $result;
440 }
441
442 if ( ! $order->has_status( 'pos-open' ) || $order->get_date_paid( 'edit' ) ) {
443 return $result;
444 }
445
446 if ( ! \is_array( $result ) || ! $this->redirect_targets_order_received( $result, $order ) ) {
447 return $result;
448 }
449
450 $gateway_id = $order->get_payment_method();
451
452 if ( $this->gateway_moves_money( $gateway_id ) ) {
453 return $result;
454 }
455 $configured = $this->get_stored_gateway_order_status( $gateway_id );
456
457 if ( '' === $configured ) {
458 return $result;
459 }
460
461 $status = $this->normalize_status( $configured, '' );
462
463 if ( '' === $status || $order->has_status( $status ) ) {
464 return $result;
465 }
466
467 /*
468 * payment_complete_order_status() reports the configured status as this
469 * order's paid status, which makes WC_Order::set_status() stamp date_paid
470 * the moment the status changes — booking an unpaid order as revenue. No
471 * payment was taken here, so suppress it for this transition only.
472 *
473 * Scoped to this order id: a status-transition handler can call
474 * payment_complete() on a *different* order while this filter is live
475 * (subscriptions, bundles and gift-card plugins all do), and an
476 * unconditional '' would reach that order too — set_status() rejects an
477 * unknown status and falls back to 'pending', leaving an order that was
478 * just paid sitting unpaid.
479 */
480 $target_id = $order->get_id();
481 $suppress_paid_date = static function ( $payment_status, $filtered_order_id ) use ( $target_id ) {
482 return (int) $filtered_order_id === $target_id ? '' : $payment_status;
483 };
484
485 add_filter( 'woocommerce_payment_complete_order_status', $suppress_paid_date, PHP_INT_MAX, 2 );
486
487 try {
488 /*
489 * update_status()'s return value is deliberately not checked. It reports
490 * false only when the order has no id — impossible here — because
491 * WC_Abstract_Order::save() and WC_Order::status_transition() each catch
492 * Exception themselves and handle_exception() does not rethrow, so a
493 * throwing hook never reaches update_status()'s own catch and it still
494 * returns true. Nor could the checkout be aborted from here:
495 * WC_Form_Handler::pay_action() applies this filter inside its
496 * `'success' === $result['result']` branch and redirects unconditionally
497 * on the next line.
498 */
499 $order->update_status(
500 $status,
501 /* translators: %s: payment gateway title. */
502 sprintf( __( 'Order status set by %s; no payment was taken at the till.', 'woocommerce-pos' ), $order->get_payment_method_title() )
503 );
504 } finally {
505 // Must come off even if a status-change handler throws: left in place it
506 // would suppress the configured status, and date_paid, for every later
507 // payment in this request.
508 remove_filter( 'woocommerce_payment_complete_order_status', $suppress_paid_date, PHP_INT_MAX );
509 }
510
511 return $result;
512 }
513
514 /**
515 * Whether a successful gateway result sends the customer to the order's received page.
516 *
517 * The redirect is the one thing every gateway declares about what happens
518 * next, and it separates the two shapes that both "return success and leave
519 * the order open":
520 *
521 * - A gateway that is finished sends the customer to the received page,
522 * `get_return_url( $order )`. Quotes, invoices, purchase orders, BACS,
523 * cheque and COD all do. No money will ever move through it, so the
524 * configured POS status is the only thing that closes the sale.
525 * - A gateway that still has to collect the money sends them somewhere else:
526 * a hosted checkout off-site (Dintero, Mollie, PayPal, Klarna), or an
527 * on-site pay or receipt page that posts a form to one. It settles the
528 * order later, from its callback, through payment_complete(). Acting on
529 * this shape marked the order Completed while the cashier was still
530 * looking at the hosted checkout (1.10.20–1.10.22): Dintero's capture
531 * handler then found no transaction and bounced the order to on-hold, and
532 * the till — which reads any status outside its open/unpaid set as a
533 * finished sale — opened the receipt before a payment method was chosen.
534 *
535 * Compared without scheme or trailing slash: `get_return_url()` may upgrade to
536 * https, and a gateway may append its own arguments. Both the order's received
537 * URL and its `woocommerce_get_return_url`-filtered form are accepted, so a
538 * plugin that moves the thank-you page still matches — see is_same_page() for
539 * what "same page" means on plain permalinks, where the page is in the query.
540 *
541 * A redirect carrying a fragment never matches. A fragment on a thank-you URL
542 * is a gateway's instruction to its own script to do something before the sale
543 * is done (Stripe's and WooPayments' `#confirm-pi…` 3DS hand-off); a gateway
544 * that is finished has no reason to add one. Nor does a missing or relative
545 * redirect match: WooCommerce's pay handler would redirect to it unchanged,
546 * and nothing here can tell what it is.
547 *
548 * @param array $result Gateway result from process_payment().
549 * @param WC_Order $order The order being paid.
550 *
551 * @return bool
552 */
553 private function redirect_targets_order_received( array $result, WC_Order $order ): bool {
554 $redirect = isset( $result['redirect'] ) && \is_string( $result['redirect'] ) ? trim( $result['redirect'] ) : '';
555
556 if ( '' === $redirect || false !== strpos( $redirect, '#' ) ) {
557 return false;
558 }
559
560 $received_url = $order->get_checkout_order_received_url();
561
562 /** This filter is documented in woocommerce/includes/abstracts/abstract-wc-payment-gateway.php */
563 $return_url = apply_filters( 'woocommerce_get_return_url', $received_url, $order ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core hook.
564
565 foreach ( array_unique( array( $received_url, $return_url ) ) as $candidate ) {
566 if ( \is_string( $candidate ) && '' !== $candidate && $this->is_same_page( $redirect, $candidate ) ) {
567 return true;
568 }
569 }
570
571 return false;
572 }
573
574 /**
575 * Whether a gateway's redirect lands on the given page.
576 *
577 * Host (case-insensitive), port and path (without a trailing slash) must
578 * match; the scheme is ignored, and a port is compared only when it is not
579 * the scheme's default, so http and https forms of one origin agree while
580 * two services on one host do not.
581 *
582 * The query is compared one way: every argument the page carries must be on
583 * the redirect with the same value, except `key`, which a gateway may drop.
584 * Extra arguments on the redirect are fine (`utm_nooverride`, a gateway's own
585 * tracking). This is what makes plain permalinks safe, where every
586 * WooCommerce page shares the path `/` and the page is its query: the
587 * received page carries `page_id` and the received endpoint (`order-received`,
588 * or whatever the store renamed it to, read from the URL itself rather than
589 * assumed), so the checkout page, the pay page and any other page of the same
590 * site fail to carry one of them.
591 *
592 * @param string $redirect The gateway's redirect.
593 * @param string $page The page it must land on.
594 *
595 * @return bool
596 */
597 private function is_same_page( string $redirect, string $page ): bool {
598 $parts_r = wp_parse_url( $redirect );
599 $parts_p = wp_parse_url( $page );
600
601 if ( ! \is_array( $parts_r ) || ! \is_array( $parts_p ) ) {
602 return false;
603 }
604
605 $host_r = strtolower( (string) ( $parts_r['host'] ?? '' ) );
606 $host_p = strtolower( (string) ( $parts_p['host'] ?? '' ) );
607
608 if ( '' === $host_r || $host_r !== $host_p ) {
609 return false;
610 }
611
612 if ( $this->explicit_port( $parts_r ) !== $this->explicit_port( $parts_p ) ) {
613 return false;
614 }
615
616 $path_r = untrailingslashit( (string) ( $parts_r['path'] ?? '/' ) );
617 $path_p = untrailingslashit( (string) ( $parts_p['path'] ?? '/' ) );
618
619 if ( $path_r !== $path_p ) {
620 return false;
621 }
622
623 parse_str( (string) ( $parts_r['query'] ?? '' ), $query_r );
624 parse_str( (string) ( $parts_p['query'] ?? '' ), $query_p );
625
626 foreach ( $query_p as $name => $value ) {
627 if ( 'key' === $name ) {
628 continue;
629 }
630
631 if ( ! isset( $query_r[ $name ] ) || (string) $query_r[ $name ] !== (string) $value ) {
632 return false;
633 }
634 }
635
636 return true;
637 }
638
639 /**
640 * The port of a parsed URL, or '' when it is the scheme's default.
641 *
642 * @param array $parts Output of wp_parse_url().
643 *
644 * @return string
645 */
646 private function explicit_port( array $parts ): string {
647 if ( ! isset( $parts['port'] ) ) {
648 return '';
649 }
650
651 $port = (int) $parts['port'];
652 $scheme = strtolower( (string) ( $parts['scheme'] ?? '' ) );
653
654 if ( ( 'http' === $scheme && 80 === $port ) || ( 'https' === $scheme && 443 === $port ) ) {
655 return '';
656 }
657
658 return (string) $port;
659 }
660
661 /**
662 * Whether a gateway declares WooCommerce's refund capability.
663 *
664 * The declaration is the gateway's own (`$supports`, through
665 * `WC_Payment_Gateway::supports()` and its filter). A gateway WooCommerce
666 * does not know is taken as not moving money: the merchant stored a POS status
667 * for it, and that choice is the only thing left to act on.
668 *
669 * @param string $gateway_id The payment gateway ID.
670 *
671 * @return bool
672 */
673 private function gateway_moves_money( string $gateway_id ): bool {
674 if ( '' === $gateway_id ) {
675 return false;
676 }
677
678 $gateways = WC()->payment_gateways()->payment_gateways();
679 $gateway = $gateways[ $gateway_id ] ?? null;
680
681 return $gateway instanceof WC_Payment_Gateway && $gateway->supports( 'refunds' );
682 }
683
684 /**
685 * Read the explicitly stored per-gateway order status.
686 *
687 * The received page and the POS gateway catalog read the same value, so the
688 * reader lives on Gateway_Contract — see get_stored_order_status() there for
689 * why the raw option, and not the settings view, is the source of intent.
690 *
691 * @param string $gateway_id The payment gateway ID.
692 *
693 * @return string The stored status (may include the wc- prefix), or '' when absent.
694 */
695 private function get_stored_gateway_order_status( string $gateway_id ): string {
696 return ( new Gateway_Contract() )->get_stored_order_status( $gateway_id );
697 }
698
699 /**
700 * Resolve the configured POS order status for a given payment gateway.
701 *
702 * Looks up the per-gateway order_status from payment_gateways settings.
703 * Falls back to 'wc-completed' if no setting is found.
704 *
705 * @param string $gateway_id The payment gateway ID.
706 *
707 * @return string The configured order status (may include wc- prefix).
708 */
709 private function get_gateway_order_status( string $gateway_id ): string {
710 $gateway_settings = woocommerce_pos_get_settings( 'payment_gateways' );
711
712 if (
713 is_array( $gateway_settings )
714 && isset( $gateway_settings['gateways'][ $gateway_id ]['order_status'] )
715 && is_string( $gateway_settings['gateways'][ $gateway_id ]['order_status'] )
716 && '' !== $gateway_settings['gateways'][ $gateway_id ]['order_status']
717 ) {
718 return $gateway_settings['gateways'][ $gateway_id ]['order_status'];
719 }
720
721 return 'wc-completed';
722 }
723
724 /**
725 * Hides uuid from appearing on Order Edit page.
726 *
727 * @param array $meta_keys Hidden meta keys.
728 *
729 * @return array
730 */
731 public function hidden_order_itemmeta( array $meta_keys ): array {
732 return array_merge( $meta_keys, array( '_woocommerce_pos_uuid', '_woocommerce_pos_tax_status', '_woocommerce_pos_data' ) );
733 }
734
735 /**
736 * Filter the product object for an order item.
737 *
738 * @param bool|WC_Product $product The product object or false if not found.
739 * @param WC_Order_Item_Product $item The order item object.
740 *
741 * @return bool|WC_Product
742 */
743 public function order_item_product( $product, $item ) {
744 $pos_data_json = $item->get_meta( '_woocommerce_pos_data', true );
745
746 // For misc products (product_id=0), create a synthetic WC_Product_Simple.
747 // Requires _woocommerce_pos_data to distinguish POS items from other plugins.
748 if ( 0 === $item->get_product_id() ) {
749 if ( ! $pos_data_json ) {
750 return $product;
751 }
752
753 $product = new WC_Product_Simple();
754 $product->set_name( $item->get_name() );
755 $sku = $item->get_meta( '_sku', true );
756 if ( $sku ) {
757 $this->set_synthetic_product_sku( $product, $sku );
758 }
759
760 // Misc products are synthetic and never persisted to DB, so we can
761 // safely apply POS price context directly. Shape-tolerant read: the
762 // storage may hold the historical JSON string or a native array
763 // (after a typed sync push lands through wc/v3).
764 $pos_data = \WCPOS\WooCommercePOS\Sync\Meta_Normalizer::decode_to_array( $pos_data_json );
765 if ( \is_array( $pos_data ) ) {
766 if ( isset( $pos_data['price'] ) ) {
767 $product->set_price( $pos_data['price'] );
768 }
769 if ( isset( $pos_data['regular_price'] ) ) {
770 $product->set_regular_price( $pos_data['regular_price'] );
771 }
772 if ( isset( $pos_data['tax_status'] ) ) {
773 $product->set_tax_status( $pos_data['tax_status'] );
774 }
775 if ( ! empty( $pos_data['virtual'] ) ) {
776 $product->set_virtual( true );
777 }
778 if ( ! empty( $pos_data['downloadable'] ) ) {
779 $product->set_downloadable( true );
780 }
781 if ( ! empty( $pos_data['categories'] ) && is_array( $pos_data['categories'] ) ) {
782 $category_ids = array_filter( array_map( 'intval', array_column( $pos_data['categories'], 'id' ) ) );
783 $product->set_category_ids( $category_ids );
784 }
785 if ( $this->is_pos_discounted_item_on_sale( $item, $product ) && isset( $pos_data['price'] ) ) {
786 $product->set_sale_price( $pos_data['price'] );
787 }
788 }
789
790 return $product;
791 }
792
793 // For real products, only apply POS overrides when pos_data exists.
794 if ( ! $product || empty( $pos_data_json ) ) {
795 return $product;
796 }
797
798 $pos_data = \WCPOS\WooCommercePOS\Sync\Meta_Normalizer::decode_to_array( $pos_data_json );
799 if ( ! \is_array( $pos_data ) ) {
800 return $product;
801 }
802
803 // Use an isolated product instance for coupon-specific context.
804 if ( $product->get_id() ) {
805 $product = wc_get_product_object( $product->get_type(), $product->get_id() );
806 }
807
808 if ( isset( $pos_data['tax_status'] ) ) {
809 $product->set_tax_status( $pos_data['tax_status'] );
810 }
811
812 return $product;
813 }
814
815 /**
816 * Provide coupon-validation products with POS context (sale state, tax status).
817 *
818 * This runs only inside WC_Discounts. We return per-line-item product objects
819 * so coupon rules (exclude_sale_items, tax-aware discount amounts) use POS data
820 * without mutating products used by stock update routines.
821 *
822 * @param array $items Discount items (stdClass objects).
823 * @param WC_Discounts $discounts Discounts context.
824 *
825 * @return array
826 */
827 public function coupon_get_items_to_validate( array $items, WC_Discounts $discounts ): array {
828 $object = $discounts->get_object();
829 if ( ! $object instanceof WC_Order || ! woocommerce_pos_is_pos_order( $object ) ) {
830 return $items;
831 }
832
833 foreach ( $items as $index => $discount_item ) {
834 if ( ! isset( $discount_item->object ) || ! $discount_item->object instanceof WC_Order_Item_Product ) {
835 continue;
836 }
837
838 $original_product = isset( $discount_item->product ) && $discount_item->product instanceof WC_Product
839 ? $discount_item->product
840 : null;
841 $coupon_product = $this->build_coupon_product_context( $discount_item->object, $original_product );
842
843 if ( $coupon_product instanceof WC_Product ) {
844 $items[ $index ]->product = $coupon_product;
845 }
846 }
847
848 return $items;
849 }
850
851 /**
852 * Build a product object used only for coupon validation/calculation.
853 *
854 * @param WC_Order_Item_Product $item Order item.
855 * @param WC_Product|null $product Current product object.
856 *
857 * @return WC_Product|null
858 */
859 private function build_coupon_product_context( WC_Order_Item_Product $item, ?WC_Product $product = null ): ?WC_Product {
860 $pos_data = $this->get_pos_item_data( $item );
861 if ( null === $pos_data ) {
862 return $product;
863 }
864
865 $is_temp_id = $product && isset( self::$temp_product_categories[ $product->get_id() ] );
866
867 if ( $product && $product->get_id() && ! $is_temp_id ) {
868 // Get a fresh product instance to apply POS overrides.
869 $product = wc_get_product_object( $product->get_type(), $product->get_id() );
870 } elseif ( 0 === $item->get_product_id() ) {
871 $product = new WC_Product_Simple();
872 $product->set_name( $item->get_name() );
873 $sku = $item->get_meta( '_sku', true );
874 if ( $sku ) {
875 $this->set_synthetic_product_sku( $product, $sku );
876 }
877 }
878
879 if ( ! $product ) {
880 return null;
881 }
882
883 if ( isset( $pos_data['price'] ) ) {
884 $product->set_price( $pos_data['price'] );
885 }
886 if ( isset( $pos_data['regular_price'] ) ) {
887 $product->set_regular_price( $pos_data['regular_price'] );
888 }
889 if ( isset( $pos_data['tax_status'] ) ) {
890 $product->set_tax_status( $pos_data['tax_status'] );
891 }
892 if ( ! empty( $pos_data['virtual'] ) ) {
893 $product->set_virtual( true );
894 }
895 if ( ! empty( $pos_data['downloadable'] ) ) {
896 $product->set_downloadable( true );
897 }
898 if ( ! empty( $pos_data['categories'] ) && is_array( $pos_data['categories'] ) ) {
899 $category_ids = array_filter( array_map( 'intval', array_column( $pos_data['categories'], 'id' ) ) );
900 $product->set_category_ids( $category_ids );
901
902 // Assign a temporary non-zero ID and prime WP caches so that
903 // WC's get_the_terms() (called via wc_get_product_cat_ids) finds
904 // our categories. get_the_terms() requires get_post() to succeed
905 // and checks the object term cache before querying the DB.
906 if ( 0 === $product->get_id() && ! empty( $category_ids ) ) {
907 if ( 0 === self::$temp_id_counter ) {
908 // Use a random offset so concurrent requests don't collide
909 // when a persistent object cache is active.
910 self::$temp_id_counter = PHP_INT_MAX - wp_rand( 0, 999999 );
911 }
912 $temp_id = self::$temp_id_counter--;
913 $product->set_id( $temp_id );
914 self::$temp_product_categories[ $temp_id ] = $category_ids;
915
916 // Prime post cache so get_post(temp_id) succeeds.
917 $fake_post = new \stdClass();
918 $fake_post->ID = $temp_id;
919 $fake_post->post_type = 'product';
920 $fake_post->post_status = 'publish';
921 $fake_post->filter = 'raw';
922 $fake_post->post_parent = 0;
923 $fake_post->post_title = '';
924 $fake_post->post_content = '';
925 $fake_post->post_excerpt = '';
926 $fake_post->post_date = '';
927 $fake_post->post_date_gmt = '';
928 wp_cache_set( $temp_id, $fake_post, 'posts' );
929
930 // Prime term cache so get_object_term_cache() returns our IDs.
931 // get_the_terms() checks this cache before querying the DB,
932 // which is how validate_coupon_product_categories sees our categories.
933 wp_cache_set( $temp_id, $category_ids, 'product_cat_relationships' );
934 }
935 }
936 if ( $this->is_pos_discounted_item_on_sale( $item, $product ) && isset( $pos_data['price'] ) ) {
937 $product->set_sale_price( $pos_data['price'] );
938 }
939
940 return $product;
941 }
942
943 /**
944 * Override coupon category validation for misc products.
945 *
946 * WooCommerce uses wc_get_product_cat_ids( $product->get_id() ) to check
947 * product_categories and excluded_product_categories coupon restrictions.
948 * Synthetic misc products use temporary non-zero IDs so WC's DB lookup
949 * doesn't short-circuit. This filter re-evaluates using per-item categories.
950 *
951 * @param bool $valid Whether the coupon is valid for the product.
952 * @param WC_Product $product Product being validated.
953 * @param WC_Coupon $coupon Coupon being applied.
954 * @param mixed $values Values (order item or cart item data).
955 *
956 * @return bool
957 */
958 public function coupon_is_valid_for_product( bool $valid, $product, $coupon, $values ): bool {
959 if ( ! $product instanceof WC_Product ) {
960 return $valid;
961 }
962
963 // Only handle products with temp IDs assigned by build_coupon_product_context.
964 $product_id = $product->get_id();
965 if ( ! isset( self::$temp_product_categories[ $product_id ] ) ) {
966 return $valid;
967 }
968
969 $product_cats = $product->get_category_ids();
970 if ( empty( $product_cats ) ) {
971 return $valid;
972 }
973
974 // Include parent categories for hierarchy matching (parity with wc_get_product_cat_ids).
975 foreach ( $product_cats as $cat ) {
976 $product_cats = array_merge( $product_cats, get_ancestors( $cat, 'product_cat' ) );
977 }
978 $product_cats = array_unique( $product_cats );
979
980 // Re-evaluate product_categories restriction.
981 $coupon_cats = $coupon->get_product_categories();
982 if ( ! empty( $coupon_cats ) ) {
983 $valid = $valid && count( array_intersect( $product_cats, $coupon_cats ) ) > 0;
984 }
985
986 // Re-evaluate excluded_product_categories restriction.
987 $excluded_cats = $coupon->get_excluded_product_categories();
988 if ( ! empty( $excluded_cats ) && count( array_intersect( $product_cats, $excluded_cats ) ) > 0 ) {
989 $valid = false;
990 }
991
992 return $valid;
993 }
994
995 /**
996 * Remove temporary cache entries created by build_coupon_product_context().
997 *
998 * Hooked to woocommerce_order_after_calculate_totals (after all coupon
999 * validation is complete) so that persistent object cache backends
1000 * (Redis/Memcached) don't accumulate stale entries across requests.
1001 */
1002 public static function cleanup_temp_caches(): void {
1003 foreach ( array_keys( self::$temp_product_categories ) as $temp_id ) {
1004 wp_cache_delete( $temp_id, 'posts' );
1005 wp_cache_delete( $temp_id, 'product_cat_relationships' );
1006 }
1007 self::$temp_product_categories = array();
1008 self::$temp_id_counter = 0;
1009 }
1010
1011 /**
1012 * Determine whether an order item should be treated as "on sale" in coupon checks.
1013 *
1014 * @param WC_Order_Item_Product $item Order item.
1015 * @param WC_Product|null $product Product context (optional).
1016 *
1017 * @return bool
1018 */
1019 private function is_pos_discounted_item_on_sale( WC_Order_Item_Product $item, ?WC_Product $product = null ): bool {
1020 $pos_data = $this->get_pos_item_data( $item );
1021 if ( null === $pos_data || ! isset( $pos_data['price'], $pos_data['regular_price'] ) ) {
1022 return false;
1023 }
1024
1025 $is_on_sale = (float) $pos_data['price'] < (float) $pos_data['regular_price'];
1026 $product = $product ? $product : $item->get_product();
1027
1028 return (bool) apply_filters(
1029 'woocommerce_pos_item_is_on_sale',
1030 $is_on_sale,
1031 $product,
1032 $item,
1033 $pos_data
1034 );
1035 }
1036
1037 /**
1038 * Decode _woocommerce_pos_data from an order item.
1039 *
1040 * @param WC_Order_Item_Product $item Order item.
1041 *
1042 * @return array<string, mixed>|null
1043 */
1044 private function get_pos_item_data( WC_Order_Item_Product $item ): ?array {
1045 $pos_data_json = $item->get_meta( '_woocommerce_pos_data', true );
1046 if ( empty( $pos_data_json ) ) {
1047 return null;
1048 }
1049
1050 return \WCPOS\WooCommercePOS\Sync\Meta_Normalizer::decode_to_array( $pos_data_json );
1051 }
1052
1053 /**
1054 * Get tax location for this order.
1055 *
1056 * @param array $args Override the location.
1057 * @param WC_Abstract_Order $order The order object.
1058 *
1059 * @return array
1060 */
1061 public function get_tax_location( $args, WC_Abstract_Order $order ) {
1062 if ( ! woocommerce_pos_is_pos_order( $order ) ) {
1063 return $args;
1064 }
1065
1066 $tax_based_on = $order->get_meta( '_woocommerce_pos_tax_based_on' );
1067
1068 if ( $order instanceof WC_Order ) {
1069 if ( 'billing' == $tax_based_on ) {
1070 $args['country'] = $order->get_billing_country();
1071 $args['state'] = $order->get_billing_state();
1072 $args['postcode'] = $order->get_billing_postcode();
1073 $args['city'] = $order->get_billing_city();
1074 } elseif ( 'shipping' == $tax_based_on ) {
1075 $args['country'] = $order->get_shipping_country();
1076 $args['state'] = $order->get_shipping_state();
1077 $args['postcode'] = $order->get_shipping_postcode();
1078 $args['city'] = $order->get_shipping_city();
1079 } else {
1080 $args['country'] = WC()->countries->get_base_country();
1081 $args['state'] = WC()->countries->get_base_state();
1082 $args['postcode'] = WC()->countries->get_base_postcode();
1083 $args['city'] = WC()->countries->get_base_city();
1084 }
1085 }
1086
1087 return $args;
1088 }
1089
1090 /**
1091 * Respect a negative fee line's own tax_status and tax_class on POS-marked requests.
1092 *
1093 * WooCommerce routes negative fees through its discount tax path, disregarding the
1094 * fee's tax_status and tax_class and allocating line-item tax rates proportionally
1095 * instead. The v1 controller corrected this per-dispatch (issue #1403 row 2); this
1096 * global, request-gated registration serves both the v1 routes and the v2 push's
1097 * inner wc/v3 forward (which carries the X-WCPOS header) with one implementation.
1098 * Static so V1\Orders_Controller can delegate without constructing the service.
1099 *
1100 * @param \WC_Order_Item_Fee $fee_item The fee item.
1101 * @param array $calculate_tax_for The tax calculation location data.
1102 *
1103 * @return void
1104 */
1105 public static function fee_after_calculate_taxes( $fee_item, $calculate_tax_for ): void {
1106 if ( $fee_item->get_total() >= 0 ) {
1107 return;
1108 }
1109
1110 // Gate on the ORDER being a POS order (durable — survives wp-admin
1111 // Recalculate, bulk actions, and third-party recalculations), with the
1112 // POS request marker only as the supplement for the creation moment,
1113 // before the order is marked. A per-request-only gate silently flipped a
1114 // POS order's fee tax whenever a non-POS caller recalculated it.
1115 //
1116 // STOPGAP (2026-08-06 ruling): this preserves the existing POS fee-tax
1117 // semantics consistently, but the semantics themselves are slated for
1118 // replacement — negative fees are disowned by WooCommerce and the
1119 // override over-declares VAT on tax-inclusive stores. The plan of record
1120 // is migrating till discounts to virtual percent coupons; see
1121 // .claude/research/2026-08-06-wc-negative-fee-tax.md.
1122 // wcpos_is_pos_order() safely returns false for any non-order input.
1123 if ( ! wcpos_is_pos_order( $fee_item->get_order() ) && ! wcpos_request() ) {
1124 return;
1125 }
1126
1127 if ( 'taxable' === $fee_item->get_tax_status() ) {
1128 // Use the fee's own tax_class if set, otherwise the default class.
1129 $tax_class = $fee_item->get_tax_class();
1130 $calculate_tax_for['tax_class'] = $tax_class ? $tax_class : '';
1131
1132 $tax_rates = WC_Tax::find_rates( $calculate_tax_for );
1133 $discount_taxes = WC_Tax::calc_tax( (float) $fee_item->get_total(), $tax_rates );
1134
1135 $fee_item->set_taxes( array( 'total' => $discount_taxes ) );
1136 } else {
1137 // Clear taxes entirely when the fee's tax_status is 'none'.
1138 $fee_item->set_taxes( array() );
1139 }
1140
1141 $fee_item->save();
1142 }
1143
1144 /**
1145 * Calculate taxes for an order item.
1146 *
1147 * @param WC_Order_Item|WC_Order_Item_Shipping $item Order item object.
1148 *
1149 * @return void
1150 */
1151 public function order_item_after_calculate_taxes( $item ): void {
1152 $meta_data = $item->get_meta_data();
1153
1154 foreach ( $meta_data as $meta ) {
1155 if ( '_woocommerce_pos_data' === $meta->key ) {
1156 $pos_data = \WCPOS\WooCommercePOS\Sync\Meta_Normalizer::decode_to_array( $meta->value );
1157
1158 if ( null !== $pos_data ) {
1159 if ( isset( $pos_data['tax_status'] ) && 'none' == $pos_data['tax_status'] ) {
1160 $item->set_taxes( false );
1161 }
1162 } else {
1163 Logger::log( 'Unreadable _woocommerce_pos_data meta value on order item.' );
1164 }
1165
1166 break;
1167 }
1168 }
1169 }
1170
1171 /**
1172 * Register the POS order statuses.
1173 */
1174 private function register_order_status(): void {
1175 // Order status for open orders.
1176 register_post_status(
1177 'wc-pos-open',
1178 array(
1179 'label' => /* translators: POS order status label. */ _x( 'POS - Open', 'Order status', 'woocommerce-pos' ),
1180 'public' => true,
1181 'exclude_from_search' => false,
1182 'show_in_admin_all_list' => true,
1183 'show_in_admin_status_list' => true,
1184 // translators: %s is the number of orders with POS - Open status.
1185 'label_count' => _n_noop(
1186 'POS - Open <span class="count">(%s)</span>',
1187 'POS - Open <span class="count">(%s)</span>',
1188 'woocommerce-pos'
1189 ),
1190 )
1191 );
1192
1193 // Order status for partial payment orders.
1194 register_post_status(
1195 'wc-pos-partial',
1196 array(
1197 'label' => _x( 'POS - Partial Payment', 'Order status', 'woocommerce-pos' ),
1198 'public' => true,
1199 'exclude_from_search' => false,
1200 'show_in_admin_all_list' => true,
1201 'show_in_admin_status_list' => true,
1202 // translators: %s is the number of orders with POS - Partial Payment status.
1203 'label_count' => _n_noop(
1204 'POS - Partial Payment <span class="count">(%s)</span>',
1205 'POS - Partial Payment <span class="count">(%s)</span>',
1206 'woocommerce-pos'
1207 ),
1208 )
1209 );
1210 }
1211
1212 /**
1213 * Set SKU on a synthetic product, bypassing WooCommerce's uniqueness check.
1214 *
1215 * Synthetic products (product_id=0) are never saved to the database, so
1216 * SKU collisions with real products are irrelevant. Temporarily disabling
1217 * object_read causes set_sku() to skip the wc_product_has_unique_sku() call.
1218 *
1219 * @param WC_Product_Simple $product Synthetic product instance.
1220 * @param string $sku SKU value from order-item meta.
1221 */
1222 private function set_synthetic_product_sku( WC_Product_Simple $product, string $sku ): void {
1223 $product->set_object_read( false );
1224 $product->set_sku( $sku );
1225 $product->set_object_read( true );
1226 }
1227 }
1228