set_public_token($public_token); $this->set_secret_token($secret_token); $custom_env = getenv('ROUTEAPP_ENVIRONMENT_ENDPOINT'); if (is_null($custom_env) || !$custom_env) { $custom_env = isset($_SERVER['ROUTEAPP_ENVIRONMENT_ENDPOINT']) ? $_SERVER['ROUTEAPP_ENVIRONMENT_ENDPOINT'] : ''; } if ($custom_env == 'stage') { $this->_api_url = rtrim($this->_api_url ?? '', '/') . self::API_STAGE_ENDPOINT_V1; $this->_api_url_v2 = rtrim($this->_api_url_v2 ?? '', '/') . self::API_STAGE_ENDPOINT_V2; } else { $this->_api_url = rtrim($this->_api_url ?? '', '/') . self::API_ENDPOINT_V1; $this->_api_url_v2 = rtrim($this->_api_url_v2 ?? '', '/') . self::API_ENDPOINT_V2; } } /** * Singletons should not be cloneable. */ protected function __clone() {} public static function getInstance() { $cls = static::class; if (!isset(static::$instances[$cls])) { static::$instances[$cls] = new static; } return static::$instances[$cls]; } /** * Set the public token * @param string $token */ public function set_public_token($token) { $this->_public_token = $token; } /** * Set the secret token * @param string $token */ public function set_secret_token($token) { $this->_secret_token = $token; } /** * Get the public token * @return string string */ public function get_public_token() { return !empty($this->_public_token) ? $this->_public_token : get_option('routeapp_public_token'); } public function get_cache_api_session_key() { return $this->_cachedApiCallsSessionKey; } /** * Get the secret token * @return string string */ public function get_secret_token() { return !empty($this->_secret_token) ? $this->_secret_token : get_option('routeapp_secret_token'); } /** * Get the user token * @return string string */ public function get_user_token() { return get_option('routeapp_user_token'); } /** * Get the user id * @return string string */ public function get_user_id() { return get_option('routeapp_user_id'); } /** * Get current quote price based on subtotal * Optimized to store only essential data in session * @param $cartRef * @param $cartTotal * @param $currency * @param $cartItems * @return array|mixed */ public function get_quote($cartRef, $cartTotal, $currency, $cartItems) { $currency = !$currency ? get_woocommerce_currency() : $currency; $cartTotal = !is_null($cartTotal) && $cartTotal > 0 ? $cartTotal : 0; $merchant_id = $this->get_merchant_id(); //empty subtotal or merchant_id just return zero if ($cartTotal==0 || !$merchant_id) return ['body' => json_encode(['premium' => ['amount' => '0']])]; //check values on cache $cached = false; $key = $this->get_cache_api_session_key() . '-' . $cartRef; if (WC()->session) { $cached = WC()->session->get($key); } if ($cached) { if (time() - $cached['createdAt'] > 1800) { //if creation date is more than 30 minutes, we unset it WC()->session->__unset($key); $lastCalledMade = $key . '-latest'; WC()->session->__unset($lastCalledMade); // Clean up all old entries when we find an expired one $this->_cleanup_old_quote_sessions(); } else { return $cached['result']; } } // Make API call $api_response = $this->_make_private_api_call('quotes', array( 'merchant_id' => $merchant_id, 'cart' => [ 'cart_ref' => strval($cartRef), 'covered' => [ 'currency' => strval($currency), 'amount' => strval($cartTotal) ], 'cart_items' => $cartItems, ], ), 'POST', 'v2'); // Extract only essential data from API response to minimize session storage $essential_data = $this->_extract_essential_quote_data($api_response); // Store only essential data in session (not the full HTTP response) if (WC()->session) { // Clean up old entries BEFORE adding new one to enforce limit // This ensures we don't exceed max_entries even when adding a new quote $this->_cleanup_old_quote_sessions(); $created_at = time(); $cached = array( 'createdAt' => $created_at, 'result' => $essential_data ); WC()->session->set($key, $cached); // Store latest quote data (also minimal) // Format: array with 'body' key for compatibility with routeapp_save_quote_to_order $lastCalledMade = $key . '-latest'; WC()->session->set($lastCalledMade, $essential_data); // Track this key for cleanup and limit total entries $this->_track_quote_session_key($key, $created_at); // Clean up again after adding to ensure limit is strictly enforced // This handles the case where we had exactly max_entries before adding $this->_cleanup_old_quote_sessions(); } return $essential_data; } /** * Extract only essential data from API response * Prevents storing full HTTP response objects in session * Stores only: id, premium.amount, premium.currency, payment_responsible.type, payment_responsible.ToggleState * * @param array|WP_Error $api_response The full API response from wp_remote_request * @return array Minimal quote data in expected format (compatible with existing code) */ private function _extract_essential_quote_data($api_response) { // Handle errors - return in expected format if (is_wp_error($api_response)) { return array( 'response' => array('code' => 500), 'body' => json_encode(array('premium' => array('amount' => '0'))) ); } // Extract body from response $response_code = wp_remote_retrieve_response_code($api_response); $response_body = wp_remote_retrieve_body($api_response); // If API call failed, return original format for error handling if ($response_code !== 200 && $response_code !== 201) { return $api_response; // Return original for error handling } // Parse JSON body $body_data = json_decode($response_body, true); if (!$body_data || empty($body_data)) { return array( 'response' => array('code' => $response_code), 'body' => json_encode(array('premium' => array('amount' => '0'))) ); } $essential_quote = array(); // Extract essential fields if (isset($body_data['id'])) { $essential_quote['id'] = $body_data['id']; } if (isset($body_data['premium'])) { $essential_quote['premium'] = array( 'currency' => isset($body_data['premium']['currency']) ? $body_data['premium']['currency'] : 'USD', 'amount' => isset($body_data['premium']['amount']) ? $body_data['premium']['amount'] : '0' ); } if (isset($body_data['payment_responsible'])) { $payment = $body_data['payment_responsible']; $essential_quote['payment_responsible'] = array( 'type' => isset($payment['type']) ? $payment['type'] : 'paid_by_merchant' ); // Convert to boolean ToggleState for compatibility with existing code if (array_key_exists('toggle_state', $payment)) { $toggle_state_value = $payment['toggle_state']; // Convert "checked" -> true, "unchecked" -> false $essential_quote['payment_responsible']['ToggleState'] = ($toggle_state_value === 'checked' || $toggle_state_value === true || $toggle_state_value === 1); } elseif (array_key_exists('ToggleState', $payment)) { // Fallback for camelCase format (if API changes) $essential_quote['payment_responsible']['ToggleState'] = $payment['ToggleState']; } } // Return in expected format (compatible with routeapp_get_quote_from_api) return array( 'response' => array('code' => $response_code), 'body' => json_encode($essential_quote, JSON_FORCE_OBJECT) ); } /** * Track quote session keys for cleanup management * * @param string $key Session key * @param int $created_at Timestamp */ private function _track_quote_session_key($key, $created_at) { if (!WC()->session) { return; } $tracker_key = $this->get_cache_api_session_key() . '_keys'; $tracked_keys = WC()->session->get($tracker_key); if (!is_array($tracked_keys)) { $tracked_keys = array(); } // Add current key to tracker (avoid duplicates) $key_exists = false; foreach ($tracked_keys as $index => $tracked) { if (isset($tracked['key']) && $tracked['key'] === $key) { $tracked_keys[$index]['time'] = $created_at; // Update timestamp $key_exists = true; break; } } if (!$key_exists) { $tracked_keys[] = array('key' => $key, 'time' => $created_at); } WC()->session->set($tracker_key, $tracked_keys); } /** * Clean up old quote session entries to prevent session bloat * Removes entries older than 30 minutes and limits total entries per session * This prevents accumulation of hundreds of quote entries */ private function _cleanup_old_quote_sessions() { if (!WC()->session) { return; } $cache_prefix = $this->get_cache_api_session_key(); $current_time = time(); $max_age = 1800; // 30 minutes $max_entries = 5; // Maximum number of quote entries per session (reduced from potential hundreds) // Get tracked keys $tracker_key = $cache_prefix . '_keys'; $tracked_keys = WC()->session->get($tracker_key); if (!is_array($tracked_keys) || empty($tracked_keys)) { return; } // Clean up old entries (expired) and verify they still exist in session $valid_keys = array(); foreach ($tracked_keys as $key_with_timestamp) { if (!is_array($key_with_timestamp) || !isset($key_with_timestamp['key']) || !isset($key_with_timestamp['time'])) { continue; } $key = $key_with_timestamp['key']; $created_at = $key_with_timestamp['time']; $age = $current_time - $created_at; // Check if entry still exists in session (might have been manually removed) $session_entry = WC()->session->get($key); if ($age > $max_age || !$session_entry) { // Remove expired or missing entry WC()->session->__unset($key); WC()->session->__unset($key . '-latest'); } else { // Keep valid entry $valid_keys[] = $key_with_timestamp; } } // Always sort by time (newest first) to prepare for limiting usort($valid_keys, function($a, $b) { return $b['time'] - $a['time']; }); // Limit total entries (keep most recent) - STRICTLY enforce max_entries if (count($valid_keys) > $max_entries) { // Keep only max_entries (most recent) $keys_to_keep = array_slice($valid_keys, 0, $max_entries); // Remove excess entries (older ones beyond limit) $keys_to_remove = array_slice($valid_keys, $max_entries); foreach ($keys_to_remove as $excess_entry) { if (isset($excess_entry['key'])) { $excess_key = $excess_entry['key']; WC()->session->__unset($excess_key); WC()->session->__unset($excess_key . '-latest'); } } // Update tracker with only kept keys (maintain sorted order) $valid_keys = $keys_to_keep; } // Always update tracker to maintain correct order and remove any orphaned entries WC()->session->set($tracker_key, $valid_keys); } /** * Create the order shipment, currently only status update suported by API * @param integer $tracking_id * @param array $data * @return mixed|json string */ public function create_shipment($tracking_id, $data = array()) { if (empty($tracking_id)) return false; return $this->_make_private_api_call('shipments', array( 'tracking_number' => $this->sanitize_value($tracking_id), 'source_order_id' => $data['source_order_id'], 'source_product_ids' => $data['source_product_ids'], 'courier_id' => $this->sanitize_value($data['courier_id']), ), 'POST'); } /** * * Sanitize shipstation tracking numbers. Moved to here from the * class-routeapp-shipstation.php script because it is sometimes * getting bypassed and orders are coming through with "-(SHIPSTATION)" * at the end. Also added a more specific check for both a shipstation * prefix and suffix WITH the dash since shipstation has moved the * shipstation label to the back AND orders were coming through with * either a leading or trailing "-" * * @param $value * @return array|string */ private function sanitize_value($value) { $value = str_replace(['-(SHIPSTATION)', '(SHIPSTATION)-', '(Shipstation)'], '', $value); $value = str_replace('.', '', $value); $value = trim($value); return $value; } /** * Get the order shipment * @param integer $tracking_id * @param integer $order_id * @param array $data * @return mixed|json string */ public function get_shipment($tracking_id, $order_id) { if (empty($tracking_id) || empty($order_id)) return false; return $this->_make_private_api_call('shipments/' . $tracking_id . '?source_order_id=' . $order_id); } /** * Update the order shipment, currently only status update suported by API * @param integer $tracking_id * @param integer $order_id * @param array $data * @return mixed|json string */ public function update_shipment($tracking_id, $order_id, $data = array()) { if (empty($tracking_id) || empty($order_id)) return false; return $this->_make_private_api_call('shipments/' . $tracking_id . '?source_order_id=' . $order_id, array( 'source_order_id' => $data['source_order_id'], 'source_product_ids' => $data['source_product_ids'], 'courier_id' => $data['courier_id'], ), 'POST'); } /** * Cancel the order shipment, currently only status update suported by API * @param integer $tracking_id * @param integer $order_id * @param array $data * @return mixed|json string */ public function cancel_shipment($tracking_id, $data = array()) { if (empty($tracking_id)) return false; return $this->_make_private_api_call('shipments/' . $tracking_id . '/cancel' . '?source_order_id=' . $data['source_order_id'], array( 'source_order_id' => $data['source_order_id'], 'source_product_ids' => $data['source_product_ids'], ), 'POST'); } /** * Create the order, currently only status update suported by API * @param integer $data * @return mixed|json string */ public function create_order($data) { return $this->_make_private_api_call('orders', $data, 'POST', 'v2'); } /** * Get the order * @param integer $source_order_id * @return mixed|json string */ public function get_order($source_order_id) { return $this->_make_private_api_call('orders/' . $source_order_id, 'GET'); } /** * Update the order, currently only status update suported by API * @param integer $data * @return mixed|json string */ public function update_order($order_id, $data) { return $this->_make_private_api_call('orders/' . $order_id, $data, 'POST', 'v2'); } /** * Cancel the order, currently only status update suported by API * @param integer $order_id * @return mixed|json string */ public function cancel_order($order_id) { return $this->_make_private_api_call('orders/' . $order_id . '/cancel', array(), 'POST'); } /** * Get user billing status settings * @param integer $order_id * @return mixed|json string */ public function get_billing() { return $this->_make_private_api_call('billing', array(), 'GET'); } /** * Get the Route Merchant ID * @return mixed */ public function get_merchant_id() { return get_option(self::ROUTEAPP_MERCHANT_ID); } /** * Get the Route Merchant ID * * @param $merchant_id * @param $blog_id * * @return mixed */ public function set_merchant_id($merchant_id, $blog_id = null){ if (isset($blog_id) && is_multisite()) { return update_blog_option($blog_id, self::ROUTEAPP_MERCHANT_ID, $merchant_id); } return update_option(self::ROUTEAPP_MERCHANT_ID , $merchant_id); } public static function get_route_public_instance(){ global $routeapp_public; return $routeapp_public; } /** * Get merchant * @return mixed */ public function get_merchant() { if (!empty($this->_merchant)) { return $this->_merchant; } $endpoint = 'merchants'; $merchantResponse = $this->get_merchant_id() ? $this->_make_private_api_call($endpoint . '/' . $this->get_merchant_id(), array(), 'GET') : $this->_make_private_api_call($endpoint, array(), 'GET'); try { $response_code = wp_remote_retrieve_response_code($merchantResponse); if ( is_wp_error($merchantResponse) || $response_code != 200 ) { if ($response_code !== 403) { $errorMsg = is_wp_error($merchantResponse) ? $merchantResponse->get_error_message() : $response_code; throw new Exception("Route API Error while getting merchant data: " . $errorMsg); } $merchantResponse = $this->_make_private_api_call($endpoint, array(), 'GET'); } } catch(Exception $exception) { $routeapp_public = self::get_route_public_instance(); $routeapp_public->routeapp_log($exception, $this->_extraData); return false; } if ($merchantResponse) { if ($merchantResponse["body"]) { $body = json_decode($merchantResponse["body"]); $merchant = is_array($body) ? $body[0] : $body; if ($merchant) { $this->_merchant = $merchant; if (empty($this->get_merchant_id()) || (isset($merchant->id) && $merchant->id !== $this->get_merchant_id())) { $this->set_merchant_id($merchant->id, get_current_blog_id()); } return $this->_merchant; } } } } /** * Create user account * @param array $data * @return mixed|json string */ public function create_user($data) { return $this->_make_private_api_call( 'users', $data, 'POST' ); } /** * Create user account * @param $username * @param $password * @return mixed|json string */ public function login_user($username, $password) { return $this->_make_private_api_call( 'login', [ "username" => $username, "password" => $password ], 'POST' ); } /** * Create merchant account * @param array $data * @return mixed|json string */ public function create_merchant($data) { return $this->_make_private_api_call_using_user_token( 'merchants', $data, 'POST' ); } /** * Get merchant account by user * @return mixed|json string */ public function get_merchants() { return $this->_make_private_api_call_using_user_token( "users/" . $this->get_user_id() . "/merchants", [], 'GET' ); } /** * Get activate account link at Route API * * @param array $email * @return mixed|json string */ public function activate_account($email) { return $this->_make_private_api_call( 'activate_account', $email, 'POST' ); } /** * Get asset settings * @param $apiHost * @return mixed|json string */ public function asset_settings($apiHost) { return $this->_make_public_api_call("asset-settings/$apiHost", array(), 'GET'); } /** * Update the account status at Route API * * @return mixed|json string */ public function update_merchant_status($status) { $endpoint = 'merchants/' . $this->get_merchant_id(); $params = ['status' => $status]; return $this->_make_private_api_call( $endpoint, $params, 'POST' ); } /* * Make the call to the API * @param string $endpoint * @param array $params * @param string $method * @param string $version * @return mixed|json string */ private function _make_api_call($token, $endpoint, $params = array(), $method = 'GET', $version='v1') { $url = $version=='v1' ? $this->_api_url : $this->_api_url_v2; $url.= $endpoint; $extraData = array( 'params' => $params, 'method' => $method, 'endpoint' => $url ); $this->_extraData = $extraData; $headers = [ 'Content-Type' => 'application/json', 'token' => $token, ]; if ($version=='v2') { $headers['Protect-Widget-Version'] = 'route-widget-core'; } //platform $headers['platform'] = 'woocommerce'; //woocommerce + wordpress version $wooVersion = ''; if ( in_array( 'woocommerce/woocommerce.php', apply_filters( 'active_plugins', get_option( 'active_plugins' ) ) ) && defined('WC_VERSION') ) { $wooVersion = WC_VERSION; } $wordpressVersion = ''; if (function_exists('get_bloginfo')) { $wordpressVersion = get_bloginfo('version'); } $headers['platform_version'] = 'WooCommerce: ' . $wooVersion . ' WordPress: ' . $wordpressVersion; //route module version $module_version= defined('ROUTEAPP_VERSION') ? ROUTEAPP_VERSION :''; $headers['module_version'] = $module_version; $args = array( 'timeout' => 6, 'method' => $method, 'headers' => $headers, 'body' => $method === 'POST' ? json_encode($params) : null ); return wp_remote_request($url, $args); } private function _make_public_api_call($endpoint, $params = array(), $method = 'GET', $version='v1') { return $this->_make_api_call($this->get_public_token(), $endpoint, $params, $method, $version); } protected function _make_private_api_call($endpoint, $params = array(), $method = 'GET', $version='v1') { return $this->_make_api_call($this->get_secret_token(), $endpoint, $params, $method, $version); } protected function _make_private_api_call_using_user_token($endpoint, $params = array(), $method = 'GET', $version='v1') { return $this->_make_api_call($this->get_user_token(), $endpoint, $params, $method, $version); } /** * Exchange a one-time token for merchant data (no merchant API auth; Route v1/otp/verify). * * @param string $token OTP from Route Dashboard after signing in with platformUrl. * @return array|\WP_Error Merchant payload with id, public_api_key, prod_api_secret, store_domain on success. */ public function verify_otp( $token ) { $url = $this->_api_url . 'otp/verify'; $headers = array( 'Content-Type' => 'application/json', 'platform' => 'woocommerce', ); $wooVersion = ''; if ( in_array( 'woocommerce/woocommerce.php', apply_filters( 'active_plugins', get_option( 'active_plugins' ) ), true ) && defined( 'WC_VERSION' ) ) { $wooVersion = WC_VERSION; } $wordpressVersion = function_exists( 'get_bloginfo' ) ? get_bloginfo( 'version' ) : ''; $headers['platform_version'] = 'WooCommerce: ' . $wooVersion . ' WordPress: ' . $wordpressVersion; $headers['module_version'] = defined( 'ROUTEAPP_VERSION' ) ? ROUTEAPP_VERSION : ''; $args = array( 'timeout' => 15, 'method' => 'POST', 'headers' => $headers, 'body' => wp_json_encode( array( 'token' => $token ) ), ); $response = wp_remote_request( $url, $args ); if ( is_wp_error( $response ) ) { return $response; } $code = wp_remote_retrieve_response_code( $response ); $body_raw = wp_remote_retrieve_body( $response ); $data = json_decode( $body_raw, true ); if ( 200 !== (int) $code ) { $message = is_array( $data ) && ! empty( $data['error'] ) ? $data['error'] : 'OTP verification failed'; return new \WP_Error( 'route_otp_verify_failed', $message, array( 'status' => $code ) ); } if ( ! is_array( $data ) ) { return new \WP_Error( 'route_otp_invalid_response', 'Unexpected response from Route API' ); } $result = isset( $data['result'] ) && is_array( $data['result'] ) ? $data['result'] : $data; if ( empty( $result['id'] ) || empty( $result['public_api_key'] ) || empty( $result['prod_api_secret'] ) ) { return new \WP_Error( 'route_otp_incomplete', 'OTP response missing merchant credentials' ); } return $result; } }