PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.22
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.22
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 1.10.0 1.9.17 All 166 releases
woocommerce-pos / includes / Orders.php

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

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