PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.26
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.26
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.26, at includes/Orders.php

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