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 +580 -81 2.4.3 → 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 );
@@ -162,11 +163,16 @@
162 163 add_action('wp_ajax_routeapp_add_admin_fee', array($this, 'routeapp_add_admin_fee'));
163 164 add_action('wp_ajax_nopriv_routeapp_add_admin_fee', array($this, 'routeapp_add_admin_fee'));
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'));
167 + add_action('wp_ajax_routeapp_refresh_admin_fee_button', array($this, 'routeapp_refresh_admin_fee_button'));
166 168 add_action('wc_avatax_api_fee_line_data', array($this, 'routeapp_edit_avatax_fee'));
167 - add_action('woocommerce_shipstation_export_order_xml', array($this, 'routeapp_add_sku_to_route_node') );
168 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 +
169 175 add_action('woocommerce_order_details_after_order_table', array($this, 'order_updates_route_tracking'), 1, 1);
170 176
171 177 add_action('woocommerce_blocks_checkout_update_order_meta', array($this, 'routeapp_save_order_status_to_order'), 10, 1);
172 178 add_action('woocommerce_store_api_checkout_update_order_meta', array($this, 'routeapp_save_order_status_to_order'), 10, 1);
@@ -181,8 +187,12 @@
181 187 add_action('init', array($this, 'routeapp_register_checkout_block'));
182 188
183 189 // Register IntegrationInterface for WooCommerce Blocks
184 190 add_action('woocommerce_blocks_checkout_block_registration', array($this, 'routeapp_register_blocks_integration'));
191 +
192 + // Enqueue widget assets early on cart/checkout for stable script order with Blocks SSR.
193 + add_action('wp_enqueue_scripts', array($this, 'routeapp_maybe_enqueue_widget_assets'), 20);
194 + add_action('woocommerce_blocks_enqueue_checkout_block_scripts_before', array($this, 'routeapp_maybe_enqueue_widget_assets'));
185 195 }
186 196
187 197 /**
188 198 * Hide order line_item meta_data from Route App
@@ -196,8 +206,10 @@
196 206 $arr[] = '_routeapp_virtual';
197 207 $arr[] = '_routeapp_weight_value';
198 208 $arr[] = '_routeapp_weight_unit';
199 209 $arr[] = '_routeapp_origin_location';
210 + $arr[] = self::ROUTE_FEE_ITEM_META;
211 + $arr[] = self::ROUTE_FEE_SKU_META;
200 212 return $arr;
201 213 }
202 214
203 215 /**
@@ -344,8 +356,35 @@
344 356 }
345 357 }
346 358 }
347 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 +
348 387 public function routeapp_add_admin_fee() {
349 388 $this->routeapp_admin_fee->routeapp_add_admin_fee();
350 389 }
351 390
@@ -352,8 +391,12 @@
352 391 public function routeapp_remove_admin_fee() {
353 392 $this->routeapp_admin_fee->routeapp_remove_admin_fee();
354 393 }
355 394
395 + public function routeapp_refresh_admin_fee_button() {
396 + $this->routeapp_admin_fee->routeapp_refresh_admin_fee_button();
397 + }
398 +
356 399 public function register_shortcode() {
357 400 add_shortcode( 'route', array( $this, 'route_widget_shortcode' ) );
358 401 }
359 402
@@ -386,20 +429,24 @@
386 429 }
387 430 }
388 431
389 432 /**
390 - * 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 + *
391 438 * @since 1.0.0
392 439 * @param Object $cart_or_checkout
393 - * MSS-6261:The issue: checkout_route_insurance_fee is bound to hooks that pass different parameter types:
394 - * woocommerce_checkout_before_order_review -> WC_Checkout
395 - * woocommerce_cart_calculate_fees -> WC_Cart
396 - * At line 388, it assumes a WC_Cart and calls get_cart_hash(), which fails when a WC_Checkout is passed.
397 - * Fixing by detecting the object type and handling both cases.
398 - */
440 + */
399 441 public function checkout_route_insurance_fee( $cart_or_checkout ) {
400 442 if ( (is_admin() && ! defined( 'DOING_AJAX' )) ) return;
401 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 +
402 449 // Determine if we received a WC_Cart or WC_Checkout object
403 450 $is_checkout_object = is_a( $cart_or_checkout, 'WC_Checkout' );
404 451 $cart = $is_checkout_object ? WC()->cart : $cart_or_checkout;
405 452
@@ -430,10 +477,20 @@
430 477 $checkbox = $route_insurance_quote->payment_responsible->ToggleState;
431 478 WC()->session->set('checkbox_checked', $checkbox);
432 479 }
433 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 +
434 491 Route_Setup::set_setup_check_widget(Route_Setup::SETUP_CHECK_WIDGET_PHP);
435 - 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()) {
436 493 $cart->add_fee( $this->routeapp_get_insurance_label(),
437 494 $route_insurance_amount,
438 495 $this->routeapp_get_fee_taxable(),
439 496 $this->routeapp_get_taxable_class());
@@ -440,8 +497,35 @@
440 497 }
441 498 }
442 499
443 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 + /**
444 528 * Create checkout
445 529 * @since 1.0.0
446 530 *
447 531 */
@@ -498,54 +582,141 @@
498 582
499 583 return $output;
500 584 }
501 585
502 - public function route_widget_shortcode()
503 - {
504 - if(is_admin() || empty($this->routeapp_get_public_token())) return;
586 + /**
587 + * Enqueue Route widget JS/CSS with correct dependency order.
588 + * routeapp-public-pbc.js must load after route-protection-widget.js (Route.Protection).
589 + */
590 + public function routeapp_enqueue_widget_assets() {
591 + if ( is_admin() || empty( $this->routeapp_get_public_token() ) ) {
592 + return;
593 + }
594 + if ( ! function_exists( 'WC' ) || ! WC()->cart ) {
595 + return;
596 + }
505 597
506 - $custom_env = getenv('ROUTEAPP_ENVIRONMENT_ENDPOINT');
507 - if (is_null($custom_env) || !$custom_env) {
508 - $custom_env = isset($_SERVER['ROUTEAPP_ENVIRONMENT_ENDPOINT']) ? $_SERVER['ROUTEAPP_ENVIRONMENT_ENDPOINT'] : '';
598 + $custom_env = getenv( 'ROUTEAPP_ENVIRONMENT_ENDPOINT' );
599 + if ( is_null( $custom_env ) || ! $custom_env ) {
600 + $custom_env = isset( $_SERVER['ROUTEAPP_ENVIRONMENT_ENDPOINT'] ) ? $_SERVER['ROUTEAPP_ENVIRONMENT_ENDPOINT'] : '';
509 601 }
510 - $custom_env = empty($custom_env) ? 'production' : $custom_env;
602 + $custom_env = empty( $custom_env ) ? 'production' : $custom_env;
603 + if ( ! isset( self::AMPLITUDE_API_KEY[ $custom_env ] ) ) {
604 + $custom_env = 'production';
605 + }
511 606
512 - wp_enqueue_script($this->plugin_name . '-widget', 'https://protection-widget.route.com/route-protection-widget.js', array(), $this->version, false);
607 + $widget_handle = $this->plugin_name . '-widget';
608 + wp_enqueue_script(
609 + $widget_handle,
610 + 'https://protection-widget.route.com/route-protection-widget.js',
611 + array(),
612 + $this->version,
613 + true
614 + );
513 615
514 - $amplitude_key = self::AMPLITUDE_API_KEY[$custom_env];
616 + $amplitude_key = self::AMPLITUDE_API_KEY[ $custom_env ];
515 617
516 - // Load Amplitude SDK (standard version, not snippet)
517 - wp_enqueue_script('amplitude-js', "https://cdn.amplitude.com/script/$amplitude_key.js", array(), null, false);
618 + // Load Amplitude SDK (standard version, not snippet)
619 + wp_enqueue_script( 'amplitude-js', "https://cdn.amplitude.com/script/$amplitude_key.js", array(), null, true );
620 + // Enqueue Amplitude Analytics module (depends on jQuery and Amplitude SDK)
621 + wp_enqueue_script(
622 + $this->plugin_name . '-amplitude-analytics',
623 + plugin_dir_url( __FILE__ ) . 'js/amplitude-analytics.js',
624 + array( 'jquery', 'amplitude-js' ),
625 + $this->version,
626 + true
627 + );
628 + $pbc_deps = array( 'jquery', $widget_handle, $this->plugin_name . '-amplitude-analytics' );
629 + if ( $this->routeapp_should_add_wc_blocks_checkout_dependency() ) {
630 + $pbc_deps[] = 'wc-blocks-checkout';
631 + }
518 632
519 - // Enqueue Amplitude Analytics module (depends on jQuery and Amplitude SDK)
520 - wp_enqueue_script($this->plugin_name . '-amplitude-analytics', plugin_dir_url(__FILE__) . 'js/amplitude-analytics.js', array('jquery', 'amplitude-js'), $this->version, false);
633 + /**
634 + * Filter script dependencies for routeapp-public-pbc.js.
635 + *
636 + * @param string[] $pbc_deps Script handles routeapp-public-pbc.js depends on.
637 + */
638 + $pbc_deps = apply_filters( 'routeapp_pbc_script_dependencies', $pbc_deps );
521 639
522 - // Main script depends on amplitude-analytics when available
523 - wp_enqueue_script($this->plugin_name, plugin_dir_url(__FILE__) . 'js/routeapp-public-pbc.js', array('jquery', $this->plugin_name . '-amplitude-analytics'), $this->version, false);
640 + // Main script depends on amplitude-analytics when available.
641 + wp_enqueue_script(
642 + $this->plugin_name,
643 + plugin_dir_url( __FILE__ ) . 'js/routeapp-public-pbc.js',
644 + $pbc_deps,
645 + $this->version,
646 + true
647 + );
524 648
525 - /**
526 - * Calculate total Route fee
527 - * Uses the premium amount from route insurance data if available,
528 - * otherwise defaults to 0.98 as a fallback fee amount
529 - */
530 - $totalRouteFee = (is_object($this->route_insurance_data) && isset($this->route_insurance_data->premium->amount))
531 - ? (float) $this->route_insurance_data->premium->amount
532 - : 0.98;
649 + /**
650 + * Calculate total Route fee
651 + * Uses the premium amount from route insurance data if available,
652 + * otherwise defaults to 0.98 as a fallback fee amount
653 + */
654 + $totalRouteFee = ( is_object( $this->route_insurance_data ) && isset( $this->route_insurance_data->premium->amount ) )
655 + ? (float) $this->route_insurance_data->premium->amount
656 + : 0.98;
533 657
534 - // Pass cart data to JavaScript - send to amplitude-analytics.js
535 - wp_localize_script($this->plugin_name . '-amplitude-analytics', 'cartData', array(
536 - 'cartId' => WC()->cart->get_cart_hash(),
537 - 'amplitudeKey' => $amplitude_key,
538 - 'widgetId' => uniqid('widget-', true),
539 - 'subtotal' => round($this->get_cart_subtotal_with_only_shippable_items(WC()->cart), 2),
540 - 'totalRouteFee' => $totalRouteFee,
541 - 'environment' => $custom_env,
542 - 'currency' => get_woocommerce_currency(),
543 - 'merchantId' => $this->routeapp_api_client->get_merchant_id(),
544 - ));
658 + // Pass cart data to JavaScript - send to amplitude-analytics.js
659 + wp_localize_script(
660 + $this->plugin_name . '-amplitude-analytics',
661 + 'cartData',
662 + array(
663 + 'cartId' => WC()->cart->get_cart_hash(),
664 + 'amplitudeKey' => $amplitude_key,
665 + 'widgetId' => uniqid( 'widget-', true ),
666 + 'subtotal' => round( $this->get_cart_subtotal_with_only_shippable_items( WC()->cart ), 2 ),
667 + 'totalRouteFee' => $totalRouteFee,
668 + 'environment' => $custom_env,
669 + 'currency' => get_woocommerce_currency(),
670 + 'merchantId' => $this->routeapp_api_client->get_merchant_id(),
671 + )
672 + );
545 673
546 - wp_enqueue_style($this->plugin_name . '-widget', plugin_dir_url(__FILE__) . 'css/routeapp-widget.css', array(), $this->version, false);
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 + );
547 681
682 + wp_enqueue_style(
683 + $this->plugin_name . '-widget',
684 + plugin_dir_url( __FILE__ ) . 'css/routeapp-widget.css',
685 + array(),
686 + $this->version,
687 + 'all'
688 + );
689 + }
690 +
691 + /**
692 + * Enqueue widget assets on cart, checkout, and Blocks checkout script hook.
693 + */
694 + public function routeapp_maybe_enqueue_widget_assets() {
695 + if ( is_admin() || empty( $this->routeapp_get_public_token() ) ) {
696 + return;
697 + }
698 + if ( function_exists( 'is_cart' ) && function_exists( 'is_checkout' ) && ! is_cart() && ! is_checkout() ) {
699 + return;
700 + }
701 + $this->routeapp_enqueue_widget_assets();
702 + }
703 +
704 + public function route_widget_shortcode()
705 + {
706 + if(is_admin() || empty($this->routeapp_get_public_token())) return;
707 +
708 + // Enqueue only outside cart/checkout; wp_enqueue_scripts handles those pages.
709 + if ( function_exists( 'is_cart' ) && function_exists( 'is_checkout' ) && ! is_cart() && ! is_checkout() ) {
710 + $this->routeapp_enqueue_widget_assets();
711 + }
712 +
713 + $custom_env = getenv('ROUTEAPP_ENVIRONMENT_ENDPOINT');
714 + if (is_null($custom_env) || !$custom_env) {
715 + $custom_env = isset($_SERVER['ROUTEAPP_ENVIRONMENT_ENDPOINT']) ? $_SERVER['ROUTEAPP_ENVIRONMENT_ENDPOINT'] : '';
716 + }
717 + $custom_env = empty($custom_env) ? 'production' : $custom_env;
718 +
548 719 global $wp;
549 720 $parsedUrl = parse_url(home_url( $wp->request));
550 721
551 722 switch($custom_env) {
@@ -570,17 +741,11 @@
570 741 '<input type="hidden" value="' . $this->routeapp_api_client->get_merchant_id() . '" class="routeapp-merchant-id">'.
571 742 '<input type="hidden" value="' . $custom_env . '" class="routeapp-env">'.
572 743 '<input type="hidden" value="' . $checkbox .'" class="routeapp-checkbox-cookie">';
573 744
574 - //check if shipping method is allowed
575 - $cart = WC()->cart;
576 - $shipping_method = $cart->calculate_shipping();
577 - if (isset($shipping_method[0]->method_id)) {
578 - $shipping_method = $shipping_method[0]->method_id;
579 - $show_widget = $this->routeapp_is_shipping_method_allowed($shipping_method);
580 - if (!$show_widget) {
581 - $return .= '<input type="hidden" value="true" class="routeapp-invalid-shipping-method">';
582 - }
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">';
583 748 }
584 749 return $return . '<div id="RouteWidget" class="route-div"></div></div>' ;
585 750 }
586 751
@@ -640,9 +805,9 @@
640 805 $insurance_selected = false;
641 806 $insurance_amount = false;
642 807
643 808 foreach ($order->get_items('fee') as $fee) {
644 - if ($fee->get_name() === $this->routeapp_get_insurance_label()) {
809 + if (self::routeapp_is_route_protection_fee($fee)) {
645 810 $insurance_selected = true;
646 811 $insurance_amount = $fee->get_amount();
647 812 if ( class_exists('Automattic\WooCommerce\Utilities\OrderUtil')
648 813 && OrderUtil::custom_orders_table_usage_is_enabled() ) {
@@ -923,9 +1088,9 @@
923 1088
924 1089 $fees = $order->get_items('fee');
925 1090 if (!empty($fees)) {
926 1091 foreach ($fees as $fee) {
927 - if ($fee->get_name() === $this->routeapp_get_insurance_label()) {
1092 + if (self::routeapp_is_route_protection_fee($fee)) {
928 1093 $insurance_amount = $fee->get_amount();
929 1094 $updated |= $this->updateRouteOrderMetaData( $order, $is_hpos, '_routeapp_route_charge', $insurance_amount );
930 1095 $updated |= $this->updateRouteOrderMetaData( $order, $is_hpos, '_routeapp_route_protection', true );
931 1096 }
@@ -937,9 +1102,9 @@
937 1102 // Fallback to WC()->cart->get_fees() if no fees are present in the order
938 1103 $cart_fees = WC()->cart->get_fees();
939 1104 if (!empty($cart_fees)) {
940 1105 foreach ($cart_fees as $cart_fee) {
941 - if ($cart_fee->name === $this->routeapp_get_insurance_label()) {
1106 + if (self::routeapp_is_route_protection_fee_name($cart_fee->name)) {
942 1107 $insurance_amount = $cart_fee->amount; // Correct method for cart fee amount
943 1108 $updated |= $this->updateRouteOrderMetaData( $order, $is_hpos, '_routeapp_route_charge', $insurance_amount );
944 1109 $updated |= $this->updateRouteOrderMetaData( $order, $is_hpos, '_routeapp_route_protection', true );
945 1110 }
@@ -1186,10 +1351,9 @@
1186 1351 } else {
1187 1352 return false;
1188 1353 }
1189 1354 }
1190 - $excludedShippingMethods = $this->routeapp_get_excluded_shipping_methods();
1191 - if (is_array($excludedShippingMethods) && in_array($shipping_method, $excludedShippingMethods)) {
1355 + if ($this->routeapp_is_shipping_method_excluded($shipping_method)) {
1192 1356 return false;
1193 1357 }
1194 1358 } else {
1195 1359 if (WC()->session->get('shipping_for_package_0')) {
@@ -1196,10 +1360,9 @@
1196 1360 foreach( WC()->session->get('shipping_for_package_0')['rates'] as $method_id => $rate ){
1197 1361 if( WC()->session->get('chosen_shipping_methods')[0] == $method_id ){
1198 1362 $shippingMethod = explode(':', $rate->id);
1199 1363 $shippingMethod = $shippingMethod[0];
1200 - $excludedShippingMethods = $this->routeapp_get_excluded_shipping_methods();
1201 - if (in_array($shippingMethod, $excludedShippingMethods)) {
1364 + if ($this->routeapp_is_shipping_method_excluded($shippingMethod)) {
1202 1365 return false;
1203 1366 }
1204 1367 }
1205 1368 }
@@ -1207,8 +1370,30 @@
1207 1370 }
1208 1371 return true;
1209 1372 }
1210 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 +
1211 1396 public function routeapp_get_insurance_label() {
1212 1397 return __(self::ROUTE_LABEL, 'routeapp');
1213 1398 }
1214 1399
@@ -1454,8 +1639,11 @@
1454 1639 return rest_ensure_response( [] );
1455 1640 }
1456 1641
1457 1642 $shipping_method = $_POST['shipping_method'];
1643 + if ($shipping_method === 'false' || $shipping_method === '') {
1644 + $shipping_method = false;
1645 + }
1458 1646
1459 1647 //check first if shipping method is allowed
1460 1648 if ($shipping_method) {
1461 1649 $show_widget = $this->routeapp_is_shipping_method_allowed($shipping_method);
@@ -1465,11 +1653,10 @@
1465 1653 }
1466 1654 }
1467 1655
1468 1656 $cart = WC()->cart;
1469 - $this->checkout_route_insurance_fee($cart);
1470 1657 $response= [
1471 - '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,
1472 1659 ];
1473 1660
1474 1661 wp_send_json( $response );
1475 1662 }
@@ -1794,38 +1981,307 @@
1794 1981
1795 1982 return $fee_line;
1796 1983 }
1797 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 + }
1798 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 +
1799 2007 /**
1800 - * This method adds to xml export an SKU tag in order to identify
1801 - * Route Protection to ShipStation integration
2008 + * Whether a fee name refers to Route Shipping Protection (EN or translated).
1802 2009 *
1803 - * @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.
1804 2191 * @return mixed
1805 2192 */
1806 - 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 + }
1807 2196
1808 - 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 + }
1809 2207
1810 - $items = $order_xml->getElementsByTagName('Items');
2208 + $items_nodes = $order_xml->getElementsByTagName( 'Items' );
2209 + foreach ( $items_nodes as $item_node ) {
2210 + $to_remove = array();
1811 2211
1812 - foreach ($items as $itemNode) {
1813 - foreach ($itemNode->getElementsByTagName('Item') as $item) {
1814 - foreach ($item->getElementsByTagName('Name') as $name) {
1815 - if (trim($name->nodeValue) === self::ROUTE_LABEL)
1816 - {
1817 - $itemNode->removeChild($item);
1818 - }
1819 - }
2212 + foreach ( $item_node->childNodes as $child ) {
2213 + if ( ! ( $child instanceof DOMElement ) || 'Item' !== $child->nodeName ) {
2214 + continue;
1820 2215 }
2216 +
2217 + if ( $this->routeapp_shipstation_xml_item_is_route( $child ) ) {
2218 + $to_remove[] = $child;
2219 + }
1821 2220 }
1822 2221
2222 + foreach ( $to_remove as $node ) {
2223 + $item_node->removeChild( $node );
2224 + }
1823 2225 }
1824 2226
1825 2227 return $order_xml;
1826 2228 }
1827 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 +
1828 2284 /**
1829 2285 * Update order meta only if changed (HPOS & CPT safe)
1830 2286 *
1831 2287 * @return bool True if meta changed
@@ -1909,9 +2365,9 @@
1909 2365 if ( empty( $method_id ) ) {
1910 2366 continue;
1911 2367 }
1912 2368 $method_id = explode( ':', $method_id )[0];
1913 - if ( in_array( $method_id, $excluded_methods, true ) ) {
2369 + if ( $this->routeapp_is_shipping_method_excluded( $method_id ) ) {
1914 2370 return 1;
1915 2371 }
1916 2372 }
1917 2373 return 0;
@@ -1931,9 +2387,9 @@
1931 2387 if ( ! isset( $packages['rates'][ $chosen_id ] ) ) {
1932 2388 return 0;
1933 2389 }
1934 2390 $method_id = explode( ':', $packages['rates'][ $chosen_id ]->id )[0];
1935 - if ( in_array( $method_id, $excluded_methods, true ) ) {
2391 + if ( $this->routeapp_is_shipping_method_excluded( $method_id ) ) {
1936 2392 return 1;
1937 2393 }
1938 2394
1939 2395 return 0;
@@ -2071,8 +2527,51 @@
2071 2527 }
2072 2528 if ( class_exists( 'Routeapp_Blocks_Integration' ) ) {
2073 2529 $integration_registry->register( new Routeapp_Blocks_Integration() );
2074 2530 }
2531 + }
2532 +
2533 + /**
2534 + * Whether the store checkout page uses the WooCommerce Checkout block.
2535 + *
2536 + * @return bool
2537 + */
2538 + private function routeapp_is_wc_blocks_checkout_page() {
2539 + if ( class_exists( '\Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils' ) ) {
2540 + return \Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils::is_checkout_block_default();
2541 + }
2542 +
2543 + if ( ! function_exists( 'wc_get_page_id' ) || ! function_exists( 'has_block' ) ) {
2544 + return false;
2545 + }
2546 +
2547 + $checkout_page_id = wc_get_page_id( 'checkout' );
2548 + return $checkout_page_id > 0 && has_block( 'woocommerce/checkout', $checkout_page_id );
2549 + }
2550 +
2551 + /**
2552 + * Whether routeapp-public-pbc.js should depend on wc-blocks-checkout.
2553 + *
2554 + * Classic checkout stores often deregister block scripts after they are registered.
2555 + * Adding wc-blocks-checkout as a dependency in that case causes WordPress to silently
2556 + * drop routeapp-public-pbc.js when the handle is later deregistered.
2557 + *
2558 + * @return bool
2559 + */
2560 + private function routeapp_should_add_wc_blocks_checkout_dependency() {
2561 + if ( ! wp_script_is( 'wc-blocks-checkout', 'registered' ) ) {
2562 + return false;
2563 + }
2564 +
2565 + if ( doing_action( 'woocommerce_blocks_enqueue_checkout_block_scripts_before' ) ) {
2566 + return true;
2567 + }
2568 +
2569 + if ( ! function_exists( 'is_checkout' ) || ! is_checkout() ) {
2570 + return false;
2571 + }
2572 +
2573 + return $this->routeapp_is_wc_blocks_checkout_page();
2075 2574 }
2076 2575
2077 2576 /**
2078 2577 * Check whether the checkout page contains the route/route-protection block.