PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
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 1.9.15 1.9.16 1.9.14 1.9.13 1.9.12 1.9.11 1.9.10 1.9.9 All 158 releases
woocommerce-pos / includes / Orders.php

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

748 lines 26.3 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 <paul@kilbot.com>
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_hidden_order_itemmeta', array( $this, 'hidden_order_itemmeta' ) );
66 add_filter( 'woocommerce_order_item_product', array( $this, 'order_item_product' ), 10, 2 );
67 add_filter( 'woocommerce_order_get_tax_location', array( $this, 'get_tax_location' ), 10, 2 );
68 add_action( 'woocommerce_order_item_after_calculate_taxes', array( $this, 'order_item_after_calculate_taxes' ) );
69 add_action( 'woocommerce_order_item_shipping_after_calculate_taxes', array( $this, 'order_item_after_calculate_taxes' ) );
70 add_action( 'woocommerce_order_item_fee_after_calculate_taxes', array( __CLASS__, 'fee_after_calculate_taxes' ), 10, 2 );
71 add_filter( 'woocommerce_coupon_get_items_to_validate', array( $this, 'coupon_get_items_to_validate' ), 10, 2 );
72 add_filter( 'woocommerce_coupon_is_valid_for_product', array( $this, 'coupon_is_valid_for_product' ), 10, 4 );
73 add_action( 'woocommerce_order_after_calculate_totals', array( __CLASS__, 'cleanup_temp_caches' ), 999 );
74 }
75
76 /**
77 * Add custom POS order statuses.
78 *
79 * @param array $order_statuses Existing order statuses.
80 *
81 * @return array
82 */
83 public function wc_order_statuses( array $order_statuses ): array {
84 $order_statuses['wc-pos-open'] = /* translators: POS order status label. */ _x( 'POS - Open', 'Order status', 'woocommerce-pos' );
85 $order_statuses['wc-pos-partial'] = _x( 'POS - Partial Payment', 'Order status', 'woocommerce-pos' );
86
87 return $order_statuses;
88 }
89
90 /**
91 * WooCommerce order-pay form won't allow processing of orders with total = 0.
92 *
93 * NOTE: $needs_payment is meant to be a boolean, but I have seen it as null.
94 *
95 * @param bool $needs_payment Whether payment is needed.
96 * @param WC_Abstract_Order $order The order object.
97 * @param array $valid_order_statuses Valid order statuses for payment.
98 *
99 * @return bool
100 */
101 public function order_needs_payment( $needs_payment, WC_Abstract_Order $order, array $valid_order_statuses ) {
102 // If the order total is zero and status is a POS status, then allow payment to be taken, ie: Gift Card.
103 if ( 0 == $order->get_total() && \in_array( $order->get_status(), array( 'pos-open', 'pos-partial' ), true ) ) {
104 return true;
105 }
106
107 return $needs_payment;
108 }
109
110 /**
111 * Note: the wc- prefix is not used here because it is added by WooCommerce.
112 *
113 * @param array $order_statuses Valid order statuses.
114 * @param WC_Abstract_Order $order The order object.
115 *
116 * @return array
117 */
118 public function valid_order_statuses_for_payment( array $order_statuses, WC_Abstract_Order $order ): array {
119 $order_statuses[] = 'pos-open';
120 $order_statuses[] = 'pos-partial';
121
122 return $order_statuses;
123 }
124
125 /**
126 * Valid order statuses for payment complete.
127 *
128 * @param array $order_statuses Valid order statuses.
129 * @param WC_Abstract_Order $order The order object.
130 *
131 * @return array
132 */
133 public function valid_order_statuses_for_payment_complete( array $order_statuses, WC_Abstract_Order $order ): array {
134 $order_statuses[] = 'pos-open';
135 $order_statuses[] = 'pos-partial';
136
137 return $order_statuses;
138 }
139
140 /**
141 * Payment complete order status.
142 * POS orders are also matched by order origin because gateway webhooks and
143 * reconciliation crons complete payment outside POS requests.
144 *
145 * @param string $status Order status.
146 * @param int $id Order ID.
147 * @param WC_Abstract_Order $order The order object.
148 *
149 * @return string
150 */
151 public function payment_complete_order_status( string $status, int $id, WC_Abstract_Order $order ): string {
152 if ( woocommerce_pos_request() || woocommerce_pos_is_pos_order( $order ) ) {
153 return $this->normalize_status( $this->get_gateway_order_status( $order->get_payment_method() ), $status );
154 }
155
156 return $status;
157 }
158
159 /**
160 * Process payment order status for offline gateways (BACS, cheque, COD).
161 *
162 * @param string $status Order status from gateway.
163 * @param WC_Abstract_Order $order The order object.
164 *
165 * @return string
166 */
167 public function offline_process_payment_order_status( string $status, WC_Abstract_Order $order ): string {
168 if ( ! woocommerce_pos_request() ) {
169 return $status;
170 }
171
172 if ( ! $order->get_id() ) {
173 return $status;
174 }
175
176 if ( ! woocommerce_pos_is_pos_order( $order ) ) {
177 return $status;
178 }
179
180 return $this->normalize_status( $this->get_gateway_order_status( $order->get_payment_method() ), $status );
181 }
182
183 /**
184 * Normalise a configured gateway order status for the WooCommerce status filters.
185 *
186 * Both `woocommerce_payment_complete_order_status` and the offline gateway
187 * `*_process_payment_order_status` filters expect a status *without* the `wc-`
188 * prefix, so the prefix is stripped and the result validated against the
189 * registered order statuses. Anything empty or unrecognised falls back.
190 *
191 * @param string $candidate The configured status, which may carry the `wc-` prefix.
192 * @param string $fallback Status to return when the candidate is empty or unknown.
193 *
194 * @return string
195 */
196 private function normalize_status( string $candidate, string $fallback ): string {
197 $normalized_status = 0 === strpos( $candidate, 'wc-' )
198 ? substr( $candidate, 3 )
199 : $candidate;
200
201 if ( '' === $normalized_status ) {
202 return $fallback;
203 }
204
205 $valid_statuses = array_map(
206 function ( string $order_status ): string {
207 return 0 === strpos( $order_status, 'wc-' )
208 ? substr( $order_status, 3 )
209 : $order_status;
210 },
211 array_keys( wc_get_order_statuses() )
212 );
213
214 return \in_array( $normalized_status, $valid_statuses, true )
215 ? $normalized_status
216 : $fallback;
217 }
218
219 /**
220 * Resolve the configured POS order status for a given payment gateway.
221 *
222 * Looks up the per-gateway order_status from payment_gateways settings.
223 * Falls back to 'wc-completed' if no setting is found.
224 *
225 * @param string $gateway_id The payment gateway ID.
226 *
227 * @return string The configured order status (may include wc- prefix).
228 */
229 private function get_gateway_order_status( string $gateway_id ): string {
230 $gateway_settings = woocommerce_pos_get_settings( 'payment_gateways' );
231
232 if (
233 is_array( $gateway_settings )
234 && isset( $gateway_settings['gateways'][ $gateway_id ]['order_status'] )
235 && is_string( $gateway_settings['gateways'][ $gateway_id ]['order_status'] )
236 && '' !== $gateway_settings['gateways'][ $gateway_id ]['order_status']
237 ) {
238 return $gateway_settings['gateways'][ $gateway_id ]['order_status'];
239 }
240
241 return 'wc-completed';
242 }
243
244 /**
245 * Hides uuid from appearing on Order Edit page.
246 *
247 * @param array $meta_keys Hidden meta keys.
248 *
249 * @return array
250 */
251 public function hidden_order_itemmeta( array $meta_keys ): array {
252 return array_merge( $meta_keys, array( '_woocommerce_pos_uuid', '_woocommerce_pos_tax_status', '_woocommerce_pos_data' ) );
253 }
254
255 /**
256 * Filter the product object for an order item.
257 *
258 * @param bool|WC_Product $product The product object or false if not found.
259 * @param WC_Order_Item_Product $item The order item object.
260 *
261 * @return bool|WC_Product
262 */
263 public function order_item_product( $product, $item ) {
264 $pos_data_json = $item->get_meta( '_woocommerce_pos_data', true );
265
266 // For misc products (product_id=0), create a synthetic WC_Product_Simple.
267 // Requires _woocommerce_pos_data to distinguish POS items from other plugins.
268 if ( 0 === $item->get_product_id() ) {
269 if ( ! $pos_data_json ) {
270 return $product;
271 }
272
273 $product = new WC_Product_Simple();
274 $product->set_name( $item->get_name() );
275 $sku = $item->get_meta( '_sku', true );
276 if ( $sku ) {
277 $this->set_synthetic_product_sku( $product, $sku );
278 }
279
280 // Misc products are synthetic and never persisted to DB, so we can
281 // safely apply POS price context directly. Shape-tolerant read: the
282 // storage may hold the historical JSON string or a native array
283 // (after a typed sync push lands through wc/v3).
284 $pos_data = \WCPOS\WooCommercePOS\Sync\Meta_Normalizer::decode_to_array( $pos_data_json );
285 if ( \is_array( $pos_data ) ) {
286 if ( isset( $pos_data['price'] ) ) {
287 $product->set_price( $pos_data['price'] );
288 }
289 if ( isset( $pos_data['regular_price'] ) ) {
290 $product->set_regular_price( $pos_data['regular_price'] );
291 }
292 if ( isset( $pos_data['tax_status'] ) ) {
293 $product->set_tax_status( $pos_data['tax_status'] );
294 }
295 if ( ! empty( $pos_data['virtual'] ) ) {
296 $product->set_virtual( true );
297 }
298 if ( ! empty( $pos_data['downloadable'] ) ) {
299 $product->set_downloadable( true );
300 }
301 if ( ! empty( $pos_data['categories'] ) && is_array( $pos_data['categories'] ) ) {
302 $category_ids = array_filter( array_map( 'intval', array_column( $pos_data['categories'], 'id' ) ) );
303 $product->set_category_ids( $category_ids );
304 }
305 if ( $this->is_pos_discounted_item_on_sale( $item, $product ) && isset( $pos_data['price'] ) ) {
306 $product->set_sale_price( $pos_data['price'] );
307 }
308 }
309
310 return $product;
311 }
312
313 // For real products, only apply POS overrides when pos_data exists.
314 if ( ! $product || empty( $pos_data_json ) ) {
315 return $product;
316 }
317
318 $pos_data = \WCPOS\WooCommercePOS\Sync\Meta_Normalizer::decode_to_array( $pos_data_json );
319 if ( ! \is_array( $pos_data ) ) {
320 return $product;
321 }
322
323 // Use an isolated product instance for coupon-specific context.
324 if ( $product->get_id() ) {
325 $product = wc_get_product_object( $product->get_type(), $product->get_id() );
326 }
327
328 if ( isset( $pos_data['tax_status'] ) ) {
329 $product->set_tax_status( $pos_data['tax_status'] );
330 }
331
332 return $product;
333 }
334
335 /**
336 * Provide coupon-validation products with POS context (sale state, tax status).
337 *
338 * This runs only inside WC_Discounts. We return per-line-item product objects
339 * so coupon rules (exclude_sale_items, tax-aware discount amounts) use POS data
340 * without mutating products used by stock update routines.
341 *
342 * @param array $items Discount items (stdClass objects).
343 * @param WC_Discounts $discounts Discounts context.
344 *
345 * @return array
346 */
347 public function coupon_get_items_to_validate( array $items, WC_Discounts $discounts ): array {
348 $object = $discounts->get_object();
349 if ( ! $object instanceof WC_Order || ! woocommerce_pos_is_pos_order( $object ) ) {
350 return $items;
351 }
352
353 foreach ( $items as $index => $discount_item ) {
354 if ( ! isset( $discount_item->object ) || ! $discount_item->object instanceof WC_Order_Item_Product ) {
355 continue;
356 }
357
358 $original_product = isset( $discount_item->product ) && $discount_item->product instanceof WC_Product
359 ? $discount_item->product
360 : null;
361 $coupon_product = $this->build_coupon_product_context( $discount_item->object, $original_product );
362
363 if ( $coupon_product instanceof WC_Product ) {
364 $items[ $index ]->product = $coupon_product;
365 }
366 }
367
368 return $items;
369 }
370
371 /**
372 * Build a product object used only for coupon validation/calculation.
373 *
374 * @param WC_Order_Item_Product $item Order item.
375 * @param WC_Product|null $product Current product object.
376 *
377 * @return WC_Product|null
378 */
379 private function build_coupon_product_context( WC_Order_Item_Product $item, ?WC_Product $product = null ): ?WC_Product {
380 $pos_data = $this->get_pos_item_data( $item );
381 if ( null === $pos_data ) {
382 return $product;
383 }
384
385 $is_temp_id = $product && isset( self::$temp_product_categories[ $product->get_id() ] );
386
387 if ( $product && $product->get_id() && ! $is_temp_id ) {
388 // Get a fresh product instance to apply POS overrides.
389 $product = wc_get_product_object( $product->get_type(), $product->get_id() );
390 } elseif ( 0 === $item->get_product_id() ) {
391 $product = new WC_Product_Simple();
392 $product->set_name( $item->get_name() );
393 $sku = $item->get_meta( '_sku', true );
394 if ( $sku ) {
395 $this->set_synthetic_product_sku( $product, $sku );
396 }
397 }
398
399 if ( ! $product ) {
400 return null;
401 }
402
403 if ( isset( $pos_data['price'] ) ) {
404 $product->set_price( $pos_data['price'] );
405 }
406 if ( isset( $pos_data['regular_price'] ) ) {
407 $product->set_regular_price( $pos_data['regular_price'] );
408 }
409 if ( isset( $pos_data['tax_status'] ) ) {
410 $product->set_tax_status( $pos_data['tax_status'] );
411 }
412 if ( ! empty( $pos_data['virtual'] ) ) {
413 $product->set_virtual( true );
414 }
415 if ( ! empty( $pos_data['downloadable'] ) ) {
416 $product->set_downloadable( true );
417 }
418 if ( ! empty( $pos_data['categories'] ) && is_array( $pos_data['categories'] ) ) {
419 $category_ids = array_filter( array_map( 'intval', array_column( $pos_data['categories'], 'id' ) ) );
420 $product->set_category_ids( $category_ids );
421
422 // Assign a temporary non-zero ID and prime WP caches so that
423 // WC's get_the_terms() (called via wc_get_product_cat_ids) finds
424 // our categories. get_the_terms() requires get_post() to succeed
425 // and checks the object term cache before querying the DB.
426 if ( 0 === $product->get_id() && ! empty( $category_ids ) ) {
427 if ( 0 === self::$temp_id_counter ) {
428 // Use a random offset so concurrent requests don't collide
429 // when a persistent object cache is active.
430 self::$temp_id_counter = PHP_INT_MAX - wp_rand( 0, 999999 );
431 }
432 $temp_id = self::$temp_id_counter--;
433 $product->set_id( $temp_id );
434 self::$temp_product_categories[ $temp_id ] = $category_ids;
435
436 // Prime post cache so get_post(temp_id) succeeds.
437 $fake_post = new \stdClass();
438 $fake_post->ID = $temp_id;
439 $fake_post->post_type = 'product';
440 $fake_post->post_status = 'publish';
441 $fake_post->filter = 'raw';
442 $fake_post->post_parent = 0;
443 $fake_post->post_title = '';
444 $fake_post->post_content = '';
445 $fake_post->post_excerpt = '';
446 $fake_post->post_date = '';
447 $fake_post->post_date_gmt = '';
448 wp_cache_set( $temp_id, $fake_post, 'posts' );
449
450 // Prime term cache so get_object_term_cache() returns our IDs.
451 // get_the_terms() checks this cache before querying the DB,
452 // which is how validate_coupon_product_categories sees our categories.
453 wp_cache_set( $temp_id, $category_ids, 'product_cat_relationships' );
454 }
455 }
456 if ( $this->is_pos_discounted_item_on_sale( $item, $product ) && isset( $pos_data['price'] ) ) {
457 $product->set_sale_price( $pos_data['price'] );
458 }
459
460 return $product;
461 }
462
463 /**
464 * Override coupon category validation for misc products.
465 *
466 * WooCommerce uses wc_get_product_cat_ids( $product->get_id() ) to check
467 * product_categories and excluded_product_categories coupon restrictions.
468 * Synthetic misc products use temporary non-zero IDs so WC's DB lookup
469 * doesn't short-circuit. This filter re-evaluates using per-item categories.
470 *
471 * @param bool $valid Whether the coupon is valid for the product.
472 * @param WC_Product $product Product being validated.
473 * @param WC_Coupon $coupon Coupon being applied.
474 * @param mixed $values Values (order item or cart item data).
475 *
476 * @return bool
477 */
478 public function coupon_is_valid_for_product( bool $valid, $product, $coupon, $values ): bool {
479 if ( ! $product instanceof WC_Product ) {
480 return $valid;
481 }
482
483 // Only handle products with temp IDs assigned by build_coupon_product_context.
484 $product_id = $product->get_id();
485 if ( ! isset( self::$temp_product_categories[ $product_id ] ) ) {
486 return $valid;
487 }
488
489 $product_cats = $product->get_category_ids();
490 if ( empty( $product_cats ) ) {
491 return $valid;
492 }
493
494 // Include parent categories for hierarchy matching (parity with wc_get_product_cat_ids).
495 foreach ( $product_cats as $cat ) {
496 $product_cats = array_merge( $product_cats, get_ancestors( $cat, 'product_cat' ) );
497 }
498 $product_cats = array_unique( $product_cats );
499
500 // Re-evaluate product_categories restriction.
501 $coupon_cats = $coupon->get_product_categories();
502 if ( ! empty( $coupon_cats ) ) {
503 $valid = $valid && count( array_intersect( $product_cats, $coupon_cats ) ) > 0;
504 }
505
506 // Re-evaluate excluded_product_categories restriction.
507 $excluded_cats = $coupon->get_excluded_product_categories();
508 if ( ! empty( $excluded_cats ) && count( array_intersect( $product_cats, $excluded_cats ) ) > 0 ) {
509 $valid = false;
510 }
511
512 return $valid;
513 }
514
515 /**
516 * Remove temporary cache entries created by build_coupon_product_context().
517 *
518 * Hooked to woocommerce_order_after_calculate_totals (after all coupon
519 * validation is complete) so that persistent object cache backends
520 * (Redis/Memcached) don't accumulate stale entries across requests.
521 */
522 public static function cleanup_temp_caches(): void {
523 foreach ( array_keys( self::$temp_product_categories ) as $temp_id ) {
524 wp_cache_delete( $temp_id, 'posts' );
525 wp_cache_delete( $temp_id, 'product_cat_relationships' );
526 }
527 self::$temp_product_categories = array();
528 self::$temp_id_counter = 0;
529 }
530
531 /**
532 * Determine whether an order item should be treated as "on sale" in coupon checks.
533 *
534 * @param WC_Order_Item_Product $item Order item.
535 * @param WC_Product|null $product Product context (optional).
536 *
537 * @return bool
538 */
539 private function is_pos_discounted_item_on_sale( WC_Order_Item_Product $item, ?WC_Product $product = null ): bool {
540 $pos_data = $this->get_pos_item_data( $item );
541 if ( null === $pos_data || ! isset( $pos_data['price'], $pos_data['regular_price'] ) ) {
542 return false;
543 }
544
545 $is_on_sale = (float) $pos_data['price'] < (float) $pos_data['regular_price'];
546 $product = $product ? $product : $item->get_product();
547
548 return (bool) apply_filters(
549 'woocommerce_pos_item_is_on_sale',
550 $is_on_sale,
551 $product,
552 $item,
553 $pos_data
554 );
555 }
556
557 /**
558 * Decode _woocommerce_pos_data from an order item.
559 *
560 * @param WC_Order_Item_Product $item Order item.
561 *
562 * @return array<string, mixed>|null
563 */
564 private function get_pos_item_data( WC_Order_Item_Product $item ): ?array {
565 $pos_data_json = $item->get_meta( '_woocommerce_pos_data', true );
566 if ( empty( $pos_data_json ) ) {
567 return null;
568 }
569
570 return \WCPOS\WooCommercePOS\Sync\Meta_Normalizer::decode_to_array( $pos_data_json );
571 }
572
573 /**
574 * Get tax location for this order.
575 *
576 * @param array $args Override the location.
577 * @param WC_Abstract_Order $order The order object.
578 *
579 * @return array
580 */
581 public function get_tax_location( $args, WC_Abstract_Order $order ) {
582 if ( ! woocommerce_pos_is_pos_order( $order ) ) {
583 return $args;
584 }
585
586 $tax_based_on = $order->get_meta( '_woocommerce_pos_tax_based_on' );
587
588 if ( $order instanceof WC_Order ) {
589 if ( 'billing' == $tax_based_on ) {
590 $args['country'] = $order->get_billing_country();
591 $args['state'] = $order->get_billing_state();
592 $args['postcode'] = $order->get_billing_postcode();
593 $args['city'] = $order->get_billing_city();
594 } elseif ( 'shipping' == $tax_based_on ) {
595 $args['country'] = $order->get_shipping_country();
596 $args['state'] = $order->get_shipping_state();
597 $args['postcode'] = $order->get_shipping_postcode();
598 $args['city'] = $order->get_shipping_city();
599 } else {
600 $args['country'] = WC()->countries->get_base_country();
601 $args['state'] = WC()->countries->get_base_state();
602 $args['postcode'] = WC()->countries->get_base_postcode();
603 $args['city'] = WC()->countries->get_base_city();
604 }
605 }
606
607 return $args;
608 }
609
610 /**
611 * Respect a negative fee line's own tax_status and tax_class on POS-marked requests.
612 *
613 * WooCommerce routes negative fees through its discount tax path, disregarding the
614 * fee's tax_status and tax_class and allocating line-item tax rates proportionally
615 * instead. The v1 controller corrected this per-dispatch (issue #1403 row 2); this
616 * global, request-gated registration serves both the v1 routes and the v2 push's
617 * inner wc/v3 forward (which carries the X-WCPOS header) with one implementation.
618 * Static so V1\Orders_Controller can delegate without constructing the service.
619 *
620 * @param \WC_Order_Item_Fee $fee_item The fee item.
621 * @param array $calculate_tax_for The tax calculation location data.
622 *
623 * @return void
624 */
625 public static function fee_after_calculate_taxes( $fee_item, $calculate_tax_for ): void {
626 if ( $fee_item->get_total() >= 0 ) {
627 return;
628 }
629
630 // Gate on the ORDER being a POS order (durable — survives wp-admin
631 // Recalculate, bulk actions, and third-party recalculations), with the
632 // POS request marker only as the supplement for the creation moment,
633 // before the order is marked. A per-request-only gate silently flipped a
634 // POS order's fee tax whenever a non-POS caller recalculated it.
635 //
636 // STOPGAP (2026-08-06 ruling): this preserves the existing POS fee-tax
637 // semantics consistently, but the semantics themselves are slated for
638 // replacement — negative fees are disowned by WooCommerce and the
639 // override over-declares VAT on tax-inclusive stores. The plan of record
640 // is migrating till discounts to virtual percent coupons; see
641 // .claude/research/2026-08-06-wc-negative-fee-tax.md.
642 // wcpos_is_pos_order() safely returns false for any non-order input.
643 if ( ! wcpos_is_pos_order( $fee_item->get_order() ) && ! wcpos_request() ) {
644 return;
645 }
646
647 if ( 'taxable' === $fee_item->get_tax_status() ) {
648 // Use the fee's own tax_class if set, otherwise the default class.
649 $tax_class = $fee_item->get_tax_class();
650 $calculate_tax_for['tax_class'] = $tax_class ? $tax_class : '';
651
652 $tax_rates = WC_Tax::find_rates( $calculate_tax_for );
653 $discount_taxes = WC_Tax::calc_tax( (float) $fee_item->get_total(), $tax_rates );
654
655 $fee_item->set_taxes( array( 'total' => $discount_taxes ) );
656 } else {
657 // Clear taxes entirely when the fee's tax_status is 'none'.
658 $fee_item->set_taxes( array() );
659 }
660
661 $fee_item->save();
662 }
663
664 /**
665 * Calculate taxes for an order item.
666 *
667 * @param WC_Order_Item|WC_Order_Item_Shipping $item Order item object.
668 *
669 * @return void
670 */
671 public function order_item_after_calculate_taxes( $item ): void {
672 $meta_data = $item->get_meta_data();
673
674 foreach ( $meta_data as $meta ) {
675 if ( '_woocommerce_pos_data' === $meta->key ) {
676 $pos_data = \WCPOS\WooCommercePOS\Sync\Meta_Normalizer::decode_to_array( $meta->value );
677
678 if ( null !== $pos_data ) {
679 if ( isset( $pos_data['tax_status'] ) && 'none' == $pos_data['tax_status'] ) {
680 $item->set_taxes( false );
681 }
682 } else {
683 Logger::log( 'Unreadable _woocommerce_pos_data meta value on order item.' );
684 }
685
686 break;
687 }
688 }
689 }
690
691 /**
692 * Register the POS order statuses.
693 */
694 private function register_order_status(): void {
695 // Order status for open orders.
696 register_post_status(
697 'wc-pos-open',
698 array(
699 'label' => /* translators: POS order status label. */ _x( 'POS - Open', 'Order status', 'woocommerce-pos' ),
700 'public' => true,
701 'exclude_from_search' => false,
702 'show_in_admin_all_list' => true,
703 'show_in_admin_status_list' => true,
704 // translators: %s is the number of orders with POS - Open status.
705 'label_count' => _n_noop(
706 'POS - Open <span class="count">(%s)</span>',
707 'POS - Open <span class="count">(%s)</span>',
708 'woocommerce-pos'
709 ),
710 )
711 );
712
713 // Order status for partial payment orders.
714 register_post_status(
715 'wc-pos-partial',
716 array(
717 'label' => _x( 'POS - Partial Payment', 'Order status', 'woocommerce-pos' ),
718 'public' => true,
719 'exclude_from_search' => false,
720 'show_in_admin_all_list' => true,
721 'show_in_admin_status_list' => true,
722 // translators: %s is the number of orders with POS - Partial Payment status.
723 'label_count' => _n_noop(
724 'POS - Partial Payment <span class="count">(%s)</span>',
725 'POS - Partial Payment <span class="count">(%s)</span>',
726 'woocommerce-pos'
727 ),
728 )
729 );
730 }
731
732 /**
733 * Set SKU on a synthetic product, bypassing WooCommerce's uniqueness check.
734 *
735 * Synthetic products (product_id=0) are never saved to the database, so
736 * SKU collisions with real products are irrelevant. Temporarily disabling
737 * object_read causes set_sku() to skip the wc_product_has_unique_sku() call.
738 *
739 * @param WC_Product_Simple $product Synthetic product instance.
740 * @param string $sku SKU value from order-item meta.
741 */
742 private function set_synthetic_product_sku( WC_Product_Simple $product, string $sku ): void {
743 $product->set_object_read( false );
744 $product->set_sku( $sku );
745 $product->set_object_read( true );
746 }
747 }
748