PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.20
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.20
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 1.9.15 1.9.16 All 164 releases
← All changes | includes/Orders.php +257 -44 1.9.16 → 1.10.20 View file →
@@ -61,13 +61,15 @@
61 61 add_filter( 'woocommerce_payment_complete_order_status', array( $this, 'payment_complete_order_status' ), 10, 3 );
62 62 add_filter( 'woocommerce_bacs_process_payment_order_status', array( $this, 'offline_process_payment_order_status' ), 10, 2 );
63 63 add_filter( 'woocommerce_cheque_process_payment_order_status', array( $this, 'offline_process_payment_order_status' ), 10, 2 );
64 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 );
65 66 add_filter( 'woocommerce_hidden_order_itemmeta', array( $this, 'hidden_order_itemmeta' ) );
66 67 add_filter( 'woocommerce_order_item_product', array( $this, 'order_item_product' ), 10, 2 );
67 68 add_filter( 'woocommerce_order_get_tax_location', array( $this, 'get_tax_location' ), 10, 2 );
68 69 add_action( 'woocommerce_order_item_after_calculate_taxes', array( $this, 'order_item_after_calculate_taxes' ) );
69 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 );
70 72 add_filter( 'woocommerce_coupon_get_items_to_validate', array( $this, 'coupon_get_items_to_validate' ), 10, 2 );
71 73 add_filter( 'woocommerce_coupon_is_valid_for_product', array( $this, 'coupon_is_valid_for_product' ), 10, 4 );
72 74 add_action( 'woocommerce_order_after_calculate_totals', array( __CLASS__, 'cleanup_temp_caches' ), 999 );
73 75 }
@@ -137,8 +139,10 @@
137 139 }
138 140
139 141 /**
140 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.
141 145 *
142 146 * @param string $status Order status.
143 147 * @param int $id Order ID.
144 148 * @param WC_Abstract_Order $order The order object.
@@ -145,32 +149,10 @@
145 149 *
146 150 * @return string
147 151 */
148 152 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;
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 );
173 155 }
174 156
175 157 return $status;
176 158 }
@@ -195,16 +177,31 @@
195 177 if ( ! woocommerce_pos_is_pos_order( $order ) ) {
196 178 return $status;
197 179 }
198 180
199 - $gateway_order_status = $this->get_gateway_order_status( $order->get_payment_method() );
181 + return $this->normalize_status( $this->get_gateway_order_status( $order->get_payment_method() ), $status );
182 + }
200 183
201 - $normalized_status = 0 === strpos( $gateway_order_status, 'wc-' )
202 - ? substr( $gateway_order_status, 3 )
203 - : $gateway_order_status;
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;
204 201
205 202 if ( '' === $normalized_status ) {
206 - return $status;
203 + return $fallback;
207 204 }
208 205
209 206 $valid_statuses = array_map(
210 207 function ( string $order_status ): string {
@@ -216,12 +213,177 @@
216 213 );
217 214
218 215 return \in_array( $normalized_status, $valid_statuses, true )
219 216 ? $normalized_status
220 - : $status;
217 + : $fallback;
221 218 }
222 219
223 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 + /**
224 386 * Resolve the configured POS order status for a given payment gateway.
225 387 *
226 388 * Looks up the per-gateway order_status from payment_gateways settings.
227 389 * Falls back to 'wc-completed' if no setting is found.
@@ -281,11 +443,13 @@
281 443 $this->set_synthetic_product_sku( $product, $sku );
282 444 }
283 445
284 446 // 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 ) ) {
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 ) ) {
288 452 if ( isset( $pos_data['price'] ) ) {
289 453 $product->set_price( $pos_data['price'] );
290 454 }
291 455 if ( isset( $pos_data['regular_price'] ) ) {
@@ -316,10 +480,10 @@
316 480 if ( ! $product || empty( $pos_data_json ) ) {
317 481 return $product;
318 482 }
319 483
320 - $pos_data = json_decode( $pos_data_json, true );
321 - if ( JSON_ERROR_NONE !== json_last_error() || ! \is_array( $pos_data ) ) {
484 + $pos_data = \WCPOS\WooCommercePOS\Sync\Meta_Normalizer::decode_to_array( $pos_data_json );
485 + if ( ! \is_array( $pos_data ) ) {
322 486 return $product;
323 487 }
324 488
325 489 // Use an isolated product instance for coupon-specific context.
@@ -568,14 +732,9 @@
568 732 if ( empty( $pos_data_json ) ) {
569 733 return null;
570 734 }
571 735
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;
736 + return \WCPOS\WooCommercePOS\Sync\Meta_Normalizer::decode_to_array( $pos_data_json );
578 737 }
579 738
580 739 /**
581 740 * Get tax location for this order.
@@ -614,8 +773,62 @@
614 773 return $args;
615 774 }
616 775
617 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 + /**
618 831 * Calculate taxes for an order item.
619 832 *
620 833 * @param WC_Order_Item|WC_Order_Item_Shipping $item Order item object.
621 834 *
@@ -625,16 +838,16 @@
625 838 $meta_data = $item->get_meta_data();
626 839
627 840 foreach ( $meta_data as $meta ) {
628 841 if ( '_woocommerce_pos_data' === $meta->key ) {
629 - $pos_data = json_decode( $meta->value, true );
842 + $pos_data = \WCPOS\WooCommercePOS\Sync\Meta_Normalizer::decode_to_array( $meta->value );
630 843
631 - if ( JSON_ERROR_NONE === json_last_error() ) {
844 + if ( null !== $pos_data ) {
632 845 if ( isset( $pos_data['tax_status'] ) && 'none' == $pos_data['tax_status'] ) {
633 846 $item->set_taxes( false );
634 847 }
635 848 } else {
636 - Logger::log( 'JSON parse error: ' . json_last_error_msg() );
849 + Logger::log( 'Unreadable _woocommerce_pos_data meta value on order item.' );
637 850 }
638 851
639 852 break;
640 853 }