PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.23
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.23
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 1.10.4 1.10.2 1.10.1 All 168 releases
woocommerce-pos / includes / Orders.php

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

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