| @@ -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 | } |