PluginProbe
Route ‑ Shipping Protection / 2.4.11
Route ‑ Shipping Protection v2.4.11
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
routeapp / public / class-routeapp-public.php

class-routeapp-public.php in Route ‑ Shipping Protection 2.4.11, at public/class-routeapp-public.php

2,233 lines 85.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The public-facing functionality of the plugin.
4 *
5 * @link https://route.com/
6 * @since 1.0.0
7 *
8 * @package Routeapp
9 * @subpackage Routeapp/public
10 */
11
12 use Automattic\WooCommerce\Utilities\OrderUtil;
13
14 /**
15 * The public-facing functionality of routeapp.
16 *
17 *
18 * @package Routeapp
19 * @subpackage Routeapp/public
20 * @author Route App <[email protected]>
21 */
22
23 class Routeapp_Public {
24
25 const HTTP_SUCCESS_CODE = 200;
26 const HTTP_BAD_REQUEST_CODE = 400;
27 const HTTP_CREATED_CODE = 201;
28 const HTTP_CONFLICT_CODE = 409;
29 const HTTP_NOT_FOUND = 404;
30 const HTTP_INTERNAL_SERVER_ERROR_CODE = 500;
31 const DEFAULT_MAX_USD_SUBTOTAL_ALLOWED = 5000;
32 const DEFAULT_MIN_USD_SUBTOTAL_ALLOWED = 0;
33 const ROUTE_LABEL = 'Route Shipping Protection';
34 const DEFAULT_CHECKOUT_WEBHOOK = 'woocommerce_checkout_before_order_review';
35 const ROUTE_PROTECTION_SKU = 'ROUTEINS';
36 const ALLOWED_QUOTE_TYPE = 'paid_by_customer';
37
38 const AMPLITUDE_API_KEY = [
39 'production' => '6d4f9158886e0c6d78e3bd97663ba920',
40 'stage' => '99a869d62986f6415610c6741057bc92',
41 'dev' => '527af176070cf4b775c6a718f0737f0a'
42 ];
43
44 /**
45 * The ID of this plugin.
46 *
47 * @since 1.0.0
48 * @access private
49 * @var string $plugin_name The ID of this plugin.
50 */
51 private $plugin_name;
52
53 private $route_insurance_data = null;
54
55 /**
56 * The version of this plugin.
57 *
58 * @since 1.0.0
59 * @access private
60 * @var string $version The current version of this plugin.
61 */
62 private $version;
63
64 /**
65 * Routeapp API instance
66 * @since 1.0.0
67 * @access private
68 * @var string $routeapp_api Routeapp API Client
69 */
70 public $routeapp_api_client;
71
72
73 /**
74 * Route Shipment Tracking Module
75 */
76 private $routeapp_shipment_tracking;
77
78 /**
79 * Last Request
80 */
81 private $routeapp_last_request;
82
83 /**
84 * Route Logger
85 */
86 private $routeapp_logger;
87
88 /**
89 * Route Admin Fee
90 */
91 private $routeapp_admin_fee;
92
93 /**
94 * Route Setup
95 */
96 private $route_app_setup_helper;
97
98 /**
99 * Whether the custom Route checkout block has been rendered on this request.
100 */
101 private $route_block_rendered = false;
102
103 /**
104 * Initialize the class and set its properties.
105 *
106 * @since 1.0.0
107 * @param string $plugin_name The name of the plugin.
108 * @param string $version The version of this plugin.
109 */
110 public function __construct($plugin_name, $version)
111 {
112
113 $this->plugin_name = $plugin_name;
114 $this->version = $version;
115 $this->routeapp_api_client = new Routeapp_API_Client($this->routeapp_get_public_token(), $this->routeapp_get_secret_token());
116 $this->routeapp_shipment_tracking = new Routeapp_Shipment_Tracking();
117 $this->routeapp_logger = new Routeapp_Logger_Sentry();
118 $this->routeapp_admin_fee = new Routeapp_Admin_Route_Fee();
119 $this->route_app_setup_helper = new Route_Setup();
120 Route_Setup::ensure_store_hash();
121 $this->add_actions_and_filters();
122 }
123
124 public function add_actions_and_filters() {
125 //CHECKOUT HOOK
126 $checkout_hook = $this->routeapp_get_checkout_hook();
127 add_action($checkout_hook, array($this, 'checkout_route_insurance'), 1, 0);
128
129
130 add_action('woocommerce_checkout_before_order_review', array($this, 'checkout_route_insurance_fee'), 20, 1);
131
132 add_action('woocommerce_cart_calculate_fees', array($this, 'checkout_route_insurance_fee'), 20, 1);
133 add_action('wp_ajax_woo_get_ajax_data', array($this, 'checkout_route_insurance_set_session'));
134 add_action('wp_ajax_nopriv_woo_get_ajax_data', array($this, 'checkout_route_insurance_set_session'));
135
136 add_action('wp_ajax_woo_check_widget', array($this, 'check_route_widget'));
137 add_action('wp_ajax_nopriv_woo_check_widget', array($this, 'check_route_widget'));
138
139 add_action( 'woocommerce_checkout_create_order_line_item', [ $this, 'routeapp_checkout_create_order_line_item' ], 10, 4 );
140 add_action( 'woocommerce_ajax_add_order_item_meta', [$this, 'routeapp_ajax_add_order_item_meta'], 10, 2 );
141 add_filter( 'woocommerce_hidden_order_itemmeta', [$this,'routeapp_woocommerce_hidden_order_itemmeta'], 10, 1);
142
143 add_action( 'added_post_meta', array( $this, 'routeapp_update_tracking_order_api' ), 20, 3 );
144 add_action( 'updated_post_meta', array( $this, 'routeapp_update_tracking_order_api' ), 20, 3 );
145
146 add_action('woocommerce_after_order_object_save', array($this, 'routeapp_update_tracking_order_api_HPOS'), 20, 2);
147
148 add_action( 'added_order_item_meta', array( $this, 'routeapp_update_tracking_order_item_api' ), 20, 3 );
149 add_action( 'updated_order_item_meta', array( $this, 'routeapp_update_tracking_order_item_api' ), 20, 3 );
150 add_action('woocommerce_order_status_processing', array($this, 'routeapp_mark_order'), 10, 1);
151 add_action('woocommerce_order_status_completed', array($this, 'routeapp_mark_order'), 8, 2);
152 add_action('woocommerce_new_order', array($this, 'routeapp_save_quote_to_order'), 10, 1);
153 add_action('rest_api_init', array($this, 'routeapp_register_status_route'));
154 add_action('rest_api_init', array($this, 'routeapp_register_setup_route'));
155 add_action('wp_ajax_get_route_checkout', array($this, 'routeapp_register_checkout_route'));
156 add_action('wp_ajax_nopriv_get_route_checkout', array($this, 'routeapp_register_checkout_route'));
157 add_action('wp_head', array($this, 'wc_routeapp_ajaxurl'));
158 add_action('woocommerce_init', array($this, 'register_shortcode'));
159 add_action('woocommerce_rest_prepare_order_note', array($this, 'check_order_note'));
160 add_action('wp_insert_comment', array($this, 'check_order_note_by_note_id'));
161
162 add_action('wp_ajax_routeapp_add_admin_fee', array($this, 'routeapp_add_admin_fee'));
163 add_action('wp_ajax_nopriv_routeapp_add_admin_fee', array($this, 'routeapp_add_admin_fee'));
164 add_action('wp_ajax_routeapp_remove_admin_fee', array($this, 'routeapp_remove_admin_fee'));
165 add_action('wp_ajax_nopriv_routeapp_remove_admin_fee', array($this, 'routeapp_remove_admin_fee'));
166 add_action('wp_ajax_routeapp_refresh_admin_fee_button', array($this, 'routeapp_refresh_admin_fee_button'));
167 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
170 add_action('woocommerce_order_details_after_order_table', array($this, 'order_updates_route_tracking'), 1, 1);
171
172 add_action('woocommerce_blocks_checkout_update_order_meta', array($this, 'routeapp_save_order_status_to_order'), 10, 1);
173 add_action('woocommerce_store_api_checkout_update_order_meta', array($this, 'routeapp_save_order_status_to_order'), 10, 1);
174
175 // Register callback for WooCommerce blocks checkout cart updates
176 add_action('woocommerce_blocks_loaded', array($this, 'routeapp_register_blocks_checkout_callback'));
177
178 // Inject Route widget before order summary in checkout block (default placement)
179 add_filter('render_block_woocommerce/checkout-order-summary-block', array($this, 'routeapp_checkout_block_before_order_summary'), 10, 3);
180
181 // Register the Route Protection custom checkout block
182 add_action('init', array($this, 'routeapp_register_checkout_block'));
183
184 // Register IntegrationInterface for WooCommerce Blocks
185 add_action('woocommerce_blocks_checkout_block_registration', array($this, 'routeapp_register_blocks_integration'));
186
187 // Enqueue widget assets early on cart/checkout for stable script order with Blocks SSR.
188 add_action('wp_enqueue_scripts', array($this, 'routeapp_maybe_enqueue_widget_assets'), 20);
189 add_action('woocommerce_blocks_enqueue_checkout_block_scripts_before', array($this, 'routeapp_maybe_enqueue_widget_assets'));
190 }
191
192 /**
193 * Hide order line_item meta_data from Route App
194 *
195 * @param $arr
196 * @return mixed
197 */
198 function routeapp_woocommerce_hidden_order_itemmeta($arr) {
199 $arr[] = '_routeapp_source_product_id';
200 $arr[] = '_routeapp_image_url';
201 $arr[] = '_routeapp_virtual';
202 $arr[] = '_routeapp_weight_value';
203 $arr[] = '_routeapp_weight_unit';
204 $arr[] = '_routeapp_origin_location';
205 return $arr;
206 }
207
208 /**
209 * Fill metadata
210 *
211 * @param $item WC_Order_Item
212 * @param $product WC_Product
213 * @param $entity_type enum('order_item','item')
214 * @return void
215 */
216 public static function fillMetaData($item, $product, $entity_type='order_item') {
217
218 $sourceProductId = $product->get_id();
219 $productImageUrl = (wp_get_attachment_url($product->get_image_id())) ? wp_get_attachment_url($product->get_image_id()) : null;
220 $weightValue = $product->get_weight();
221
222 $originLocation = json_encode([
223 'address' => get_option('woocommerce_store_address'),
224 'address2' => get_option('woocommerce_store_address_2'),
225 'store_city' => get_option('woocommerce_store_city'),
226 'store_postcode' => get_option('woocommerce_store_postcode'),
227 'default_country' => get_option('woocommerce_default_country'),
228 ]);
229
230
231 switch($entity_type) {
232 case 'order_item':
233 //add source_product_id as order line_item meta_data
234 self::addItemMeta($item, '_routeapp_source_product_id', $sourceProductId);
235
236 //add image_url as order line_item meta_data
237 self::addItemMeta($item, '_routeapp_image_url', $productImageUrl);
238
239 //add weight_value as order line_item meta_data
240 self::addItemMeta($item, '_routeapp_weight_value', $weightValue);
241
242 //add weight_unit as order line_item meta_data
243 self::addItemMeta($item, '_routeapp_weight_unit', get_option('woocommerce_weight_unit'));
244
245 //check if product is virtual
246 if ($product->is_virtual()) {
247 //add virtual as order line_item meta_data
248 self::addItemMeta($item, '_routeapp_virtual', 1);
249 }
250 //add origin location as order line_item meta_data
251 self::addItemMeta($item, '_routeapp_origin_location', $originLocation);
252 break;
253 case 'item':
254 //add source_product_id as order line_item meta_data
255 self::addOrderItemMeta($item->get_id(), '_routeapp_source_product_id', $sourceProductId);
256
257 //add image_url as order line_item meta_data
258 self::addOrderItemMeta($item->get_id(), '_routeapp_image_url', $productImageUrl);
259
260 //add weight_value as order line_item meta_data
261 self::addOrderItemMeta($item->get_id(), '_routeapp_weight_value', $weightValue);
262
263 //add weight_unit as order line_item meta_data
264 self::addOrderItemMeta($item->get_id(), '_routeapp_weight_unit', get_option('woocommerce_weight_unit'));
265
266 //check if product is virtual
267 if ($product->is_virtual()) {
268 //add virtual as order line_item meta_data
269 self::addOrderItemMeta($item->get_id(), '_routeapp_virtual', 1);
270 }
271 //add origin location as order line_item meta_data
272 self::addOrderItemMeta($item->get_id(), '_routeapp_origin_location', $originLocation);
273 break;
274 }
275 }
276
277 /**
278 * Add order line_item meta_data on ajax add to cart
279 *
280 * @param $item_id String
281 * @param $item WC_Order_Item
282 * @return false|void
283 */
284 public static function routeapp_ajax_add_order_item_meta( $item_id, $item) {
285 $product = $item->get_product(); // Get the WC_Product object
286 if ($product instanceof WC_Product) {
287 try {
288 self::fillMetaData($item, $product, 'item');
289 }catch (Exception $exception) {
290 return false;
291 }
292 }
293 }
294
295 /**
296 * @param $itemId WC_Order_Item ID
297 * @param $key String
298 * @param $value String
299 * @return bool
300 */
301 private static function addOrderItemMeta($itemId, $key, $value) {
302 try {
303 $fieldExists = wc_get_order_item_meta( $itemId, $key, true);
304 if (!$fieldExists) {
305 wc_add_order_item_meta($itemId, $key, $value);
306 }
307 }catch (Exception $exception) {
308 return false;
309 }
310 return true;
311 }
312
313 /**
314 * @param $item WC_Order_Item
315 * @param $key String
316 * @param $value String
317 * @return bool
318 */
319 private static function addItemMeta($item, $key, $value) {
320 try {
321 $fieldExists = $item->get_meta($key, true);
322 if (!$fieldExists) {
323 $item->add_meta_data( $key, $value );
324 }
325 }catch (Exception $exception) {
326 return false;
327 }
328 return true;
329 }
330
331 /**
332 * Add order line_item meta_data on checkout add to cart
333 *
334 * @param $item WC_Order_Item
335 * @param $cart_item_key String
336 * @param $values Object
337 * @param $order WC_Order
338 * @return false|void
339 */
340 public function routeapp_checkout_create_order_line_item( $item, $cart_item_key, $values, $order ) {
341 if ( $order instanceof WC_Order ) {
342 $product = $item->get_product(); // Get the WC_Product object
343 if ($product instanceof WC_Product) {
344 try {
345 self::fillMetaData($item, $product, 'order_item');
346 }catch (Exception $exception) {
347 return false;
348 }
349 }
350 }
351 }
352
353 public function routeapp_add_admin_fee() {
354 $this->routeapp_admin_fee->routeapp_add_admin_fee();
355 }
356
357 public function routeapp_remove_admin_fee() {
358 $this->routeapp_admin_fee->routeapp_remove_admin_fee();
359 }
360
361 public function routeapp_refresh_admin_fee_button() {
362 $this->routeapp_admin_fee->routeapp_refresh_admin_fee_button();
363 }
364
365 public function register_shortcode() {
366 add_shortcode( 'route', array( $this, 'route_widget_shortcode' ) );
367 }
368
369 public function get_allowed_quote_type() {
370 return self::ALLOWED_QUOTE_TYPE;
371 }
372
373 public function check_route_widget() {
374 if (!isset($_POST) || !$_POST['shipping_method']) {
375 wp_send_json_error('its missing shipping method on the request');
376 return;
377 }
378 $shipping_method = isset($_POST['shipping_method']) ? $_POST['shipping_method'] : '';
379 $response = [];
380 $response['show_widget'] = $this->routeapp_is_shipping_method_allowed($shipping_method);
381 wp_send_json_success( $response );
382 return;
383 }
384
385 /**
386 * set routeapp session
387 *
388 * @since 1.0.0
389 */
390 public function checkout_route_insurance_set_session() {
391 if ( isset($_POST['checkbox']) && $_POST['checkbox'] === 'true' ){
392 WC()->session->set('checkbox_checked', true );
393 } else {
394 WC()->session->set( 'checkbox_checked', false );
395 }
396 }
397
398 /**
399 * Add route insurance fee in checkout and cart
400 * @since 1.0.0
401 * @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 */
408 public function checkout_route_insurance_fee( $cart_or_checkout ) {
409 if ( (is_admin() && ! defined( 'DOING_AJAX' )) ) return;
410
411 // Determine if we received a WC_Cart or WC_Checkout object
412 $is_checkout_object = is_a( $cart_or_checkout, 'WC_Checkout' );
413 $cart = $is_checkout_object ? WC()->cart : $cart_or_checkout;
414
415 // Use WC()->cart as fallback if not a valid WC_Cart
416 if ( is_null( $cart ) || ! is_a( $cart, 'WC_Cart' ) ) {
417 $cart = WC()->cart;
418 }
419
420 $checkbox = WC()->session->get( 'checkbox_checked' );
421
422 $cartRef = $cart->get_cart_hash();
423 $cartTotal = round($this->get_cart_subtotal_with_only_shippable_items($cart), 2);
424 $currency = get_woocommerce_currency();
425 $cartItems = $this->get_cart_shippable_items($cart);
426 $route_insurance_quote = $this->routeapp_get_quote_from_api($cartRef, $cartTotal, $currency, $cartItems);
427 $this->route_insurance_data = $route_insurance_quote;
428
429 $route_insurance_amount = false;
430 //only add fee if the quote is covered by customer and has a valid amount
431 if (isset($route_insurance_quote->premium->amount) &&
432 (isset($route_insurance_quote->payment_responsible->type)) && $route_insurance_quote->payment_responsible->type==self::ALLOWED_QUOTE_TYPE) {
433 $route_insurance_amount = $route_insurance_quote->premium->amount;
434 }
435
436 if (is_null($checkbox) &&
437 isset($route_insurance_quote->payment_responsible) &&
438 property_exists($route_insurance_quote->payment_responsible, 'ToggleState')) {
439 $checkbox = $route_insurance_quote->payment_responsible->ToggleState;
440 WC()->session->set('checkbox_checked', $checkbox);
441 }
442
443 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()) {
445 $cart->add_fee( $this->routeapp_get_insurance_label(),
446 $route_insurance_amount,
447 $this->routeapp_get_fee_taxable(),
448 $this->routeapp_get_taxable_class());
449 }
450 }
451
452 /**
453 * Create checkout
454 * @since 1.0.0
455 *
456 */
457 public function checkout_route_insurance() {
458 echo do_shortcode('[route]');
459 }
460
461 public function order_updates_route_tracking($order) {
462 if ($this->routeapp_route_show_order_updates()) {
463 echo $this->route_show_thankyoupage_asset($order);
464 }
465 }
466
467 public function route_show_thankyoupage_asset($order) {
468 $output = '';
469
470 $site = parse_url(get_site_url(get_current_blog_id()), PHP_URL_HOST);
471 $response = Routeapp_API_Client::getInstance()->asset_settings($site);
472
473 if (is_wp_error($response) ||
474 (isset($response['response']['code']) && $response['response']['code'] == 404) ||
475 is_null($response['body'])) {
476 return $output;
477 } else {
478
479 $body = json_decode($response['body']);
480
481 if (!is_null($body) &&
482 !is_null($body->asset_settings) &&
483 !is_null($body->asset_settings->asset_live) &&
484 $body->asset_settings->asset_live) {
485 $raw_html = $body->asset_settings->asset_content->raw_html;
486 $css = $body->asset_settings->asset_content->css_url;
487 $js = isset($body->asset_settings->asset_content->js_url) ? $body->asset_settings->asset_content->js_url : '';
488 $jsPath = '';
489 if (!empty($js)) {
490 $jsPath = '<script type="text/javascript" src="' . $js . '"></script>';
491 }
492 $output = '<link rel="stylesheet" href="' . $css . '" type="text/css" />' .
493 $jsPath .
494 '<section class=woocommerce-order-updates>' .
495 '<h2 class="woocommerce-order-updates__title">Order Updates</h2>' .
496 '</section>' .
497 '<div><span class="os-order-number" style="display:none;">'.$order->get_id().'</span></div>'.
498 '<div>' .
499 $raw_html .
500 '</div>' .
501 '<div>&nbsp;</div>';
502
503 wp_enqueue_script($this->plugin_name . '-analytics', 'https://cdn.routeapp.io/route-analytics/route-analytics.js', array(), $this->version, false);
504 wp_enqueue_script($this->plugin_name, plugin_dir_url(__FILE__) . 'js/routeapp-analytics.js', array('jquery'), $this->version, false);
505 }
506 }
507
508 return $output;
509 }
510
511 /**
512 * Enqueue Route widget JS/CSS with correct dependency order.
513 * routeapp-public-pbc.js must load after route-protection-widget.js (Route.Protection).
514 */
515 public function routeapp_enqueue_widget_assets() {
516 if ( is_admin() || empty( $this->routeapp_get_public_token() ) ) {
517 return;
518 }
519 if ( ! function_exists( 'WC' ) || ! WC()->cart ) {
520 return;
521 }
522
523 $custom_env = getenv( 'ROUTEAPP_ENVIRONMENT_ENDPOINT' );
524 if ( is_null( $custom_env ) || ! $custom_env ) {
525 $custom_env = isset( $_SERVER['ROUTEAPP_ENVIRONMENT_ENDPOINT'] ) ? $_SERVER['ROUTEAPP_ENVIRONMENT_ENDPOINT'] : '';
526 }
527 $custom_env = empty( $custom_env ) ? 'production' : $custom_env;
528 if ( ! isset( self::AMPLITUDE_API_KEY[ $custom_env ] ) ) {
529 $custom_env = 'production';
530 }
531
532 $widget_handle = $this->plugin_name . '-widget';
533 wp_enqueue_script(
534 $widget_handle,
535 'https://protection-widget.route.com/route-protection-widget.js',
536 array(),
537 $this->version,
538 true
539 );
540
541 $amplitude_key = self::AMPLITUDE_API_KEY[ $custom_env ];
542
543 // Load Amplitude SDK (standard version, not snippet)
544 wp_enqueue_script( 'amplitude-js', "https://cdn.amplitude.com/script/$amplitude_key.js", array(), null, true );
545 // Enqueue Amplitude Analytics module (depends on jQuery and Amplitude SDK)
546 wp_enqueue_script(
547 $this->plugin_name . '-amplitude-analytics',
548 plugin_dir_url( __FILE__ ) . 'js/amplitude-analytics.js',
549 array( 'jquery', 'amplitude-js' ),
550 $this->version,
551 true
552 );
553 $pbc_deps = array( 'jquery', $widget_handle, $this->plugin_name . '-amplitude-analytics' );
554 if ( $this->routeapp_should_add_wc_blocks_checkout_dependency() ) {
555 $pbc_deps[] = 'wc-blocks-checkout';
556 }
557
558 /**
559 * Filter script dependencies for routeapp-public-pbc.js.
560 *
561 * @param string[] $pbc_deps Script handles routeapp-public-pbc.js depends on.
562 */
563 $pbc_deps = apply_filters( 'routeapp_pbc_script_dependencies', $pbc_deps );
564
565 // Main script depends on amplitude-analytics when available.
566 wp_enqueue_script(
567 $this->plugin_name,
568 plugin_dir_url( __FILE__ ) . 'js/routeapp-public-pbc.js',
569 $pbc_deps,
570 $this->version,
571 true
572 );
573
574 /**
575 * Calculate total Route fee
576 * Uses the premium amount from route insurance data if available,
577 * otherwise defaults to 0.98 as a fallback fee amount
578 */
579 $totalRouteFee = ( is_object( $this->route_insurance_data ) && isset( $this->route_insurance_data->premium->amount ) )
580 ? (float) $this->route_insurance_data->premium->amount
581 : 0.98;
582
583 // Pass cart data to JavaScript - send to amplitude-analytics.js
584 wp_localize_script(
585 $this->plugin_name . '-amplitude-analytics',
586 'cartData',
587 array(
588 'cartId' => WC()->cart->get_cart_hash(),
589 'amplitudeKey' => $amplitude_key,
590 'widgetId' => uniqid( 'widget-', true ),
591 'subtotal' => round( $this->get_cart_subtotal_with_only_shippable_items( WC()->cart ), 2 ),
592 'totalRouteFee' => $totalRouteFee,
593 'environment' => $custom_env,
594 'currency' => get_woocommerce_currency(),
595 'merchantId' => $this->routeapp_api_client->get_merchant_id(),
596 )
597 );
598
599 wp_enqueue_style(
600 $this->plugin_name . '-widget',
601 plugin_dir_url( __FILE__ ) . 'css/routeapp-widget.css',
602 array(),
603 $this->version,
604 'all'
605 );
606 }
607
608 /**
609 * Enqueue widget assets on cart, checkout, and Blocks checkout script hook.
610 */
611 public function routeapp_maybe_enqueue_widget_assets() {
612 if ( is_admin() || empty( $this->routeapp_get_public_token() ) ) {
613 return;
614 }
615 if ( function_exists( 'is_cart' ) && function_exists( 'is_checkout' ) && ! is_cart() && ! is_checkout() ) {
616 return;
617 }
618 $this->routeapp_enqueue_widget_assets();
619 }
620
621 public function route_widget_shortcode()
622 {
623 if(is_admin() || empty($this->routeapp_get_public_token())) return;
624
625 // Enqueue only outside cart/checkout; wp_enqueue_scripts handles those pages.
626 if ( function_exists( 'is_cart' ) && function_exists( 'is_checkout' ) && ! is_cart() && ! is_checkout() ) {
627 $this->routeapp_enqueue_widget_assets();
628 }
629
630 $custom_env = getenv('ROUTEAPP_ENVIRONMENT_ENDPOINT');
631 if (is_null($custom_env) || !$custom_env) {
632 $custom_env = isset($_SERVER['ROUTEAPP_ENVIRONMENT_ENDPOINT']) ? $_SERVER['ROUTEAPP_ENVIRONMENT_ENDPOINT'] : '';
633 }
634 $custom_env = empty($custom_env) ? 'production' : $custom_env;
635
636 global $wp;
637 $parsedUrl = parse_url(home_url( $wp->request));
638
639 switch($custom_env) {
640 case 'stage':
641 $custom_env = 'Route.Environment.Dev';
642 break;
643 default:
644 $custom_env = 'Route.Environment.Production';
645 break;
646 }
647 $checkbox = WC()->session->get( 'checkbox_checked' );
648 $checkbox = $checkbox ? 'Route.Coverage.ActiveByDefault' : 'Route.Coverage.InactiveByDefault';
649
650 $divId = is_checkout() ? 'route-checkout-radio' : 'route-cart-radio';
651
652 $return = '<div id="'.$divId.'">' .
653 '<input type="hidden" value="'. get_woocommerce_currency() .'" class="routeapp-currency">'.
654 '<input type="hidden" value="'. round($this->get_cart_subtotal_with_only_shippable_items(WC()->cart), 2) .'" class="routeapp-subtotal">'.
655 '<input type="hidden" value="' . admin_url('admin-ajax.php') . '" class="routeapp-ajax-url">'.
656 '<input type="hidden" value="' . $parsedUrl['host'] . '" class="routeapp-store-domain">'.
657 '<input type="hidden" value="' . get_option('blogname') . '" class="routeapp-store-name">'.
658 '<input type="hidden" value="' . $this->routeapp_api_client->get_merchant_id() . '" class="routeapp-merchant-id">'.
659 '<input type="hidden" value="' . $custom_env . '" class="routeapp-env">'.
660 '<input type="hidden" value="' . $checkbox .'" class="routeapp-checkbox-cookie">';
661
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 }
671 }
672 return $return . '<div id="RouteWidget" class="route-div"></div></div>' ;
673 }
674
675
676 public function wc_routeapp_ajaxurl() {
677 echo '<script type="text/javascript">' .
678 'var wc_routeapp_ajaxurl = "' . admin_url('admin-ajax.php') . '"' .
679 '</script>';
680 }
681
682 /**
683 * get amount from RouteApp API
684 *
685 * @param $cartRef
686 * @param $cartTotal
687 * @param $currency
688 * @param $cartItems
689 * @return false|mixed
690 */
691 public function routeapp_get_quote_from_api($cartRef, $cartTotal, $currency, $cartItems) {
692 if (!$cartRef) return false;
693 if (!$cartTotal) return false;
694 $currency = !$currency ? get_woocommerce_currency() : $currency;
695
696 $response = $this->routeapp_api_client->get_quote($cartRef, $cartTotal, $currency, $cartItems);
697
698 if (is_wp_error($response)) {
699 return false;
700 } else {
701 if (isset($response['response']['code']) && $response['response']['code'] == 401) {
702 return false;
703 }
704 $price_data = json_decode($response['body']);
705 Route_Setup::set_setup_check_widget(Route_Setup::SETUP_CHECK_WIDGET_JS);
706 return $price_data;
707 }
708 }
709
710 /**
711 * create order data sending for API
712 * @since 1.0.0
713 * @param integer $order_id
714 */
715 public function create_order_data_api($order_id) {
716
717 $order = wc_get_order( $order_id );
718
719 if ( class_exists('Automattic\WooCommerce\Utilities\OrderUtil')
720 && OrderUtil::custom_orders_table_usage_is_enabled() ) {
721 // HPOS usage is enabled.
722 $quote = $order->get_meta('_routeapp_quote');
723 } else {
724 // Traditional CPT-based orders are in use.
725 $quote = get_post_meta($order->get_id(), '_routeapp_quote', true);
726 }
727
728 $insurance_selected = false;
729 $insurance_amount = false;
730
731 foreach ($order->get_items('fee') as $fee) {
732 if ($fee->get_name() === $this->routeapp_get_insurance_label()) {
733 $insurance_selected = true;
734 $insurance_amount = $fee->get_amount();
735 if ( class_exists('Automattic\WooCommerce\Utilities\OrderUtil')
736 && OrderUtil::custom_orders_table_usage_is_enabled() ) {
737 // HPOS usage is enabled.
738 $order->update_meta_data('_routeapp_route_charge', $fee->get_amount() );
739 $order->save();
740 } else {
741 // Traditional CPT-based orders are in use.
742 update_post_meta( $order->get_id(), '_routeapp_route_charge', $fee->get_amount() );
743 }
744 }
745 }
746 $quoteId = 'Invalid';
747 if ($quote) {
748 $quoteDecoded = json_decode($quote);
749 $quoteId = $quoteDecoded->id;
750 }
751
752 $destination = array(
753 "first_name" => $order->get_billing_first_name(),
754 "last_name" => $order->get_billing_last_name(),
755 "street_address1" => $order->get_shipping_address_1(),
756 "street_address2" => $order->get_shipping_address_2(),
757 "city" => $order->get_shipping_city(),
758 "province" => $order->get_shipping_state(),
759 "zip" => $order->get_shipping_postcode(),
760 "country_code" => $order->get_shipping_country(),
761 );
762 $customerDetails = array(
763 "first_name" => $order->get_billing_first_name(),
764 "last_name" => $order->get_billing_last_name(),
765 "email" => $order->get_billing_email(),
766 "phone" => $order->get_billing_phone()
767 );
768 $order_data = $order->get_data();
769 $is_shipping_method_allowed = $this->routeapp_is_shipping_method_allowed(false, $order);
770
771 $itemdata = array();
772
773 foreach ($order->get_items() as $item) {
774 ## Using WC_Order_Item_Product methods ##
775 $product = $item->get_product(); // Get the WC_Product object
776
777 if ($product instanceof WC_Product) {
778
779 $isProductVirtual = (bool)$product->is_virtual();
780
781 $arrItem = array(
782 "delivery_method" => $isProductVirtual ? 'digital' :
783 ($is_shipping_method_allowed ? 'ship_to_home' : 'ship_to_store'),
784 "is_insured" => $isProductVirtual ? false :
785 ($is_shipping_method_allowed ? true : false),
786 "source_product_id" => $product->get_id(),
787 "source_id" => strval($item->get_id()),
788 "sku" => $product->get_sku(),
789 "name" => $product->get_title(),
790 "price" => $product->get_price(),
791 "quantity" => $item->get_quantity(),
792 "image_url" => (wp_get_attachment_url($product->get_image_id())) ? wp_get_attachment_url($product->get_image_id()) : null,
793 "upc" => ""// WooCommerce doesn’t provide UPC by default
794 );
795
796 array_push($itemdata, $arrItem);
797 }
798
799 }
800
801 if ( class_exists('Automattic\WooCommerce\Utilities\OrderUtil')
802 && OrderUtil::custom_orders_table_usage_is_enabled() ) {
803 // HPOS usage is enabled.
804 $order->update_meta_data('_routeapp_route_protection', $insurance_selected );
805 $order->update_meta_data('_routeapp_version', ROUTEAPP_VERSION );
806
807 $order->save();
808 } else {
809 // Traditional CPT-based orders are in use.
810 update_post_meta( $order->get_id(), '_routeapp_route_protection', $insurance_selected );
811 update_post_meta( $order->get_id(), '_routeapp_version', ROUTEAPP_VERSION );
812 }
813
814 $orderData = array(
815 'quote_id' => $quoteId,
816 'source_order_id' => $order->get_id(),
817 'source_order_number' => $order->get_id(),
818 'subtotal' => $order->get_subtotal(),
819 'order_total' => $order->get_total(),
820 'shipping_total' => $order->get_shipping_total(),
821 'discounts_total' => $order->get_discount_total(),
822 'source_created_on' => date(DATE_ATOM, strtotime($order->get_date_created())),
823 'source_updated_on' => date(DATE_ATOM, strtotime($order->get_date_modified())),
824 'taxes' => $order_data['total_tax'],
825 'insurance_selected' => $insurance_selected,
826 'customer_details' => $customerDetails,
827 'shipping_details' => $destination,
828 'line_items' => $itemdata,
829 'currency' => $order->get_currency(),
830 'amount_covered' => $this->get_order_subtotal($order),
831 'origin' => 'woocommerce-module',
832 );
833 //if customer covers the order, pass the paid to insure with Route Fee value
834 if ($insurance_selected && $insurance_amount) {
835 $orderData['paid_to_insure'] = $insurance_amount;
836 }
837
838 return $orderData;
839 }
840
841 /**
842 * Update tracking order data sending for API
843 * @since 1.0.0
844 * @param integer $item_id
845 * @param object $order
846 * @param string $post
847 */
848 public function routeapp_update_tracking_order_api($item_id, $order, $post) {
849 if (is_null($order) || get_post_type($order) !== 'shop_order' || $post == '_edit_lock') {
850 return false;
851 }
852 $this->routeapp_shipment_tracking->update($order, $this);
853 }
854
855 /**
856 * Update tracking order data sending when HPOS is enable
857 * @param object $order
858 * @param object $data_store
859 */
860 public function routeapp_update_tracking_order_api_HPOS($order, $data_store) {
861 static $already_executed = false;
862
863 if ($already_executed) {
864 return false; // Prevent recursion
865 }
866
867 $already_executed = true;
868
869 // Ensure the $order object is valid and of type 'shop_order'
870 if (!$order || $order->get_type() !== 'shop_order') {
871 return false;
872 }
873
874 $this->routeapp_shipment_tracking->update($order->get_id(), $this);
875 }
876
877 /**
878 * Update tracking order item data sending for API
879 * @since 1.0.0
880 * @param integer $order_id
881 * @param object $post
882 * @param boolean $update
883 */
884 public function routeapp_update_tracking_order_item_api($item_id, $order_item_id, $post) {
885 if (is_null($order_item_id) ||
886 !is_admin() ||
887 $this->_isShippingTrackingCall($post)
888 ) {
889 return false;
890 }
891 $this->routeapp_shipment_tracking->update($order_item_id, $this);
892 }
893
894 /**
895 * Check if the post comes for shipment tracking
896 * @param $post
897 * @return bool
898 */
899 private function _isShippingTrackingCall($post) {
900 $isNotShippingTracking = $post != '_wc_shipment_tracking_items' &&
901 $post != '_bst_tracking_number' &&
902 $post != 'mimo_tracking_number' &&
903 $post != '_aftership_tracking_number' &&
904 $post != 'ywot_tracking_code' &&
905 $post != 'ph_shipment_tracking_shipping_service' &&
906 $post != 'ph_shipment_tracking_ids' &&
907 $post != 'usps_evs_label_details_array' &&
908 $post != '_order_trackno' &&
909 $post != '_order_trackno1' &&
910 $post != '_order_trackno2' &&
911 $post != '_order_trackno3' &&
912 $post != '_order_trackno4' &&
913 $post != '_vi_wot_order_item_tracking_data' &&
914 $post != 'wc_connect_labels';
915
916 return apply_filters('routeapp_meta_key_is_not_shipping_related', $isNotShippingTracking, $post);
917 }
918
919
920 public function routeapp_update_merchant_api( $merchant_id, $merchantData ) {
921 if ( is_null($merchant_id) ) {
922 return false;
923 }
924
925 $response = $this->routeapp_api_client->update_merchant( $merchant_id, $merchantData );
926 if ( is_wp_error($response) ) {
927 return false;
928 }
929
930 return true;
931 }
932
933
934 /**
935 * Updates Route-related metadata to the order after it is placed in draft status.
936 *
937 * This method is triggered when an order is created in draft status and is responsible for
938 * adding meta fields with the list Route sync status.
939 * This prevents incorrect synchronization of draft orders.
940 *
941 * @param int $order_id The ID of the WooCommerce order.
942 */
943 public function routeapp_save_order_status_to_order( $order_id ) {
944 if ( ! $order_id || ! WC()->session ) {
945 return;
946 }
947
948 try {
949 $order = wc_get_order( $order_id );
950
951 if ( ! $order ) {
952 throw new Exception( "Order with ID {$order_id} could not be retrieved." );
953 }
954 $is_hpos = class_exists('Automattic\WooCommerce\Utilities\OrderUtil')
955 && OrderUtil::custom_orders_table_usage_is_enabled();
956
957 $acceptedOrderStatuses = get_option('routeapp_included_order_statuses') ? get_option('routeapp_included_order_statuses') : [];
958 $acceptedCanceledStatuses = get_option('routeapp_cancel_order_statuses') ? get_option('routeapp_cancel_order_statuses') : [];
959
960 $updated = false;
961 $updated |= $this->updateRouteOrderMetaData( $order, $is_hpos, '_routeapp_included_order_statuses', $acceptedOrderStatuses );
962 $updated |= $this->updateRouteOrderMetaData( $order, $is_hpos, '_routeapp_cancel_order_statuses', $acceptedCanceledStatuses );
963
964 if ($updated && $is_hpos) {
965 $order->save();
966 }
967 } catch ( Exception $e ) {
968 error_log( "An error occurred while updating Route order status meta fields for order ID {$order_id}: " . $e->getMessage() );
969 }
970 }
971
972
973 /**
974 * Save into order meta_data the quote from the last request made on session
975 *
976 * @param $order_id
977 */
978 public function routeapp_save_quote_to_order($order_id )
979 {
980 if (!$order_id && !WC()->session) {
981 return;
982 }
983 $order = wc_get_order($order_id);
984 if (!$order) {
985 return;
986 }
987 //MSS-6248, Support High-Performance Order Storage (HPOS); fall back to legacy order storage when disabled.
988 $is_hpos = class_exists('Automattic\WooCommerce\Utilities\OrderUtil')
989 && OrderUtil::custom_orders_table_usage_is_enabled();
990
991 $updated = false;
992
993 if ($is_hpos) {
994 $quote = $order->get_meta('_routeapp_quote');
995 } else {
996 // Traditional CPT-based orders are in use.
997 $quote = get_post_meta($order->get_id(), '_routeapp_quote', true);
998 }
999
1000 if (!$quote) {
1001 $cartRef = $order->get_cart_hash();
1002 if (!$cartRef) {
1003 //create uniq ID for backend orders
1004 $cartRef = uniqid();
1005 }
1006 $main_key = $this->routeapp_api_client->get_cache_api_session_key().'-'.$cartRef;
1007 $key = $main_key.'-latest';
1008 if (WC()->session) {
1009 $cached = WC()->session->get($key);
1010 if (isset($cached) && is_array($cached) && isset($cached['body'])) {
1011
1012 $fees = $order->get_items('fee');
1013 if (!empty($fees)) {
1014 foreach ($fees as $fee) {
1015 if ($fee->get_name() === $this->routeapp_get_insurance_label()) {
1016 $insurance_amount = $fee->get_amount();
1017 $updated |= $this->updateRouteOrderMetaData( $order, $is_hpos, '_routeapp_route_charge', $insurance_amount );
1018 $updated |= $this->updateRouteOrderMetaData( $order, $is_hpos, '_routeapp_route_protection', true );
1019 }
1020 }
1021 } else {
1022 try {
1023 // Check if WC()->cart exists and is accessible
1024 if (class_exists('WC_Cart') && isset(WC()->cart) && WC()->cart instanceof WC_Cart) {
1025 // Fallback to WC()->cart->get_fees() if no fees are present in the order
1026 $cart_fees = WC()->cart->get_fees();
1027 if (!empty($cart_fees)) {
1028 foreach ($cart_fees as $cart_fee) {
1029 if ($cart_fee->name === $this->routeapp_get_insurance_label()) {
1030 $insurance_amount = $cart_fee->amount; // Correct method for cart fee amount
1031 $updated |= $this->updateRouteOrderMetaData( $order, $is_hpos, '_routeapp_route_charge', $insurance_amount );
1032 $updated |= $this->updateRouteOrderMetaData( $order, $is_hpos, '_routeapp_route_protection', true );
1033 }
1034 }
1035 }
1036 }
1037 } catch (Exception $e) {
1038 // Log the exception and ensure the application flow continues
1039 error_log("An error occurred while processing cart fees: " . $e->getMessage());
1040 }
1041
1042 }
1043 $updated |= $this->updateRouteOrderMetaData( $order, $is_hpos, '_routeapp_quote', $cached['body'] );
1044
1045 //clear cache!
1046 WC()->session->__unset($main_key);
1047 WC()->session->__unset($key);
1048 }
1049 }
1050 }
1051
1052 //Add orders status meta data
1053 $acceptedOrderStatuses = get_option('routeapp_included_order_statuses') ? get_option('routeapp_included_order_statuses') : [];
1054 $acceptedCanceledStatuses = get_option('routeapp_cancel_order_statuses') ? get_option('routeapp_cancel_order_statuses') : [];
1055
1056 $updated |= $this->updateRouteOrderMetaData( $order, $is_hpos, '_routeapp_included_order_statuses', $acceptedOrderStatuses );
1057 $updated |= $this->updateRouteOrderMetaData( $order, $is_hpos, '_routeapp_cancel_order_statuses', $acceptedCanceledStatuses );
1058
1059 //MSS-6533: Add ship to store flag and excluded shipping methods meta data to the order
1060 $updated |= $this->updateRouteOrderMetaData( $order, $is_hpos,'_routeapp_order_ship_to_store', $this->routeapp_resolve_ship_to_store_flag( $order ) );
1061 $updated |= $this->updateRouteOrderMetaData( $order, $is_hpos, '_routeapp_excluded_shipping_methods', $this->routeapp_get_excluded_shipping_methods());
1062
1063 // Save order if there is any update
1064 if ($updated && $is_hpos) {
1065 $order->save();
1066 }
1067 }
1068
1069 /**
1070 * Mark order on woocommerce thank you hook
1071 * @since 1.0.0
1072 * @param integer $order_id
1073 *
1074 */
1075 public function routeapp_mark_order($order_id)
1076 {
1077 if (!$order_id) {
1078 return;
1079 }
1080
1081 $order = wc_get_order($order_id);
1082
1083 //order cancel action will be handled by webhook event
1084 if ($order->get_status() === 'cancelled') {
1085 return;
1086 }
1087
1088 // Init the setup for readiness report
1089 $this->_setup_readiness_report_check();
1090
1091 if (WC()->session) {
1092 WC()->session->__unset('checkbox_checked');
1093 }
1094 }
1095
1096 /**
1097 * Check order note (look for shipment tracking data on it)
1098 * @param $noteId id
1099 * @return mixed
1100 */
1101 public function check_order_note_by_note_id($noteId)
1102 {
1103 $comment = get_comment($noteId);
1104 $order = wc_get_order($comment->comment_post_ID);
1105 if ($order && $order->get_id()) {
1106 $this->routeapp_shipment_tracking->parse_order_notes($order->get_id(), $this);
1107 }
1108 }
1109
1110 /**
1111 * Check order note (look for shipment tracking data on it)
1112 * @param $response WP_REST_Response
1113 * @return mixed
1114 */
1115 public function check_order_note($response)
1116 {
1117 $note = $response->get_data();
1118 $comment = get_comment($note['id']);
1119 $order = wc_get_order($comment->comment_post_ID);
1120 if ($order && $order->get_id()) {
1121 $this->routeapp_shipment_tracking->parse_order_notes($order->get_id(), $this);
1122 }
1123 return $response;
1124 }
1125 /**
1126 * Cancel the tracking order sending for API
1127 * @since 1.0.0
1128 * @param integer $order_id
1129 * @param integer $tracking_number
1130 * @param array $product_ids
1131 */
1132
1133 public function routeapp_cancel_tracking_order( $order_id, $tracking_number, $product_ids ) {
1134 if ( is_null($order_id) ) {
1135 return false;
1136 }
1137 $this->routeapp_shipment_tracking->cancel($order_id, $tracking_number, $product_ids, $this);
1138 }
1139
1140 /**
1141 * Create user at Route
1142 * @param $data
1143 * @return array|bool|mixed|object
1144 */
1145 public function routeapp_create_user($data){
1146 $response = $this->routeapp_api_client->create_user($data);
1147
1148 $this->routeapp_last_request = $response;
1149
1150 if ( is_wp_error( $response ) ) {
1151 return false;
1152 } else {
1153 $body = json_decode($response['body']);
1154
1155 if ($response['response']['code'] != self::HTTP_CREATED_CODE) {
1156 return false;
1157 }
1158
1159 return $body;
1160 }
1161 }
1162
1163 /**
1164 * Create user at Route
1165 *
1166 * @param $username
1167 * @param $password
1168 *
1169 * @return array|bool|mixed|object
1170 */
1171 public function routeapp_user_login($username, $password){
1172 $response = $this->routeapp_api_client->login_user($username, $password);
1173
1174 $this->routeapp_last_request = $response;
1175
1176 if ( is_wp_error( $response ) ) {
1177 return false;
1178 } else {
1179 $body = json_decode($response['body']);
1180
1181 if ($response['response']['code'] != self::HTTP_SUCCESS_CODE) {
1182 return false;
1183 }
1184
1185 return $body;
1186 }
1187 }
1188
1189 /**
1190 * Creates merchant at Route
1191 *
1192 * @param $data
1193 * @return array|bool|mixed|object
1194 */
1195 public function routeapp_create_merchant($data){
1196
1197 $response = $this->routeapp_api_client->create_merchant($data);
1198 $this->routeapp_last_request = $response;
1199
1200 if ( is_wp_error( $response ) ) {
1201 return false;
1202 } else {
1203 $body = json_decode($response['body']);
1204
1205 if ($response['response']['code'] != self::HTTP_CREATED_CODE && $response['response']['code'] != self::HTTP_SUCCESS_CODE) {
1206 return false;
1207 }
1208
1209 return $body;
1210 }
1211 }
1212
1213 /**
1214 * Creates merchant at Route
1215 *
1216 * @param $data
1217 * @return array|bool|mixed|object
1218 */
1219 public function routeapp_get_merchants(){
1220
1221 $response = $this->routeapp_api_client->get_merchants();
1222 $this->routeapp_last_request = $response;
1223
1224 if ( is_wp_error( $response ) ) {
1225 return false;
1226 } else {
1227 $body = json_decode($response['body']);
1228
1229 if ($response['response']['code'] != self::HTTP_CREATED_CODE && $response['response']['code'] != self::HTTP_SUCCESS_CODE) {
1230 return false;
1231 }
1232
1233 return $body;
1234 }
1235 }
1236
1237 /**
1238 * Activate user account
1239 *
1240 * @param $email
1241 *
1242 * @return array|bool|mixed|object
1243 */
1244 public function routeapp_activate_account($email){
1245
1246 $response = $this->routeapp_api_client->activate_account(['email' => $email]);
1247 $this->routeapp_last_request = $response;
1248
1249 if ( is_wp_error( $response ) ) {
1250 return false;
1251 } else {
1252 $body = json_decode($response['body']);
1253
1254 if ($response['response']['code'] != self::HTTP_CREATED_CODE && $response['response']['code'] != self::HTTP_SUCCESS_CODE) {
1255 return false;
1256 }
1257
1258 return $body;
1259 }
1260 }
1261
1262 public function routeapp_is_shipping_method_allowed($shipping_method=false, $order=false) {
1263
1264 if ($shipping_method || $order) {
1265 if ($shipping_method) {
1266 $shipping_method = explode(':', $shipping_method);
1267 $shipping_method = $shipping_method[0];
1268 }
1269 if ($order) {
1270 $shipping_method = @array_shift($order->get_shipping_methods());
1271 if(isset($shipping_method['method_id']))
1272 {
1273 $shipping_method = $shipping_method['method_id'];
1274 } else {
1275 return false;
1276 }
1277 }
1278 $excludedShippingMethods = $this->routeapp_get_excluded_shipping_methods();
1279 if (is_array($excludedShippingMethods) && in_array($shipping_method, $excludedShippingMethods)) {
1280 return false;
1281 }
1282 } else {
1283 if (WC()->session->get('shipping_for_package_0')) {
1284 foreach( WC()->session->get('shipping_for_package_0')['rates'] as $method_id => $rate ){
1285 if( WC()->session->get('chosen_shipping_methods')[0] == $method_id ){
1286 $shippingMethod = explode(':', $rate->id);
1287 $shippingMethod = $shippingMethod[0];
1288 $excludedShippingMethods = $this->routeapp_get_excluded_shipping_methods();
1289 if (in_array($shippingMethod, $excludedShippingMethods)) {
1290 return false;
1291 }
1292 }
1293 }
1294 }
1295 }
1296 return true;
1297 }
1298
1299 public function routeapp_get_insurance_label() {
1300 return __(self::ROUTE_LABEL, 'routeapp');
1301 }
1302
1303 public function routeapp_is_opt_in() {
1304 $merchant = $this->routeapp_api_client->get_merchant();
1305 return isset($merchant->merchant_preferences->opt_in) && $merchant->merchant_preferences->opt_in == true;
1306 }
1307
1308 public function routeapp_get_public_token() {
1309 return get_option('routeapp_public_token');
1310 }
1311
1312 public static function routeapp_get_taxable_class() {
1313 return get_option('routeapp_taxable_class');
1314 }
1315
1316 public static function routeapp_get_checkout_hook() {
1317 return get_option('routeapp_checkout_hook') ? get_option('routeapp_checkout_hook') : self::DEFAULT_CHECKOUT_WEBHOOK;
1318 }
1319
1320 public static function routeapp_get_checkout_hook_options() {
1321 return [
1322 'woocommerce_before_checkout_form' => 'woocommerce_before_checkout_form',
1323 'woocommerce_checkout_before_customer_details' => 'woocommerce_checkout_before_customer_details',
1324 'woocommerce_checkout_billing' => 'woocommerce_checkout_billing',
1325 'woocommerce_before_checkout_billing_form' => 'woocommerce_before_checkout_billing_form',
1326 'woocommerce_after_checkout_billing_form' => 'woocommerce_after_checkout_billing_form',
1327 'woocommerce_before_checkout_registration_form' => 'woocommerce_before_checkout_registration_form',
1328 'woocommerce_after_checkout_registration_form' => 'woocommerce_after_checkout_registration_form',
1329 'woocommerce_checkout_shipping' => 'woocommerce_checkout_shipping',
1330 'woocommerce_before_checkout_shipping_form' => 'woocommerce_before_checkout_shipping_form',
1331 'woocommerce_after_checkout_shipping_form' => 'woocommerce_after_checkout_shipping_form',
1332 'woocommerce_before_order_notes' => 'woocommerce_before_order_notes',
1333 'woocommerce_after_order_notes' => 'woocommerce_after_order_notes',
1334 'woocommerce_checkout_after_customer_details' => 'woocommerce_checkout_after_customer_details',
1335 'woocommerce_checkout_before_order_review_heading' => 'woocommerce_checkout_before_order_review_heading',
1336 'woocommerce_checkout_order_review' => 'woocommerce_checkout_order_review',
1337 'woocommerce_checkout_before_order_review' => 'woocommerce_checkout_before_order_review',
1338 'woocommerce_review_order_before_cart_contents' => 'woocommerce_review_order_before_cart_contents',
1339 'woocommerce_review_order_after_cart_contents' => 'woocommerce_review_order_after_cart_contents',
1340 'woocommerce_review_order_before_shipping' => 'woocommerce_review_order_before_shipping',
1341 'woocommerce_review_order_after_shipping' => 'woocommerce_review_order_after_shipping',
1342 'woocommerce_review_order_before_order_total' => 'woocommerce_review_order_before_order_total',
1343 'woocommerce_review_order_after_order_total' => 'woocommerce_review_order_after_order_total',
1344 'woocommerce_review_order_before_payment' => 'woocommerce_review_order_before_payment',
1345 'woocommerce_review_order_before_submit' => 'woocommerce_review_order_before_submit',
1346 'woocommerce_review_order_after_submit' => 'woocommerce_review_order_after_submit',
1347 'woocommerce_review_order_after_payment' => 'woocommerce_review_order_after_payment',
1348 'woocommerce_checkout_after_order_review' => 'woocommerce_checkout_after_order_review',
1349 'woocommerce_after_checkout_form' => 'woocommerce_after_checkout_form'
1350 ];
1351 }
1352
1353 public static function routeapp_get_fee_taxable() {
1354 if (get_option('routeapp_route_fee_taxable') == 'yes') return true;
1355 return false;
1356 }
1357
1358 public function routeapp_get_excluded_shipping_methods() {
1359 return get_option('routeapp_excluded_shipping_methods') ?
1360 get_option('routeapp_excluded_shipping_methods') :
1361 [];
1362 }
1363
1364 public function routeapp_get_secret_token() {
1365 return get_option('routeapp_secret_token');
1366 }
1367
1368 public function routeapp_route_enabled_extra_columns() {
1369 return get_option('routeapp_route_enable_extra_columns') !== "no";
1370 }
1371
1372 public function routeapp_get_fresh_installed(){
1373 return get_option('routeapp_route_fresh_installed');
1374 }
1375
1376 public function routeapp_route_show_order_updates() {
1377 return get_option('routeapp_route_show_order_updates', 'yes') == 'yes';
1378 }
1379
1380 private static function routeapp_get_failed_registration(){
1381 return get_option('routeapp_failed_registration');
1382 }
1383
1384 /**
1385 * If it's installation has failed at user step creation step
1386 *
1387 * @return bool
1388 */
1389 public function has_user_creation_failed(){
1390 return self::routeapp_get_failed_registration() == Route_Setup::FAILED_REGISTRATION_STEP_USER;
1391 }
1392
1393 /**
1394 * If it's installation has failed at user step creation step due to conflict
1395 *
1396 * @return bool
1397 */
1398 public function has_user_creation_conflicted(){
1399 return !Route_Setup::has_valid_merchant_tokens() && (self::routeapp_get_failed_registration() == Route_Setup::FAILED_REGISTRATION_STEP_USER_DUPLICATED || self::routeapp_get_failed_registration() == Route_Setup::FAILED_REGISTRATION_STEP_USER_LOGIN_FAILED);
1400 }
1401
1402 /**
1403 * If it's installation has failed at Merchant creation step
1404 *
1405 * @return bool
1406 */
1407 public function has_merchant_creation_failed(){
1408 return self::routeapp_get_failed_registration() == Route_Setup::FAILED_REGISTRATION_STEP_MERCHANT && !Route_Setup::has_valid_merchant_tokens();
1409 }
1410 /**
1411 * If it's installation has failed at Merchant creation step
1412 *
1413 * @return bool
1414 */
1415 public function has_merchant_creation_conflicted(){
1416 return self::routeapp_get_failed_registration() == Route_Setup::FAILED_REGISTRATION_STEP_MERCHANT_DUPLICATED && !Route_Setup::has_valid_merchant_tokens();
1417 }
1418
1419 /**
1420 * If it's installation has failed at Merchant creation step
1421 *
1422 * @return bool
1423 */
1424 public function successful_registration(){
1425 return self::routeapp_get_failed_registration() === Route_Setup::REGISTRATION_FIXED || self::routeapp_get_failed_registration() === Route_Setup::REGISTRATION_STEP_USER_LOGIN_SUCCESS;
1426 }
1427
1428 /**
1429 * Get registration step at setup flow
1430 * @return mixed
1431 */
1432 private static function registration_step(){
1433 return get_option(Route_Setup::REGISTRATION_STEP);
1434 }
1435
1436 /**
1437 * Check if at the merchant create flow it was already redirected
1438 * @return bool
1439 */
1440 public static function was_redirected(){
1441 return self::registration_step() == Route_Setup::REDIRECTED;
1442 }
1443
1444 /**
1445 * Set it as redirected to Route after registration is complete
1446 * @return void
1447 */
1448 public function set_as_redirected(){
1449 update_option(Route_Setup::REGISTRATION_STEP, Route_Setup::REDIRECTED);
1450 }
1451
1452 public function routeapp_register_status_route() {
1453 register_rest_route( 'route', 'status', array(
1454 'methods' => 'GET',
1455 'callback' => function() {
1456 return rest_ensure_response( $this->routeapp_build_status_response() );
1457 },
1458 'permission_callback' => '__return_true'
1459 )
1460 );
1461 }
1462
1463 /**
1464 * Build the associative array returned by GET /wp-json/route/status.
1465 *
1466 * Kept separate from the REST callback so tests can exercise the response
1467 * shape directly without REST dispatch. Never returns raw credentials —
1468 * `routeapp_user_token` and `routeapp_user_id` are intentionally excluded;
1469 * `public_token`, `secret_key`, and `routeapp_store_hash` are masked via
1470 * {@see Routeapp_Public::_encodeToken()}. `routeapp_merchant_id` is
1471 * exposed raw — it is the merchant identifier Route support matches
1472 * against, and already appears in outbound webhook URLs.
1473 *
1474 * @return array Flat associative array keyed by setting name.
1475 */
1476 private function routeapp_build_status_response() {
1477 $store_hash = Route_Setup::get_store_hash();
1478 $widget_install_ts = get_option( Route_Setup::SETUP_CHECK_WIDGET_INSTALLATION_DATE, null );
1479 $widget_install_human = null;
1480 if ( ! empty( $widget_install_ts ) && is_numeric( $widget_install_ts ) ) {
1481 $widget_install_ts = (int) $widget_install_ts;
1482 $widget_install_human = wp_date( 'Y-m-d H:i:s', $widget_install_ts );
1483 } else {
1484 $widget_install_ts = null;
1485 }
1486
1487 return array(
1488 'version' => ROUTEAPP_VERSION,
1489 'public_token' => !empty($this->routeapp_get_public_token()) ?
1490 $this->_encodeToken($this->routeapp_get_public_token()) :
1491 false,
1492 'secret_key' => !empty($this->routeapp_get_secret_token()) ?
1493 $this->_encodeToken($this->routeapp_get_secret_token()) :
1494 false,
1495 'route_insurance_label' => $this->routeapp_get_insurance_label(),
1496 'extra_columns_enabled' => $this->routeapp_route_enabled_extra_columns(),
1497 'failed_registration' => $this->routeapp_get_failed_registration(),
1498 'base_currency' => get_woocommerce_currency(),
1499 'date' => array(
1500 'date' => date('Y-m-d H:i:s'),
1501 'timezone_type' => date('P'),
1502 'timezone' => date_default_timezone_get()
1503 ),
1504 'routeapp_route_fee_taxable' => $this->routeapp_get_fee_taxable(),
1505 'routeapp_taxable_class' => $this->routeapp_get_taxable_class(),
1506 'routeapp_excluded_shipping_methods' => $this->routeapp_get_excluded_shipping_methods(),
1507 'routeapp_route_show_order_updates' => $this->routeapp_route_show_order_updates(),
1508 'routeapp_checkout_hook' => $this->routeapp_get_checkout_hook(),
1509 'routeapp_have_webhook_secret' => $this->_routeapp_have_webhook_secret(),
1510 'routeapp_included_order_statuses' => get_option( 'routeapp_included_order_statuses', array() ),
1511 'routeapp_cancel_order_statuses' => get_option( 'routeapp_cancel_order_statuses', array() ),
1512 'routeapp_external_tracking_providers' => get_option( 'routeapp_external_tracking_providers', '' ),
1513 'routeapp_order_recover_from' => get_option( 'routeapp_order_recover_from', '' ),
1514 'routeapp_order_recover_to' => get_option( 'routeapp_order_recover_to', '' ),
1515 'routeapp_order_recover_reconcile_backend' => get_option( 'routeapp_order_recover_reconcile_backend', 'no' ),
1516 'routeapp_merchant_id' => get_option( Routeapp_API_Client::ROUTEAPP_MERCHANT_ID, '' ),
1517 'routeapp_store_hash' => ! empty( $store_hash ) ? $this->_encodeToken( $store_hash ) : false,
1518 'routeapp_last_install_date' => get_option( '_routeapp_last_install_date', '' ),
1519 'routeapp_webhooks_created' => (bool) get_option( '_routeapp_webhooks_created', false ),
1520 'routeapp_route_registration_step' => get_option( Route_Setup::REGISTRATION_STEP, '' ),
1521 'routeapp_user_conflict_auto_redirect_done' => (bool) get_option( Route_Setup::USER_CONFLICT_AUTO_REDIRECT_DONE_OPTION, false ),
1522 'routeapp_route_fresh_installed' => $this->routeapp_get_fresh_installed(),
1523 'route_setup_check_widget_installation_date' => $widget_install_ts,
1524 'route_setup_check_widget_installation_date_human' => $widget_install_human,
1525 'route_setup_check_widget_php' => (bool) get_option( Route_Setup::SETUP_CHECK_WIDGET_PHP, false ),
1526 'route_setup_check_widget_js' => (bool) get_option( Route_Setup::SETUP_CHECK_WIDGET_JS, false ),
1527 'route_setup_check_widget_api_call' => (bool) get_option( Route_Setup::SETUP_CHECK_WIDGET_API_CALL, false ),
1528 );
1529 }
1530
1531 private function _routeapp_have_webhook_secret() {
1532 return get_option('_routeapp_webhooks_secret') ? 'Yes' : 'No';
1533 }
1534
1535 private function _encodeToken($token) {
1536 return substr($token, 0, 5) . '...' . substr($token, -5);
1537 }
1538
1539 public function routeapp_register_checkout_route(){
1540
1541 if (!isset(WC()->session) || !isset($_POST) || !isset($_POST['shipping_method'])) {
1542 return rest_ensure_response( [] );
1543 }
1544
1545 $shipping_method = $_POST['shipping_method'];
1546
1547 //check first if shipping method is allowed
1548 if ($shipping_method) {
1549 $show_widget = $this->routeapp_is_shipping_method_allowed($shipping_method);
1550 if (!$show_widget) {
1551 wp_send_json(['hide-widget' => true]);
1552 return;
1553 }
1554 }
1555
1556 $cart = WC()->cart;
1557 $this->checkout_route_insurance_fee($cart);
1558 $response= [
1559 'routeapp-subtotal' => round($this->get_cart_subtotal_with_only_shippable_items($cart), 2),
1560 ];
1561
1562 wp_send_json( $response );
1563 }
1564
1565 /**
1566 * Add route to recreate User and Merchant Accounts
1567 * if it has failed at the first try
1568 */
1569 public function routeapp_register_setup_route () {
1570 register_rest_route( 'route', 'recreate_user', array(
1571 'methods' => 'GET',
1572 'callback' => [Route_Setup::class, 'retry_user'],
1573 'permission_callback' => '__return_true'
1574 ) );
1575 register_rest_route( 'route', 'recreate_merchant', array(
1576 'methods' => 'GET',
1577 'callback' => [Route_Setup::class, 'retry_merchant'],
1578 'permission_callback' => '__return_true'
1579 ) );
1580 register_rest_route( 'route', 'user_login', array(
1581 'methods' => 'POST',
1582 'callback' => [Route_Setup::class, 'user_login'],
1583 'permission_callback' => '__return_true'
1584 ) );
1585 }
1586
1587 public function routeapp_last_request_has_failed(){
1588 if ( is_wp_error( $this->routeapp_last_request ) || !isset($this->routeapp_last_request)) {
1589 return true;
1590 } else {
1591 if ($this->routeapp_last_request['response']['code'] != self::HTTP_CREATED_CODE && $this->routeapp_last_request['response']['code'] != self::HTTP_SUCCESS_CODE) {
1592 return true;
1593 }
1594
1595 return false;
1596 }
1597 }
1598
1599 public function routeapp_last_request_has_success() {
1600 if ( is_wp_error( $this->routeapp_last_request ) || !isset($this->routeapp_last_request)) {
1601 return false;
1602 }
1603 return $this->routeapp_last_request['response']['code'] === self::HTTP_SUCCESS_CODE;
1604 }
1605
1606 public function routeapp_last_request_has_conflicted(){
1607 if ( is_wp_error( $this->routeapp_last_request ) || !isset($this->routeapp_last_request)) {
1608 return false;
1609 } else {
1610 if ($this->routeapp_last_request['response']['code'] == self::HTTP_CONFLICT_CODE) {
1611 return true;
1612 }
1613
1614 return false;
1615 }
1616 }
1617
1618 public function routeapp_log($event, $extraData = false) {
1619 $this->routeapp_logger->sentry_log($event, $extraData);
1620 }
1621
1622 private function _setup_readiness_report_check()
1623 {
1624 if ($this->route_app_setup_helper->can_send_compatibility_report()) {
1625 /**
1626 * API CALL for incompatibility test
1627 */
1628 $api = new Routeapp_API_Compatibility();
1629 $api->send($this->route_app_setup_helper->prepare_compatibility_data());
1630 }
1631 }
1632
1633 public function get_order_subtotal($order)
1634 {
1635 $subtotal = 0;
1636
1637 if (is_a($order, 'WC_Order')) {
1638 $order_items = $order->get_items();
1639
1640 foreach ($order_items as $order_item_id => $order_item) {
1641 if ($this->isProductVirtual($order_item)) {
1642 //if the product is not found or virtual, skip it
1643 continue;
1644 }
1645 $subtotal += $order_item->get_subtotal();
1646 }
1647 }
1648 return $subtotal;
1649 }
1650
1651 /**
1652 * Normalize cart item (array) or order item (WC_Order_Item_Product) to a common array format.
1653 * Ensures compatibility with WooCommerce Product Bundles when used in order context.
1654 *
1655 * @param array|WC_Order_Item_Product $item Cart item array or order item object.
1656 * @return array Normalized item with keys: data, line_subtotal, quantity, product_id, variation_id, key, bundled_items, bundled_by.
1657 */
1658 private function normalize_item_for_bundle_calc($item) {
1659 if (is_object($item) && method_exists($item, 'get_product')) {
1660 /** @var WC_Order_Item_Product $item */
1661 $product = $item->get_product();
1662 $bundled_items = $item->get_meta('_bundled_items', true);
1663 $bundled_by = $item->get_meta('_bundled_by', true);
1664 // Use _bundle_cart_key so parent matches child's _bundled_by (Product Bundles uses same hash).
1665 $key = $item->get_meta('_bundle_cart_key', true) ?: $item->get_id();
1666 return [
1667 'data' => $product,
1668 'line_subtotal' => (float) $item->get_subtotal(),
1669 'quantity' => (int) $item->get_quantity(),
1670 'product_id' => $item->get_product_id(),
1671 'variation_id' => $item->get_variation_id(),
1672 'key' => $key,
1673 'bundled_items' => !empty($bundled_items) ? $bundled_items : null,
1674 'bundled_by' => !empty($bundled_by) ? $bundled_by : null,
1675 ];
1676 }
1677 return $item;
1678 }
1679
1680 /**
1681 * Check if product inside order item is virtual, return true if product is not valid
1682 * @param array|WC_Order_Item_Product $item Cart item array or order item object.
1683 * @return bool
1684 */
1685 public function isProductVirtual($item) {
1686 if (is_object($item) && method_exists($item, 'get_product_id')) {
1687 $productId = $item->get_variation_id() ? $item->get_variation_id() : $item->get_product_id();
1688 } else {
1689 $productId = isset($item['variation_id']) && !empty($item['variation_id']) ?
1690 $item['variation_id'] :
1691 $item['product_id'];
1692 }
1693
1694 $product = wc_get_product($productId);
1695 if ($product) {
1696 return $product->is_virtual();
1697 }
1698 return true;
1699 }
1700
1701 /**
1702 * Process cart item
1703 *
1704 * @param $cart_item
1705 * @param $allowed_bundle_parents
1706 * @return array
1707 */
1708 public function processCartItem($cart_item, $allowed_bundle_parents) {
1709
1710 $cart_item = $this->normalize_item_for_bundle_calc($cart_item);
1711
1712 $cart_item_subtotal = $cart_item['line_subtotal'];
1713
1714 //support for WooCommerce Product Bundles (only when product data is available; order items may have null)
1715 if (isset($cart_item['bundled_items'])) {
1716 if (isset($cart_item['data']) && $cart_item['data'] instanceof \WC_Product) {
1717 $price = $cart_item['data']->get_data()['price'];
1718 if ($price > 0) {
1719 //grab item price from parent bundle if exist, return directly
1720 $cart_item_subtotal = $price * $cart_item['quantity'];
1721 $data = [
1722 'id' => strval($cart_item['product_id']),
1723 'quantity' => intval($cart_item['quantity']),
1724 'unit_price' => strval(round($cart_item_subtotal / $cart_item['quantity'], 2))
1725 ];
1726 return ['allowed_bundle_parents' => $allowed_bundle_parents, 'data' => $data];
1727 }
1728 }
1729 //add bundle parent key to allowed array
1730 $allowed_bundle_parents[] = $cart_item['key'];
1731 }
1732
1733 if ($this->isProductVirtual($cart_item)) {
1734 //if the product is not found or virtual, skip it
1735 return ['allowed_bundle_parents' => $allowed_bundle_parents];
1736 }
1737
1738 if (isset($cart_item['bundled_by'])) {
1739 if (!in_array($cart_item['bundled_by'], $allowed_bundle_parents)) {
1740 //skip bundle item children (price is on parent)
1741 return ['allowed_bundle_parents' => $allowed_bundle_parents];
1742 }
1743 if (isset($cart_item['data']) && $cart_item['data'] instanceof \WC_Product) {
1744 //grab subtotal from bundle child
1745 $cart_item_subtotal = $cart_item['data']->get_data()['price'] * $cart_item['quantity'];
1746 }
1747 }
1748
1749 return [
1750 'allowed_bundle_parents' => $allowed_bundle_parents,
1751 'data' => [
1752 'id' => strval($cart_item['product_id']),
1753 'quantity' => intval($cart_item['quantity']),
1754 'unit_price' => strval(round($cart_item_subtotal / $cart_item['quantity'], 2))
1755 ]
1756 ];
1757 }
1758
1759 /**
1760 * Get cart item subtotal
1761 *
1762 * @param $cart_item
1763 * @param $allowed_bundle_parents
1764 * @return array
1765 */
1766 public function getCartItemSubtotal($cart_item, $allowed_bundle_parents) {
1767
1768 $cart_item = $this->normalize_item_for_bundle_calc($cart_item);
1769
1770 $cart_item_subtotal = $cart_item['line_subtotal'];
1771
1772 //support for WooCommerce Product Bundles (only when product data is available; order items may have null)
1773 if (isset($cart_item['bundled_items'])) {
1774 if (isset($cart_item['data']) && $cart_item['data'] instanceof \WC_Product) {
1775 $price = $cart_item['data']->get_data()['price'];
1776 if ($price > 0) {
1777 //grab item price from parent bundle if exist, return directly
1778 $cart_item_subtotal = $price * $cart_item['quantity'];
1779 return ['allowed_bundle_parents' => $allowed_bundle_parents, 'subtotal' => $cart_item_subtotal];
1780 }
1781 }
1782 //add bundle parent key to allowed array
1783 $allowed_bundle_parents[] = $cart_item['key'];
1784 }
1785
1786 if ($this->isProductVirtual($cart_item)) {
1787 //if the product is not found or virtual, skip it
1788 return ['allowed_bundle_parents' => $allowed_bundle_parents];
1789 }
1790
1791 if (isset($cart_item['bundled_by'])) {
1792 if (!in_array($cart_item['bundled_by'], $allowed_bundle_parents)) {
1793 //skip bundle item children (price is on parent)
1794 return ['allowed_bundle_parents' => $allowed_bundle_parents];
1795 }
1796 if (isset($cart_item['data']) && $cart_item['data'] instanceof \WC_Product) {
1797 //grab subtotal from bundle child
1798 $cart_item_subtotal = $cart_item['data']->get_data()['price'] * $cart_item['quantity'];
1799 }
1800 }
1801 return ['allowed_bundle_parents' => $allowed_bundle_parents, 'subtotal' => $cart_item_subtotal];
1802 }
1803
1804 /**
1805 * get cart shippable items to grab quote on RouteApp
1806 *
1807 * @param $cart
1808 * @param string $object
1809 * @return array
1810 */
1811 public function get_cart_shippable_items($cart, $object='cart')
1812 {
1813 $cartItems = [];
1814 if (!$cart) {
1815 return $cartItems;
1816 }
1817 $allowed_bundle_parents = [];
1818 if ($object=='cart') {
1819 foreach ( $cart->get_cart() as $cart_item ) {
1820 $cartItemData = $this->processCartItem($cart_item, $allowed_bundle_parents);
1821 $allowed_bundle_parents = $cartItemData['allowed_bundle_parents'];
1822 if (isset($cartItemData['data'])) {
1823 $cartItems[] = $cartItemData['data'];
1824 }
1825 }
1826 } else {
1827 foreach ( $cart->get_items() as $cart_item ) {
1828 $cartItemData = $this->processCartItem($cart_item, $allowed_bundle_parents);
1829 $allowed_bundle_parents = $cartItemData['allowed_bundle_parents'];
1830 if (isset($cartItemData['data'])) {
1831 $cartItems[] = $cartItemData['data'];
1832 }
1833 }
1834 }
1835
1836 return $cartItems;
1837 }
1838
1839 /**
1840 * get subtotal for shippable items only
1841 *
1842 * @param $cart
1843 * @param string $object
1844 * @return float|int|mixed
1845 */
1846 public function get_cart_subtotal_with_only_shippable_items($cart, $object='cart')
1847 {
1848 $subtotal = 0;
1849 if (!$cart) {
1850 return $subtotal;
1851 }
1852 $allowed_bundle_parents = [];
1853
1854 if ($object=='cart') {
1855 foreach ( $cart->get_cart() as $cart_item ) {
1856 $itemSubtotal = $this->getCartItemSubtotal($cart_item, $allowed_bundle_parents);
1857 $allowed_bundle_parents = $itemSubtotal['allowed_bundle_parents'];
1858 if (isset($itemSubtotal['subtotal'])) {
1859 $subtotal += $itemSubtotal['subtotal'];
1860 }
1861 }
1862 } else {
1863 foreach ( $cart->get_items() as $cart_item ) {
1864 $itemSubtotal = $this->getCartItemSubtotal($cart_item, $allowed_bundle_parents);
1865 $allowed_bundle_parents = $itemSubtotal['allowed_bundle_parents'];
1866 if (isset($itemSubtotal['subtotal'])) {
1867 $subtotal += $itemSubtotal['subtotal'];
1868 }
1869 }
1870 }
1871 return $subtotal;
1872 }
1873
1874 /**
1875 * Edit Avatax fee line (send tax class name instead item_id)
1876 */
1877 public function routeapp_edit_avatax_fee($fee_line, $fee = null)
1878 {
1879 if (isset($fee_line['description']) && $fee_line['description'] == $this->routeapp_get_insurance_label()) {
1880 $fee_line['itemCode'] = $this->routeapp_get_taxable_class();
1881 }
1882
1883 return $fee_line;
1884 }
1885
1886
1887 /**
1888 * This method adds to xml export an SKU tag in order to identify
1889 * Route Protection to ShipStation integration
1890 *
1891 * @param $order_xml
1892 * @return mixed
1893 */
1894 public function routeapp_add_sku_to_route_node($order_xml){
1895
1896 if ($order_xml instanceof DOMElement){
1897
1898 $items = $order_xml->getElementsByTagName('Items');
1899
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 }
1908 }
1909 }
1910
1911 }
1912
1913 return $order_xml;
1914 }
1915
1916 /**
1917 * Update order meta only if changed (HPOS & CPT safe)
1918 *
1919 * @return bool True if meta changed
1920 */
1921 public function updateRouteOrderMetaData(
1922 WC_Order $order,
1923 bool $is_hpos,
1924 string $meta_key,
1925 $meta_value
1926 ): bool {
1927 try {
1928 if ( ! $order || empty( $meta_key ) ) {
1929 return false;
1930 }
1931
1932 $normalized = is_scalar( $meta_value )
1933 ? (string) $meta_value
1934 : wp_json_encode( $meta_value );
1935
1936 if ( $is_hpos ) {
1937 $current = $order->get_meta( $meta_key, true );
1938 $current = is_scalar( $current ) ? (string) $current : wp_json_encode( $current );
1939
1940
1941 if ( (string) $current === (string) $normalized ) {
1942 return false;
1943 }
1944
1945 $order->update_meta_data( $meta_key, $normalized );
1946 return true;
1947
1948 }
1949
1950 $order_id = $order->get_id();
1951 $current = get_post_meta( $order_id, $meta_key, true );
1952 $current = is_scalar( $current ) ? (string) $current : wp_json_encode( $current );
1953
1954
1955 if ( (string) $current === (string) $normalized ) {
1956 return false;
1957 }
1958
1959 update_post_meta( $order_id, $meta_key, $normalized );
1960 return true;
1961
1962 } catch ( Exception $e ) {
1963 error_log(
1964 'RouteApp: error updating order meta (' . $meta_key . '): ' . $e->getMessage()
1965 );
1966 return false;
1967 }
1968 }
1969
1970 //MSS-6533
1971 /**
1972 * Resolve the local pickup flag in a FAIL-SAFE way.
1973 *
1974 * Priority:
1975 * 1. Order shipping items
1976 * 3. Checkout session (fallback only)
1977 *
1978 * @param WC_Order|null $order
1979 *
1980 * @return int 1 = local pickup, 0 = not local pickup
1981 */
1982 public function routeapp_resolve_ship_to_store_flag( WC_Order $order = null): int {
1983 $excluded_methods = $this->routeapp_get_excluded_shipping_methods();
1984 if ( empty( $excluded_methods ) || ! is_array( $excluded_methods ) ) {
1985 return 0;
1986 }
1987
1988 // Order-based (most reliable).
1989 if ( $order instanceof WC_Order ) {
1990 $shipping_items = $order->get_shipping_methods();
1991 if ( ! empty( $shipping_items ) ) {
1992 foreach ( $shipping_items as $shipping_item ) {
1993 if ( ! $shipping_item instanceof WC_Order_Item_Shipping ) {
1994 continue;
1995 }
1996 $method_id = $shipping_item->get_method_id();
1997 if ( empty( $method_id ) ) {
1998 continue;
1999 }
2000 $method_id = explode( ':', $method_id )[0];
2001 if ( in_array( $method_id, $excluded_methods, true ) ) {
2002 return 1;
2003 }
2004 }
2005 return 0;
2006 }
2007 }
2008
2009 // Session fallback (checkout only).
2010 if ( ! function_exists( 'WC' ) || ! WC()->session ) {
2011 return 0;
2012 }
2013 $chosen = WC()->session->get( 'chosen_shipping_methods' );
2014 $packages = WC()->session->get( 'shipping_for_package_0' );
2015 if ( empty( $chosen[0] ) || empty( $packages['rates'] ) ) {
2016 return 0;
2017 }
2018 $chosen_id = $chosen[0];
2019 if ( ! isset( $packages['rates'][ $chosen_id ] ) ) {
2020 return 0;
2021 }
2022 $method_id = explode( ':', $packages['rates'][ $chosen_id ]->id )[0];
2023 if ( in_array( $method_id, $excluded_methods, true ) ) {
2024 return 1;
2025 }
2026
2027 return 0;
2028 }
2029
2030 /**
2031 * Register callback for WooCommerce blocks checkout cart updates
2032 * This ensures the Route widget toggle properly updates the cart in blocks checkout
2033 *
2034 * @return void
2035 */
2036 public function routeapp_register_blocks_checkout_callback() {
2037 if ( function_exists( 'woocommerce_store_api_register_update_callback' ) ) {
2038 woocommerce_store_api_register_update_callback(
2039 array(
2040 'namespace' => 'route-widget-integration',
2041 'callback' => function( $data ) {
2042 // Update session if checkbox value is provided
2043 if ( isset( $data['checkbox'] ) && WC()->session ) {
2044 $checkboxValue = filter_var( $data['checkbox'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
2045
2046 // Treat invalid values as false to keep session state deterministic.
2047 WC()->session->set( 'checkbox_checked', true === $checkboxValue );
2048 }
2049
2050 // The cart will be recalculated automatically, triggering woocommerce_cart_calculate_fees
2051 return true;
2052 },
2053 )
2054 );
2055 }
2056 }
2057
2058 /**
2059 * Inject Route widget before the checkout block order summary (default placement for blocks checkout).
2060 *
2061 * @param string $block_content The block content.
2062 * @param array $block The full block, including name and attributes.
2063 * @param object $instance The block instance.
2064 * @return string Modified block content with Route widget prepended when applicable.
2065 */
2066 public function routeapp_checkout_block_before_order_summary( $blockContent, $block, $instance ) {
2067 if ( is_admin() && ! wp_doing_ajax() ) {
2068 return $blockContent;
2069 }
2070
2071 if ( $this->route_block_rendered || $this->is_route_block_in_checkout_page() ) {
2072 return $blockContent;
2073 }
2074
2075 if ( ! function_exists( 'WC' ) || ! WC()->cart || ! WC()->session ) {
2076 return $blockContent;
2077 }
2078 if ( empty( $this->routeapp_get_public_token() ) ) {
2079 return $blockContent;
2080 }
2081
2082 WC()->cart->calculate_totals();
2083
2084 $routeHtml = do_shortcode( '[route]' );
2085 if ( empty( $routeHtml ) ) {
2086 return $blockContent;
2087 }
2088
2089 return $routeHtml . $blockContent;
2090 }
2091
2092 /**
2093 * Register the Route Protection custom checkout block.
2094 * Hooked to 'init'.
2095 */
2096 public function routeapp_register_checkout_block() {
2097 if ( ! function_exists( 'register_block_type' ) ) {
2098 return;
2099 }
2100
2101 $block_dir = plugin_dir_path( dirname( __FILE__ ) ) . 'blocks/route-protection';
2102 if ( ! file_exists( $block_dir . '/block.json' ) ) {
2103 return;
2104 }
2105
2106 wp_register_script(
2107 'route-protection-editor',
2108 plugins_url( 'blocks/route-protection/index.js', dirname( __FILE__ ) ),
2109 array( 'wp-blocks', 'wp-element', 'wp-block-editor', 'wc-blocks-checkout' ),
2110 defined( 'ROUTEAPP_VERSION' ) ? ROUTEAPP_VERSION : '1.0.0',
2111 true
2112 );
2113
2114 register_block_type(
2115 $block_dir,
2116 array(
2117 'editor_script' => 'route-protection-editor',
2118 'render_callback' => array( $this, 'render_route_protection_block' ),
2119 )
2120 );
2121 }
2122
2123 /**
2124 * Server-side render callback for the route/route-protection block.
2125 *
2126 * @param array $attributes Block attributes.
2127 * @param string $content Block inner content.
2128 * @return string
2129 */
2130 public function render_route_protection_block( $attributes, $content ) {
2131 if ( is_admin() && ! wp_doing_ajax() ) {
2132 return '';
2133 }
2134 if ( ! function_exists( 'WC' ) || ! WC()->cart || ! WC()->session ) {
2135 return '';
2136 }
2137 if ( empty( $this->routeapp_get_public_token() ) ) {
2138 return '';
2139 }
2140
2141 WC()->cart->calculate_totals();
2142
2143 $routeHtml = do_shortcode( '[route]' );
2144
2145 $this->route_block_rendered = true;
2146
2147 return $routeHtml;
2148 }
2149
2150 /**
2151 * Register the Route Protection IntegrationInterface with WooCommerce Blocks.
2152 *
2153 * @param \Automattic\WooCommerce\Blocks\Integrations\IntegrationRegistry $integration_registry
2154 */
2155 public function routeapp_register_blocks_integration( $integration_registry ) {
2156 $file = plugin_dir_path( dirname( __FILE__ ) ) . 'includes/class-routeapp-blocks-integration.php';
2157 if ( ! class_exists( 'Routeapp_Blocks_Integration' ) && file_exists( $file ) ) {
2158 require_once $file;
2159 }
2160 if ( class_exists( 'Routeapp_Blocks_Integration' ) ) {
2161 $integration_registry->register( new Routeapp_Blocks_Integration() );
2162 }
2163 }
2164
2165 /**
2166 * Whether the store checkout page uses the WooCommerce Checkout block.
2167 *
2168 * @return bool
2169 */
2170 private function routeapp_is_wc_blocks_checkout_page() {
2171 if ( class_exists( '\Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils' ) ) {
2172 return \Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils::is_checkout_block_default();
2173 }
2174
2175 if ( ! function_exists( 'wc_get_page_id' ) || ! function_exists( 'has_block' ) ) {
2176 return false;
2177 }
2178
2179 $checkout_page_id = wc_get_page_id( 'checkout' );
2180 return $checkout_page_id > 0 && has_block( 'woocommerce/checkout', $checkout_page_id );
2181 }
2182
2183 /**
2184 * Whether routeapp-public-pbc.js should depend on wc-blocks-checkout.
2185 *
2186 * Classic checkout stores often deregister block scripts after they are registered.
2187 * Adding wc-blocks-checkout as a dependency in that case causes WordPress to silently
2188 * drop routeapp-public-pbc.js when the handle is later deregistered.
2189 *
2190 * @return bool
2191 */
2192 private function routeapp_should_add_wc_blocks_checkout_dependency() {
2193 if ( ! wp_script_is( 'wc-blocks-checkout', 'registered' ) ) {
2194 return false;
2195 }
2196
2197 if ( doing_action( 'woocommerce_blocks_enqueue_checkout_block_scripts_before' ) ) {
2198 return true;
2199 }
2200
2201 if ( ! function_exists( 'is_checkout' ) || ! is_checkout() ) {
2202 return false;
2203 }
2204
2205 return $this->routeapp_is_wc_blocks_checkout_page();
2206 }
2207
2208 /**
2209 * Check whether the checkout page contains the route/route-protection block.
2210 *
2211 * @return bool
2212 */
2213 private function is_route_block_in_checkout_page() {
2214 if ( ! function_exists( 'wc_get_page_id' ) || ! function_exists( 'has_block' ) ) {
2215 return false;
2216 }
2217
2218 $checkout_page_id = wc_get_page_id( 'checkout' );
2219 if ( $checkout_page_id <= 0 ) {
2220 return false;
2221 }
2222
2223 $post = get_post( $checkout_page_id );
2224 if ( ! $post ) {
2225 return false;
2226 }
2227
2228 return has_block( 'route/route-protection', $post );
2229 }
2230
2231 }
2232
2233