PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.19
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.19
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 1.9.15 1.9.16 1.9.14 All 163 releases
← All changes | includes/Orders.php +91 -44 1.9.161.10.19 View file →
@@ -66,8 +66,9 @@
66 66 add_filter( 'woocommerce_order_item_product', array( $this, 'order_item_product' ), 10, 2 );
67 67 add_filter( 'woocommerce_order_get_tax_location', array( $this, 'get_tax_location' ), 10, 2 );
68 68 add_action( 'woocommerce_order_item_after_calculate_taxes', array( $this, 'order_item_after_calculate_taxes' ) );
69 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 );
70 71 add_filter( 'woocommerce_coupon_get_items_to_validate', array( $this, 'coupon_get_items_to_validate' ), 10, 2 );
71 72 add_filter( 'woocommerce_coupon_is_valid_for_product', array( $this, 'coupon_is_valid_for_product' ), 10, 4 );
72 73 add_action( 'woocommerce_order_after_calculate_totals', array( __CLASS__, 'cleanup_temp_caches' ), 999 );
73 74 }
@@ -137,8 +138,10 @@
137 138 }
138 139
139 140 /**
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.
141 144 *
142 145 * @param string $status Order status.
143 146 * @param int $id Order ID.
144 147 * @param WC_Abstract_Order $order The order object.
@@ -145,32 +148,10 @@
145 148 *
146 149 * @return string
147 150 */
148 151 public function payment_complete_order_status( string $status, int $id, WC_Abstract_Order $order ): string {
149 - if ( woocommerce_pos_request() ) {
150 - $gateway_status = $this->get_gateway_order_status( $order->get_payment_method() );
151 -
152 - // This filter expects statuses without the 'wc-' prefix.
153 - $normalized_status = 0 === strpos( $gateway_status, 'wc-' )
154 - ? substr( $gateway_status, 3 )
155 - : $gateway_status;
156 -
157 - if ( '' === $normalized_status ) {
158 - return $status;
159 - }
160 -
161 - $valid_statuses = array_map(
162 - function ( string $order_status ): string {
163 - return 0 === strpos( $order_status, 'wc-' )
164 - ? substr( $order_status, 3 )
165 - : $order_status;
166 - },
167 - array_keys( wc_get_order_statuses() )
168 - );
169 -
170 - return \in_array( $normalized_status, $valid_statuses, true )
171 - ? $normalized_status
172 - : $status;
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 );
173 154 }
174 155
175 156 return $status;
176 157 }
@@ -195,16 +176,31 @@
195 176 if ( ! woocommerce_pos_is_pos_order( $order ) ) {
196 177 return $status;
197 178 }
198 179
199 - $gateway_order_status = $this->get_gateway_order_status( $order->get_payment_method() );
180 + return $this->normalize_status( $this->get_gateway_order_status( $order->get_payment_method() ), $status );
181 + }
200 182
201 - $normalized_status = 0 === strpos( $gateway_order_status, 'wc-' )
202 - ? substr( $gateway_order_status, 3 )
203 - : $gateway_order_status;
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;
204 200
205 201 if ( '' === $normalized_status ) {
206 - return $status;
202 + return $fallback;
207 203 }
208 204
209 205 $valid_statuses = array_map(
210 206 function ( string $order_status ): string {
@@ -216,9 +212,9 @@
216 212 );
217 213
218 214 return \in_array( $normalized_status, $valid_statuses, true )
219 215 ? $normalized_status
220 - : $status;
216 + : $fallback;
221 217 }
222 218
223 219 /**
224 220 * Resolve the configured POS order status for a given payment gateway.
@@ -281,11 +277,13 @@
281 277 $this->set_synthetic_product_sku( $product, $sku );
282 278 }
283 279
284 280 // Misc products are synthetic and never persisted to DB, so we can
285 - // safely apply POS price context directly.
286 - $pos_data = json_decode( $pos_data_json, true );
287 - if ( JSON_ERROR_NONE === json_last_error() && \is_array( $pos_data ) ) {
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 ) ) {
288 286 if ( isset( $pos_data['price'] ) ) {
289 287 $product->set_price( $pos_data['price'] );
290 288 }
291 289 if ( isset( $pos_data['regular_price'] ) ) {
@@ -316,10 +314,10 @@
316 314 if ( ! $product || empty( $pos_data_json ) ) {
317 315 return $product;
318 316 }
319 317
320 - $pos_data = json_decode( $pos_data_json, true );
321 - if ( JSON_ERROR_NONE !== json_last_error() || ! \is_array( $pos_data ) ) {
318 + $pos_data = \WCPOS\WooCommercePOS\Sync\Meta_Normalizer::decode_to_array( $pos_data_json );
319 + if ( ! \is_array( $pos_data ) ) {
322 320 return $product;
323 321 }
324 322
325 323 // Use an isolated product instance for coupon-specific context.
@@ -568,14 +566,9 @@
568 566 if ( empty( $pos_data_json ) ) {
569 567 return null;
570 568 }
571 569
572 - $pos_data = json_decode( $pos_data_json, true );
573 - if ( JSON_ERROR_NONE !== json_last_error() || ! \is_array( $pos_data ) ) {
574 - return null;
575 - }
576 -
577 - return $pos_data;
570 + return \WCPOS\WooCommercePOS\Sync\Meta_Normalizer::decode_to_array( $pos_data_json );
578 571 }
579 572
580 573 /**
581 574 * Get tax location for this order.
@@ -614,8 +607,62 @@
614 607 return $args;
615 608 }
616 609
617 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 + /**
618 665 * Calculate taxes for an order item.
619 666 *
620 667 * @param WC_Order_Item|WC_Order_Item_Shipping $item Order item object.
621 668 *
@@ -625,16 +672,16 @@
625 672 $meta_data = $item->get_meta_data();
626 673
627 674 foreach ( $meta_data as $meta ) {
628 675 if ( '_woocommerce_pos_data' === $meta->key ) {
629 - $pos_data = json_decode( $meta->value, true );
676 + $pos_data = \WCPOS\WooCommercePOS\Sync\Meta_Normalizer::decode_to_array( $meta->value );
630 677
631 - if ( JSON_ERROR_NONE === json_last_error() ) {
678 + if ( null !== $pos_data ) {
632 679 if ( isset( $pos_data['tax_status'] ) && 'none' == $pos_data['tax_status'] ) {
633 680 $item->set_taxes( false );
634 681 }
635 682 } else {
636 - Logger::log( 'JSON parse error: ' . json_last_error_msg() );
683 + Logger::log( 'Unreadable _woocommerce_pos_data meta value on order item.' );
637 684 }
638 685
639 686 break;
640 687 }