| @@ -32,9 +32,12 @@ | ||
| 32 | 32 | const DEFAULT_MIN_USD_SUBTOTAL_ALLOWED = 0; |
| 33 | 33 | const ROUTE_LABEL = 'Route Shipping Protection'; |
| 34 | 34 | const DEFAULT_CHECKOUT_WEBHOOK = 'woocommerce_checkout_before_order_review'; |
| 35 | 35 | const ROUTE_PROTECTION_SKU = 'ROUTEINS'; |
| 36 | + const ROUTE_FEE_ITEM_META = '_routeapp_protection_fee'; | |
| 37 | + const ROUTE_FEE_SKU_META = '_routeapp_protection_sku'; | |
| 36 | 38 | const ALLOWED_QUOTE_TYPE = 'paid_by_customer'; |
| 39 | + const PICKUP_SHIPPING_METHOD_IDS = array( 'local_pickup', 'pickup_location' ); | |
| 37 | 40 | |
| 38 | 41 | const AMPLITUDE_API_KEY = [ |
| 39 | 42 | 'production' => '6d4f9158886e0c6d78e3bd97663ba920', |
| 40 | 43 | 'stage' => '99a869d62986f6415610c6741057bc92', |
| @@ -125,11 +128,8 @@ | ||
| 125 | 128 | //CHECKOUT HOOK |
| 126 | 129 | $checkout_hook = $this->routeapp_get_checkout_hook(); |
| 127 | 130 | add_action($checkout_hook, array($this, 'checkout_route_insurance'), 1, 0); |
| 128 | 131 | |
| 129 | - | |
| 130 | - add_action('woocommerce_checkout_before_order_review', array($this, 'checkout_route_insurance_fee'), 20, 1); | |
| 131 | - | |
| 132 | 132 | add_action('woocommerce_cart_calculate_fees', array($this, 'checkout_route_insurance_fee'), 20, 1); |
| 133 | 133 | add_action('wp_ajax_woo_get_ajax_data', array($this, 'checkout_route_insurance_set_session')); |
| 134 | 134 | add_action('wp_ajax_nopriv_woo_get_ajax_data', array($this, 'checkout_route_insurance_set_session')); |
| 135 | 135 | |
| @@ -136,8 +136,9 @@ | ||
| 136 | 136 | add_action('wp_ajax_woo_check_widget', array($this, 'check_route_widget')); |
| 137 | 137 | add_action('wp_ajax_nopriv_woo_check_widget', array($this, 'check_route_widget')); |
| 138 | 138 | |
| 139 | 139 | add_action( 'woocommerce_checkout_create_order_line_item', [ $this, 'routeapp_checkout_create_order_line_item' ], 10, 4 ); |
| 140 | + add_action( 'woocommerce_checkout_create_order_fee_item', [ $this, 'routeapp_checkout_create_order_fee_item' ], 10, 4 ); | |
| 140 | 141 | add_action( 'woocommerce_ajax_add_order_item_meta', [$this, 'routeapp_ajax_add_order_item_meta'], 10, 2 ); |
| 141 | 142 | add_filter( 'woocommerce_hidden_order_itemmeta', [$this,'routeapp_woocommerce_hidden_order_itemmeta'], 10, 1); |
| 142 | 143 | |
| 143 | 144 | add_action( 'added_post_meta', array( $this, 'routeapp_update_tracking_order_api' ), 20, 3 ); |
| @@ -164,10 +165,14 @@ | ||
| 164 | 165 | add_action('wp_ajax_routeapp_remove_admin_fee', array($this, 'routeapp_remove_admin_fee')); |
| 165 | 166 | add_action('wp_ajax_nopriv_routeapp_remove_admin_fee', array($this, 'routeapp_remove_admin_fee')); |
| 166 | 167 | add_action('wp_ajax_routeapp_refresh_admin_fee_button', array($this, 'routeapp_refresh_admin_fee_button')); |
| 167 | 168 | add_action('wc_avatax_api_fee_line_data', array($this, 'routeapp_edit_avatax_fee')); |
| 168 | - add_action('woocommerce_shipstation_export_order_xml', array($this, 'routeapp_add_sku_to_route_node') ); | |
| 169 | 169 | |
| 170 | + // ShipStation: exclude Route fee on XML (legacy) and REST (4.9+) export paths. | |
| 171 | + add_filter('woocommerce_shipstation_no_shipping_item', array($this, 'routeapp_exclude_route_from_shipstation'), 10, 3); | |
| 172 | + add_filter('woocommerce_shipstation_fulfillment_item', array($this, 'routeapp_strip_route_from_shipstation_fulfillment_item'), 10, 4); | |
| 173 | + add_filter('woocommerce_shipstation_export_order_xml', array($this, 'routeapp_strip_route_from_shipstation_xml'), 10, 1); | |
| 174 | + | |
| 170 | 175 | add_action('woocommerce_order_details_after_order_table', array($this, 'order_updates_route_tracking'), 1, 1); |
| 171 | 176 | |
| 172 | 177 | add_action('woocommerce_blocks_checkout_update_order_meta', array($this, 'routeapp_save_order_status_to_order'), 10, 1); |
| 173 | 178 | add_action('woocommerce_store_api_checkout_update_order_meta', array($this, 'routeapp_save_order_status_to_order'), 10, 1); |
| @@ -201,8 +206,10 @@ | ||
| 201 | 206 | $arr[] = '_routeapp_virtual'; |
| 202 | 207 | $arr[] = '_routeapp_weight_value'; |
| 203 | 208 | $arr[] = '_routeapp_weight_unit'; |
| 204 | 209 | $arr[] = '_routeapp_origin_location'; |
| 210 | + $arr[] = self::ROUTE_FEE_ITEM_META; | |
| 211 | + $arr[] = self::ROUTE_FEE_SKU_META; | |
| 205 | 212 | return $arr; |
| 206 | 213 | } |
| 207 | 214 | |
| 208 | 215 | /** |
| @@ -349,8 +356,35 @@ | ||
| 349 | 356 | } |
| 350 | 357 | } |
| 351 | 358 | } |
| 352 | 359 | |
| 360 | + /** | |
| 361 | + * Tag the Route protection fee on checkout so exporters can identify it | |
| 362 | + * without relying on a translated fee name. | |
| 363 | + * | |
| 364 | + * Accepted limitation: WC cart fees have no SKU/meta, so we match by name | |
| 365 | + * here. That is reliable at creation time because Route just set the fee | |
| 366 | + * name via routeapp_get_insurance_label() in the same request/locale. | |
| 367 | + * Once marked, later lookups use ROUTE_FEE_ITEM_META / ROUTE_FEE_SKU_META. | |
| 368 | + * | |
| 369 | + * @param WC_Order_Item_Fee $item Fee order item. | |
| 370 | + * @param string $fee_key Cart fee key. | |
| 371 | + * @param object $fee Cart fee object. | |
| 372 | + * @param WC_Order $order Order being created. | |
| 373 | + */ | |
| 374 | + public function routeapp_checkout_create_order_fee_item( $item, $fee_key, $fee, $order ) { | |
| 375 | + $fee_name = ''; | |
| 376 | + if ( is_object( $fee ) && isset( $fee->name ) ) { | |
| 377 | + $fee_name = $fee->name; | |
| 378 | + } elseif ( is_callable( array( $item, 'get_name' ) ) ) { | |
| 379 | + $fee_name = $item->get_name(); | |
| 380 | + } | |
| 381 | + | |
| 382 | + if ( self::routeapp_is_route_protection_fee_name( $fee_name ) ) { | |
| 383 | + self::routeapp_mark_protection_fee_item( $item ); | |
| 384 | + } | |
| 385 | + } | |
| 386 | + | |
| 353 | 387 | public function routeapp_add_admin_fee() { |
| 354 | 388 | $this->routeapp_admin_fee->routeapp_add_admin_fee(); |
| 355 | 389 | } |
| 356 | 390 | |
| @@ -395,20 +429,24 @@ | ||
| 395 | 429 | } |
| 396 | 430 | } |
| 397 | 431 | |
| 398 | 432 | /** |
| 399 | - * Add route insurance fee in checkout and cart | |
| 433 | + * Add route insurance fee in checkout and cart. | |
| 434 | + * Only runs during woocommerce_cart_calculate_fees. The old | |
| 435 | + * woocommerce_checkout_before_order_review binding is gone — it fired | |
| 436 | + * after totals were already calculated and re-added the fee after opt-out. | |
| 437 | + * | |
| 400 | 438 | * @since 1.0.0 |
| 401 | 439 | * @param Object $cart_or_checkout |
| 402 | - * MSS-6261:The issue: checkout_route_insurance_fee is bound to hooks that pass different parameter types: | |
| 403 | - * woocommerce_checkout_before_order_review -> WC_Checkout | |
| 404 | - * woocommerce_cart_calculate_fees -> WC_Cart | |
| 405 | - * At line 388, it assumes a WC_Cart and calls get_cart_hash(), which fails when a WC_Checkout is passed. | |
| 406 | - * Fixing by detecting the object type and handling both cases. | |
| 407 | - */ | |
| 440 | + */ | |
| 408 | 441 | public function checkout_route_insurance_fee( $cart_or_checkout ) { |
| 409 | 442 | if ( (is_admin() && ! defined( 'DOING_AJAX' )) ) return; |
| 410 | 443 | |
| 444 | + // Widget AJAX / fragment hooks must not add fees (re-adds after opt-out, races coupons). | |
| 445 | + if ( function_exists( 'doing_action' ) && ! doing_action( 'woocommerce_cart_calculate_fees' ) ) { | |
| 446 | + return; | |
| 447 | + } | |
| 448 | + | |
| 411 | 449 | // Determine if we received a WC_Cart or WC_Checkout object |
| 412 | 450 | $is_checkout_object = is_a( $cart_or_checkout, 'WC_Checkout' ); |
| 413 | 451 | $cart = $is_checkout_object ? WC()->cart : $cart_or_checkout; |
| 414 | 452 | |
| @@ -439,10 +477,20 @@ | ||
| 439 | 477 | $checkbox = $route_insurance_quote->payment_responsible->ToggleState; |
| 440 | 478 | WC()->session->set('checkbox_checked', $checkbox); |
| 441 | 479 | } |
| 442 | 480 | |
| 481 | + // First checkout paint still defaults ON when ToggleState is missing | |
| 482 | + // (pre-existing behavior). Do not default ON during checkout AJAX — | |
| 483 | + // that re-added the fee after opt-out when the session was empty. | |
| 484 | + if ( $this->routeapp_should_default_fee_opt_in( $checkbox ) ) { | |
| 485 | + $checkbox = true; | |
| 486 | + if ( WC()->session ) { | |
| 487 | + WC()->session->set( 'checkbox_checked', true ); | |
| 488 | + } | |
| 489 | + } | |
| 490 | + | |
| 443 | 491 | Route_Setup::set_setup_check_widget(Route_Setup::SETUP_CHECK_WIDGET_PHP); |
| 444 | - if ( (is_null($checkbox) && is_checkout() || ($checkbox === TRUE || $checkbox === 1)) && $route_insurance_amount > 0 && $this->routeapp_is_shipping_method_allowed()) { | |
| 492 | + if ( ($checkbox === TRUE || $checkbox === 1) && $route_insurance_amount > 0 && $this->routeapp_is_shipping_method_allowed()) { | |
| 445 | 493 | $cart->add_fee( $this->routeapp_get_insurance_label(), |
| 446 | 494 | $route_insurance_amount, |
| 447 | 495 | $this->routeapp_get_fee_taxable(), |
| 448 | 496 | $this->routeapp_get_taxable_class()); |
| @@ -449,8 +497,35 @@ | ||
| 449 | 497 | } |
| 450 | 498 | } |
| 451 | 499 | |
| 452 | 500 | /** |
| 501 | + * WooCommerce / CheckoutWC totals refresh (update_order_review, etc.). | |
| 502 | + * | |
| 503 | + * @return bool | |
| 504 | + */ | |
| 505 | + public function routeapp_is_checkout_ajax_refresh() { | |
| 506 | + if ( ! empty( $_REQUEST['wc-ajax'] ) ) { | |
| 507 | + return true; | |
| 508 | + } | |
| 509 | + | |
| 510 | + return defined( 'DOING_AJAX' ) && DOING_AJAX; | |
| 511 | + } | |
| 512 | + | |
| 513 | + /** | |
| 514 | + * Keep the pre-existing first-load default (fee ON) when there is no | |
| 515 | + * session choice and no ToggleState. Skip that default on AJAX refreshes. | |
| 516 | + * | |
| 517 | + * @param mixed $checkbox Session checkbox value. | |
| 518 | + * @return bool | |
| 519 | + */ | |
| 520 | + public function routeapp_should_default_fee_opt_in( $checkbox ) { | |
| 521 | + return is_null( $checkbox ) | |
| 522 | + && function_exists( 'is_checkout' ) | |
| 523 | + && is_checkout() | |
| 524 | + && ! $this->routeapp_is_checkout_ajax_refresh(); | |
| 525 | + } | |
| 526 | + | |
| 527 | + /** | |
| 453 | 528 | * Create checkout |
| 454 | 529 | * @since 1.0.0 |
| 455 | 530 | * |
| 456 | 531 | */ |
| @@ -595,8 +670,16 @@ | ||
| 595 | 670 | 'merchantId' => $this->routeapp_api_client->get_merchant_id(), |
| 596 | 671 | ) |
| 597 | 672 | ); |
| 598 | 673 | |
| 674 | + wp_localize_script( | |
| 675 | + $this->plugin_name, | |
| 676 | + 'routeappWidgetConfig', | |
| 677 | + array( | |
| 678 | + 'excludedShippingMethods' => array_values( (array) $this->routeapp_get_excluded_shipping_methods() ), | |
| 679 | + ) | |
| 680 | + ); | |
| 681 | + | |
| 599 | 682 | wp_enqueue_style( |
| 600 | 683 | $this->plugin_name . '-widget', |
| 601 | 684 | plugin_dir_url( __FILE__ ) . 'css/routeapp-widget.css', |
| 602 | 685 | array(), |
| @@ -658,17 +741,11 @@ | ||
| 658 | 741 | '<input type="hidden" value="' . $this->routeapp_api_client->get_merchant_id() . '" class="routeapp-merchant-id">'. |
| 659 | 742 | '<input type="hidden" value="' . $custom_env . '" class="routeapp-env">'. |
| 660 | 743 | '<input type="hidden" value="' . $checkbox .'" class="routeapp-checkbox-cookie">'; |
| 661 | 744 | |
| 662 | - //check if shipping method is allowed | |
| 663 | - $cart = WC()->cart; | |
| 664 | - $shipping_method = $cart->calculate_shipping(); | |
| 665 | - if (isset($shipping_method[0]->method_id)) { | |
| 666 | - $shipping_method = $shipping_method[0]->method_id; | |
| 667 | - $show_widget = $this->routeapp_is_shipping_method_allowed($shipping_method); | |
| 668 | - if (!$show_widget) { | |
| 669 | - $return .= '<input type="hidden" value="true" class="routeapp-invalid-shipping-method">'; | |
| 670 | - } | |
| 745 | + $chosen_methods = ( WC()->session ) ? WC()->session->get( 'chosen_shipping_methods' ) : array(); | |
| 746 | + if ( ! empty( $chosen_methods[0] ) && ! $this->routeapp_is_shipping_method_allowed( $chosen_methods[0] ) ) { | |
| 747 | + $return .= '<input type="hidden" value="true" class="routeapp-invalid-shipping-method">'; | |
| 671 | 748 | } |
| 672 | 749 | return $return . '<div id="RouteWidget" class="route-div"></div></div>' ; |
| 673 | 750 | } |
| 674 | 751 | |
| @@ -728,9 +805,9 @@ | ||
| 728 | 805 | $insurance_selected = false; |
| 729 | 806 | $insurance_amount = false; |
| 730 | 807 | |
| 731 | 808 | foreach ($order->get_items('fee') as $fee) { |
| 732 | - if ($fee->get_name() === $this->routeapp_get_insurance_label()) { | |
| 809 | + if (self::routeapp_is_route_protection_fee($fee)) { | |
| 733 | 810 | $insurance_selected = true; |
| 734 | 811 | $insurance_amount = $fee->get_amount(); |
| 735 | 812 | if ( class_exists('Automattic\WooCommerce\Utilities\OrderUtil') |
| 736 | 813 | && OrderUtil::custom_orders_table_usage_is_enabled() ) { |
| @@ -1011,9 +1088,9 @@ | ||
| 1011 | 1088 | |
| 1012 | 1089 | $fees = $order->get_items('fee'); |
| 1013 | 1090 | if (!empty($fees)) { |
| 1014 | 1091 | foreach ($fees as $fee) { |
| 1015 | - if ($fee->get_name() === $this->routeapp_get_insurance_label()) { | |
| 1092 | + if (self::routeapp_is_route_protection_fee($fee)) { | |
| 1016 | 1093 | $insurance_amount = $fee->get_amount(); |
| 1017 | 1094 | $updated |= $this->updateRouteOrderMetaData( $order, $is_hpos, '_routeapp_route_charge', $insurance_amount ); |
| 1018 | 1095 | $updated |= $this->updateRouteOrderMetaData( $order, $is_hpos, '_routeapp_route_protection', true ); |
| 1019 | 1096 | } |
| @@ -1025,9 +1102,9 @@ | ||
| 1025 | 1102 | // Fallback to WC()->cart->get_fees() if no fees are present in the order |
| 1026 | 1103 | $cart_fees = WC()->cart->get_fees(); |
| 1027 | 1104 | if (!empty($cart_fees)) { |
| 1028 | 1105 | foreach ($cart_fees as $cart_fee) { |
| 1029 | - if ($cart_fee->name === $this->routeapp_get_insurance_label()) { | |
| 1106 | + if (self::routeapp_is_route_protection_fee_name($cart_fee->name)) { | |
| 1030 | 1107 | $insurance_amount = $cart_fee->amount; // Correct method for cart fee amount |
| 1031 | 1108 | $updated |= $this->updateRouteOrderMetaData( $order, $is_hpos, '_routeapp_route_charge', $insurance_amount ); |
| 1032 | 1109 | $updated |= $this->updateRouteOrderMetaData( $order, $is_hpos, '_routeapp_route_protection', true ); |
| 1033 | 1110 | } |
| @@ -1274,10 +1351,9 @@ | ||
| 1274 | 1351 | } else { |
| 1275 | 1352 | return false; |
| 1276 | 1353 | } |
| 1277 | 1354 | } |
| 1278 | - $excludedShippingMethods = $this->routeapp_get_excluded_shipping_methods(); | |
| 1279 | - if (is_array($excludedShippingMethods) && in_array($shipping_method, $excludedShippingMethods)) { | |
| 1355 | + if ($this->routeapp_is_shipping_method_excluded($shipping_method)) { | |
| 1280 | 1356 | return false; |
| 1281 | 1357 | } |
| 1282 | 1358 | } else { |
| 1283 | 1359 | if (WC()->session->get('shipping_for_package_0')) { |
| @@ -1284,10 +1360,9 @@ | ||
| 1284 | 1360 | foreach( WC()->session->get('shipping_for_package_0')['rates'] as $method_id => $rate ){ |
| 1285 | 1361 | if( WC()->session->get('chosen_shipping_methods')[0] == $method_id ){ |
| 1286 | 1362 | $shippingMethod = explode(':', $rate->id); |
| 1287 | 1363 | $shippingMethod = $shippingMethod[0]; |
| 1288 | - $excludedShippingMethods = $this->routeapp_get_excluded_shipping_methods(); | |
| 1289 | - if (in_array($shippingMethod, $excludedShippingMethods)) { | |
| 1364 | + if ($this->routeapp_is_shipping_method_excluded($shippingMethod)) { | |
| 1290 | 1365 | return false; |
| 1291 | 1366 | } |
| 1292 | 1367 | } |
| 1293 | 1368 | } |
| @@ -1295,8 +1370,30 @@ | ||
| 1295 | 1370 | } |
| 1296 | 1371 | return true; |
| 1297 | 1372 | } |
| 1298 | 1373 | |
| 1374 | + /** | |
| 1375 | + * Whether a shipping method is configured as in-store pickup (excluded from protection). | |
| 1376 | + * WooCommerce Blocks local pickup uses pickup_location; classic checkout uses local_pickup. | |
| 1377 | + * | |
| 1378 | + * @param string $shipping_method Method id, optionally with instance suffix (e.g. pickup_location:0). | |
| 1379 | + * @return bool | |
| 1380 | + */ | |
| 1381 | + public function routeapp_is_shipping_method_excluded($shipping_method) { | |
| 1382 | + $shipping_method = explode(':', (string) $shipping_method)[0]; | |
| 1383 | + $excluded = $this->routeapp_get_excluded_shipping_methods(); | |
| 1384 | + if (!is_array($excluded) || empty($excluded) || $shipping_method === '') { | |
| 1385 | + return false; | |
| 1386 | + } | |
| 1387 | + if (in_array($shipping_method, $excluded, true)) { | |
| 1388 | + return true; | |
| 1389 | + } | |
| 1390 | + if (in_array($shipping_method, self::PICKUP_SHIPPING_METHOD_IDS, true)) { | |
| 1391 | + return (bool) array_intersect(self::PICKUP_SHIPPING_METHOD_IDS, $excluded); | |
| 1392 | + } | |
| 1393 | + return false; | |
| 1394 | + } | |
| 1395 | + | |
| 1299 | 1396 | public function routeapp_get_insurance_label() { |
| 1300 | 1397 | return __(self::ROUTE_LABEL, 'routeapp'); |
| 1301 | 1398 | } |
| 1302 | 1399 | |
| @@ -1542,8 +1639,11 @@ | ||
| 1542 | 1639 | return rest_ensure_response( [] ); |
| 1543 | 1640 | } |
| 1544 | 1641 | |
| 1545 | 1642 | $shipping_method = $_POST['shipping_method']; |
| 1643 | + if ($shipping_method === 'false' || $shipping_method === '') { | |
| 1644 | + $shipping_method = false; | |
| 1645 | + } | |
| 1546 | 1646 | |
| 1547 | 1647 | //check first if shipping method is allowed |
| 1548 | 1648 | if ($shipping_method) { |
| 1549 | 1649 | $show_widget = $this->routeapp_is_shipping_method_allowed($shipping_method); |
| @@ -1553,11 +1653,10 @@ | ||
| 1553 | 1653 | } |
| 1554 | 1654 | } |
| 1555 | 1655 | |
| 1556 | 1656 | $cart = WC()->cart; |
| 1557 | - $this->checkout_route_insurance_fee($cart); | |
| 1558 | 1657 | $response= [ |
| 1559 | - 'routeapp-subtotal' => round($this->get_cart_subtotal_with_only_shippable_items($cart), 2), | |
| 1658 | + 'routeapp-subtotal' => $cart ? round($this->get_cart_subtotal_with_only_shippable_items($cart), 2) : 0, | |
| 1560 | 1659 | ]; |
| 1561 | 1660 | |
| 1562 | 1661 | wp_send_json( $response ); |
| 1563 | 1662 | } |
| @@ -1882,38 +1981,307 @@ | ||
| 1882 | 1981 | |
| 1883 | 1982 | return $fee_line; |
| 1884 | 1983 | } |
| 1885 | 1984 | |
| 1985 | + /** | |
| 1986 | + * Mark a fee order item as Route Shipping Protection. | |
| 1987 | + * | |
| 1988 | + * Writes fee item meta: | |
| 1989 | + * - _routeapp_protection_fee = yes | |
| 1990 | + * - _routeapp_protection_sku = ROUTEINS | |
| 1991 | + * | |
| 1992 | + * Purpose: leave a stable identity on the WC_Order_Item_Fee that does not | |
| 1993 | + * depend on the (possibly translated) fee name. Called when the fee is | |
| 1994 | + * created at checkout and from the admin order fee path. | |
| 1995 | + * | |
| 1996 | + * @param mixed $item Fee order item. | |
| 1997 | + */ | |
| 1998 | + public static function routeapp_mark_protection_fee_item( $item ) { | |
| 1999 | + if ( ! is_object( $item ) || ! is_callable( array( $item, 'update_meta_data' ) ) ) { | |
| 2000 | + return; | |
| 2001 | + } | |
| 1886 | 2002 | |
| 2003 | + $item->update_meta_data( self::ROUTE_FEE_ITEM_META, 'yes' ); | |
| 2004 | + $item->update_meta_data( self::ROUTE_FEE_SKU_META, self::ROUTE_PROTECTION_SKU ); | |
| 2005 | + } | |
| 2006 | + | |
| 1887 | 2007 | /** |
| 1888 | - * This method adds to xml export an SKU tag in order to identify | |
| 1889 | - * Route Protection to ShipStation integration | |
| 2008 | + * Whether a fee name refers to Route Shipping Protection (EN or translated). | |
| 1890 | 2009 | * |
| 1891 | - * @param $order_xml | |
| 2010 | + * Name matching is a legacy fallback only. New fees are identified via | |
| 2011 | + * ROUTE_FEE_ITEM_META / ROUTE_FEE_SKU_META. Besides the English constant and | |
| 2012 | + * the current-locale translation, we also resolve the store locale label when | |
| 2013 | + * it differs from the request locale (e.g. admin export vs checkout). | |
| 2014 | + * | |
| 2015 | + * Hot path: prefer strict string matches before html_entity_decode(). | |
| 2016 | + * | |
| 2017 | + * @param string $name Fee name. | |
| 2018 | + * @return bool | |
| 2019 | + */ | |
| 2020 | + public static function routeapp_is_route_protection_fee_name( $name ) { | |
| 2021 | + $name = trim( (string) $name ); | |
| 2022 | + if ( '' === $name ) { | |
| 2023 | + return false; | |
| 2024 | + } | |
| 2025 | + | |
| 2026 | + // Common case: plain English constant / translated label, no entities. | |
| 2027 | + if ( $name === self::ROUTE_LABEL ) { | |
| 2028 | + return true; | |
| 2029 | + } | |
| 2030 | + | |
| 2031 | + $labels = self::routeapp_get_protection_fee_label_candidates(); | |
| 2032 | + foreach ( $labels as $label ) { | |
| 2033 | + if ( $name === $label ) { | |
| 2034 | + return true; | |
| 2035 | + } | |
| 2036 | + } | |
| 2037 | + | |
| 2038 | + // Only decode when needed (e.g. "Route Shipping Protection" stored as entities). | |
| 2039 | + if ( false === strpos( $name, '&' ) ) { | |
| 2040 | + return false; | |
| 2041 | + } | |
| 2042 | + | |
| 2043 | + $decoded = trim( html_entity_decode( $name, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ); | |
| 2044 | + if ( '' === $decoded || $decoded === $name ) { | |
| 2045 | + return false; | |
| 2046 | + } | |
| 2047 | + | |
| 2048 | + if ( $decoded === self::ROUTE_LABEL ) { | |
| 2049 | + return true; | |
| 2050 | + } | |
| 2051 | + | |
| 2052 | + foreach ( $labels as $label ) { | |
| 2053 | + if ( $decoded === $label ) { | |
| 2054 | + return true; | |
| 2055 | + } | |
| 2056 | + } | |
| 2057 | + | |
| 2058 | + return false; | |
| 2059 | + } | |
| 2060 | + | |
| 2061 | + /** | |
| 2062 | + * Possible Route fee labels for the current request / store locales. | |
| 2063 | + * | |
| 2064 | + * Cached per request to avoid repeated locale switching in fee loops. | |
| 2065 | + * | |
| 2066 | + * @return string[] | |
| 2067 | + */ | |
| 2068 | + private static function routeapp_get_protection_fee_label_candidates() { | |
| 2069 | + static $cached_labels = null; | |
| 2070 | + static $cached_locale_key = null; | |
| 2071 | + | |
| 2072 | + $current_locale = function_exists( 'determine_locale' ) | |
| 2073 | + ? determine_locale() | |
| 2074 | + : ( function_exists( 'get_locale' ) ? get_locale() : '' ); | |
| 2075 | + $site_locale = function_exists( 'get_locale' ) ? get_locale() : ''; | |
| 2076 | + $locale_key = $current_locale . '|' . $site_locale; | |
| 2077 | + | |
| 2078 | + if ( null !== $cached_labels && $cached_locale_key === $locale_key ) { | |
| 2079 | + return $cached_labels; | |
| 2080 | + } | |
| 2081 | + | |
| 2082 | + $labels = array(); | |
| 2083 | + | |
| 2084 | + if ( function_exists( '__' ) ) { | |
| 2085 | + $labels[] = __( self::ROUTE_LABEL, 'routeapp' ); | |
| 2086 | + | |
| 2087 | + // When the admin/user locale differs from the site locale, also check the | |
| 2088 | + // store translation used at checkout for older orders without fee meta. | |
| 2089 | + if ( function_exists( 'switch_to_locale' ) | |
| 2090 | + && function_exists( 'restore_previous_locale' ) | |
| 2091 | + && $site_locale | |
| 2092 | + && $site_locale !== $current_locale | |
| 2093 | + && switch_to_locale( $site_locale ) ) { | |
| 2094 | + $labels[] = __( self::ROUTE_LABEL, 'routeapp' ); | |
| 2095 | + restore_previous_locale(); | |
| 2096 | + } | |
| 2097 | + } | |
| 2098 | + | |
| 2099 | + $cached_labels = array_values( array_unique( array_filter( $labels ) ) ); | |
| 2100 | + $cached_locale_key = $locale_key; | |
| 2101 | + | |
| 2102 | + return $cached_labels; | |
| 2103 | + } | |
| 2104 | + | |
| 2105 | + /** | |
| 2106 | + * Whether an order item is the Route Shipping Protection fee. | |
| 2107 | + * | |
| 2108 | + * Prefers explicit fee item meta for all newly created fees; falls back to | |
| 2109 | + * fee name only for legacy pre-migration orders. | |
| 2110 | + * | |
| 2111 | + * @param mixed $item Order item. | |
| 2112 | + * @return bool | |
| 2113 | + */ | |
| 2114 | + public static function routeapp_is_route_protection_fee( $item ) { | |
| 2115 | + if ( ! is_object( $item ) ) { | |
| 2116 | + return false; | |
| 2117 | + } | |
| 2118 | + | |
| 2119 | + if ( is_callable( array( $item, 'get_type' ) ) && 'fee' !== $item->get_type() ) { | |
| 2120 | + return false; | |
| 2121 | + } | |
| 2122 | + | |
| 2123 | + if ( is_callable( array( $item, 'get_meta' ) ) ) { | |
| 2124 | + $flag = $item->get_meta( self::ROUTE_FEE_ITEM_META, true ); | |
| 2125 | + if ( 'yes' === $flag || true === $flag || '1' === $flag || 1 === $flag ) { | |
| 2126 | + return true; | |
| 2127 | + } | |
| 2128 | + | |
| 2129 | + $sku = $item->get_meta( self::ROUTE_FEE_SKU_META, true ); | |
| 2130 | + if ( self::ROUTE_PROTECTION_SKU === $sku ) { | |
| 2131 | + return true; | |
| 2132 | + } | |
| 2133 | + } | |
| 2134 | + | |
| 2135 | + $name = is_callable( array( $item, 'get_name' ) ) ? $item->get_name() : ''; | |
| 2136 | + if ( ! self::routeapp_is_route_protection_fee_name( $name ) ) { | |
| 2137 | + return false; | |
| 2138 | + } | |
| 2139 | + | |
| 2140 | + // Legacy path: visible when an older fee lacks identity meta (locale gaps). | |
| 2141 | + static $logged_name_fallback = false; | |
| 2142 | + if ( ! $logged_name_fallback && function_exists( 'error_log' ) ) { | |
| 2143 | + $logged_name_fallback = true; | |
| 2144 | + error_log( | |
| 2145 | + 'RouteApp: identified Route protection fee via name fallback (legacy order without fee meta). Name: ' . $name | |
| 2146 | + ); | |
| 2147 | + } | |
| 2148 | + | |
| 2149 | + return true; | |
| 2150 | + } | |
| 2151 | + | |
| 2152 | + /** | |
| 2153 | + * Exclude Route fee from ShipStation item export (XML legacy + REST 4.9+). | |
| 2154 | + * | |
| 2155 | + * Used by woocommerce_shipstation_no_shipping_item on both export paths. | |
| 2156 | + * | |
| 2157 | + * @param bool $exclude Whether the item should be excluded. | |
| 2158 | + * @param mixed $product Product object or false for fees. | |
| 2159 | + * @param mixed $item Order item. | |
| 2160 | + * @return bool | |
| 2161 | + */ | |
| 2162 | + public function routeapp_exclude_route_from_shipstation( $exclude, $product, $item ) { | |
| 2163 | + if ( self::routeapp_is_route_protection_fee( $item ) ) { | |
| 2164 | + return true; | |
| 2165 | + } | |
| 2166 | + | |
| 2167 | + return $exclude; | |
| 2168 | + } | |
| 2169 | + | |
| 2170 | + /** | |
| 2171 | + * Strip Route fee from ShipStation REST fulfillment item payload (4.8+/4.9+). | |
| 2172 | + * | |
| 2173 | + * @param array $fulfillment_item Fulfillment item data. | |
| 2174 | + * @param mixed $order Order object. | |
| 2175 | + * @param mixed $item Order item. | |
| 2176 | + * @param array $extra_args Extra args. | |
| 2177 | + * @return array | |
| 2178 | + */ | |
| 2179 | + public function routeapp_strip_route_from_shipstation_fulfillment_item( $fulfillment_item, $order, $item, $extra_args = array() ) { | |
| 2180 | + if ( self::routeapp_is_route_protection_fee( $item ) ) { | |
| 2181 | + return array(); | |
| 2182 | + } | |
| 2183 | + | |
| 2184 | + return $fulfillment_item; | |
| 2185 | + } | |
| 2186 | + | |
| 2187 | + /** | |
| 2188 | + * Legacy alias kept for backwards compatibility with anything calling the old method name. | |
| 2189 | + * | |
| 2190 | + * @param mixed $order_xml Order XML DOM element. | |
| 1892 | 2191 | * @return mixed |
| 1893 | 2192 | */ |
| 1894 | - public function routeapp_add_sku_to_route_node($order_xml){ | |
| 2193 | + public function routeapp_add_sku_to_route_node( $order_xml ) { | |
| 2194 | + return $this->routeapp_strip_route_from_shipstation_xml( $order_xml ); | |
| 2195 | + } | |
| 1895 | 2196 | |
| 1896 | - if ($order_xml instanceof DOMElement){ | |
| 2197 | + /** | |
| 2198 | + * Remove Route Shipping Protection from ShipStation XML order export (legacy path). | |
| 2199 | + * | |
| 2200 | + * @param mixed $order_xml Order XML DOM element. | |
| 2201 | + * @return mixed | |
| 2202 | + */ | |
| 2203 | + public function routeapp_strip_route_from_shipstation_xml( $order_xml ) { | |
| 2204 | + if ( ! ( $order_xml instanceof DOMElement ) ) { | |
| 2205 | + return $order_xml; | |
| 2206 | + } | |
| 1897 | 2207 | |
| 1898 | - $items = $order_xml->getElementsByTagName('Items'); | |
| 2208 | + $items_nodes = $order_xml->getElementsByTagName( 'Items' ); | |
| 2209 | + foreach ( $items_nodes as $item_node ) { | |
| 2210 | + $to_remove = array(); | |
| 1899 | 2211 | |
| 1900 | - foreach ($items as $itemNode) { | |
| 1901 | - foreach ($itemNode->getElementsByTagName('Item') as $item) { | |
| 1902 | - foreach ($item->getElementsByTagName('Name') as $name) { | |
| 1903 | - if (trim($name->nodeValue) === self::ROUTE_LABEL) | |
| 1904 | - { | |
| 1905 | - $itemNode->removeChild($item); | |
| 1906 | - } | |
| 1907 | - } | |
| 2212 | + foreach ( $item_node->childNodes as $child ) { | |
| 2213 | + if ( ! ( $child instanceof DOMElement ) || 'Item' !== $child->nodeName ) { | |
| 2214 | + continue; | |
| 1908 | 2215 | } |
| 2216 | + | |
| 2217 | + if ( $this->routeapp_shipstation_xml_item_is_route( $child ) ) { | |
| 2218 | + $to_remove[] = $child; | |
| 2219 | + } | |
| 1909 | 2220 | } |
| 1910 | 2221 | |
| 2222 | + foreach ( $to_remove as $node ) { | |
| 2223 | + $item_node->removeChild( $node ); | |
| 2224 | + } | |
| 1911 | 2225 | } |
| 1912 | 2226 | |
| 1913 | 2227 | return $order_xml; |
| 1914 | 2228 | } |
| 1915 | 2229 | |
| 2230 | + /** | |
| 2231 | + * Detect Route fee nodes inside a ShipStation XML <Item>. | |
| 2232 | + * | |
| 2233 | + * @param DOMElement $item Item element. | |
| 2234 | + * @return bool | |
| 2235 | + */ | |
| 2236 | + private function routeapp_shipstation_xml_item_is_route( DOMElement $item ) { | |
| 2237 | + foreach ( $item->childNodes as $child ) { | |
| 2238 | + if ( ! ( $child instanceof DOMElement ) ) { | |
| 2239 | + continue; | |
| 2240 | + } | |
| 2241 | + | |
| 2242 | + if ( 'Name' === $child->nodeName && self::routeapp_is_route_protection_fee_name( $child->nodeValue ) ) { | |
| 2243 | + return true; | |
| 2244 | + } | |
| 2245 | + | |
| 2246 | + if ( 'SKU' === $child->nodeName && self::ROUTE_PROTECTION_SKU === trim( $child->nodeValue ) ) { | |
| 2247 | + return true; | |
| 2248 | + } | |
| 2249 | + | |
| 2250 | + if ( 'Options' !== $child->nodeName ) { | |
| 2251 | + continue; | |
| 2252 | + } | |
| 2253 | + | |
| 2254 | + foreach ( $child->getElementsByTagName( 'Option' ) as $option ) { | |
| 2255 | + $opt_name = ''; | |
| 2256 | + $opt_value = ''; | |
| 2257 | + | |
| 2258 | + foreach ( $option->childNodes as $opt_child ) { | |
| 2259 | + if ( ! ( $opt_child instanceof DOMElement ) ) { | |
| 2260 | + continue; | |
| 2261 | + } | |
| 2262 | + if ( 'Name' === $opt_child->nodeName ) { | |
| 2263 | + $opt_name = trim( $opt_child->nodeValue ); | |
| 2264 | + } | |
| 2265 | + if ( 'Value' === $opt_child->nodeName ) { | |
| 2266 | + $opt_value = trim( strip_tags( $opt_child->nodeValue ) ); | |
| 2267 | + } | |
| 2268 | + } | |
| 2269 | + | |
| 2270 | + if ( self::ROUTE_FEE_ITEM_META === $opt_name && in_array( $opt_value, array( 'yes', '1' ), true ) ) { | |
| 2271 | + return true; | |
| 2272 | + } | |
| 2273 | + | |
| 2274 | + if ( in_array( $opt_name, array( self::ROUTE_FEE_SKU_META, 'SKU' ), true ) | |
| 2275 | + && self::ROUTE_PROTECTION_SKU === $opt_value ) { | |
| 2276 | + return true; | |
| 2277 | + } | |
| 2278 | + } | |
| 2279 | + } | |
| 2280 | + | |
| 2281 | + return false; | |
| 2282 | + } | |
| 2283 | + | |
| 1916 | 2284 | /** |
| 1917 | 2285 | * Update order meta only if changed (HPOS & CPT safe) |
| 1918 | 2286 | * |
| 1919 | 2287 | * @return bool True if meta changed |
| @@ -1997,9 +2365,9 @@ | ||
| 1997 | 2365 | if ( empty( $method_id ) ) { |
| 1998 | 2366 | continue; |
| 1999 | 2367 | } |
| 2000 | 2368 | $method_id = explode( ':', $method_id )[0]; |
| 2001 | - if ( in_array( $method_id, $excluded_methods, true ) ) { | |
| 2369 | + if ( $this->routeapp_is_shipping_method_excluded( $method_id ) ) { | |
| 2002 | 2370 | return 1; |
| 2003 | 2371 | } |
| 2004 | 2372 | } |
| 2005 | 2373 | return 0; |
| @@ -2019,9 +2387,9 @@ | ||
| 2019 | 2387 | if ( ! isset( $packages['rates'][ $chosen_id ] ) ) { |
| 2020 | 2388 | return 0; |
| 2021 | 2389 | } |
| 2022 | 2390 | $method_id = explode( ':', $packages['rates'][ $chosen_id ]->id )[0]; |
| 2023 | - if ( in_array( $method_id, $excluded_methods, true ) ) { | |
| 2391 | + if ( $this->routeapp_is_shipping_method_excluded( $method_id ) ) { | |
| 2024 | 2392 | return 1; |
| 2025 | 2393 | } |
| 2026 | 2394 | |
| 2027 | 2395 | return 0; |