PluginProbe
Route ‑ Shipping Protection / 2.4.18
Route ‑ Shipping Protection v2.4.18
2.4.19 2.4.18 2.4.17 2.4.16 2.4.15 2.4.14 2.4.13 2.4.12 2.4.11 2.4.10 2.4.9 2.4.8 2.4.7 2.4.6 2.4.5 2.4.4 2.4.3 2.4.2 2.2.4 2.2.5 2.2.6 2.2.8 2.2.9 2.3.0 2.3.1 All 153 releases
← All changes | public/class-routeapp-public.php +414 -46 2.4.11 → 2.4.18 View file →
@@ -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;