PluginProbe
Route ‑ Shipping Protection / 2.4.18
Route ‑ Shipping Protection v2.4.18
2.4.19 2.4.18 2.4.17 2.4.16 2.4.15 2.4.14 2.4.13 2.4.12 2.4.11 2.4.10 2.4.9 2.4.8 2.4.7 2.4.6 2.4.5 2.4.4 2.4.3 2.4.2 2.2.4 2.2.5 2.2.6 2.2.8 2.2.9 2.3.0 2.3.1 All 153 releases
← All changes | includes/class-routeapp-api-client.php +333 -14 2.2.6 → 2.4.18 View file →
@@ -93,13 +93,13 @@
93 93 if (is_null($custom_env) || !$custom_env) {
94 94 $custom_env = isset($_SERVER['ROUTEAPP_ENVIRONMENT_ENDPOINT']) ? $_SERVER['ROUTEAPP_ENVIRONMENT_ENDPOINT'] : '';
95 95 }
96 96 if ($custom_env == 'stage') {
97 - $this->_api_url = rtrim($this->_api_url, '/') . self::API_STAGE_ENDPOINT_V1;
98 - $this->_api_url_v2 = rtrim($this->_api_url_v2, '/') . self::API_STAGE_ENDPOINT_V2;
97 + $this->_api_url = rtrim($this->_api_url ?? '', '/') . self::API_STAGE_ENDPOINT_V1;
98 + $this->_api_url_v2 = rtrim($this->_api_url_v2 ?? '', '/') . self::API_STAGE_ENDPOINT_V2;
99 99 } else {
100 - $this->_api_url = rtrim($this->_api_url, '/') . self::API_ENDPOINT_V1;
101 - $this->_api_url_v2 = rtrim($this->_api_url_v2, '/') . self::API_ENDPOINT_V2;
100 + $this->_api_url = rtrim($this->_api_url ?? '', '/') . self::API_ENDPOINT_V1;
101 + $this->_api_url_v2 = rtrim($this->_api_url_v2 ?? '', '/') . self::API_ENDPOINT_V2;
102 102 }
103 103 }
104 104
105 105 /**
@@ -176,8 +176,9 @@
176 176 }
177 177
178 178 /**
179 179 * Get current quote price based on subtotal
180 + * Optimized to store only essential data in session
180 181 * @param $cartRef
181 182 * @param $cartTotal
182 183 * @param $currency
183 184 * @param $cartItems
@@ -201,15 +202,19 @@
201 202 if ($cached) {
202 203 if (time() - $cached['createdAt'] > 1800) {
203 204 //if creation date is more than 30 minutes, we unset it
204 205 WC()->session->__unset($key);
206 + $lastCalledMade = $key . '-latest';
207 + WC()->session->__unset($lastCalledMade);
208 + // Clean up all old entries when we find an expired one
209 + $this->_cleanup_old_quote_sessions();
205 210 } else {
206 211 return $cached['result'];
207 212 }
208 213 }
209 214
210 - $cached['createdAt'] = time();
211 - $cached['result'] = $this->_make_private_api_call('quotes', array(
215 + // Make API call
216 + $api_response = $this->_make_private_api_call('quotes', array(
212 217 'merchant_id' => $merchant_id,
213 218 'cart' => [
214 219 'cart_ref' => strval($cartRef),
215 220 'covered' => [
@@ -218,17 +223,229 @@
218 223 ],
219 224 'cart_items' => $cartItems,
220 225 ],
221 226 ), 'POST', 'v2');
227 +
228 + // Extract only essential data from API response to minimize session storage
229 + $essential_data = $this->_extract_essential_quote_data($api_response);
230 +
231 + // Store only essential data in session (not the full HTTP response)
222 232 if (WC()->session) {
233 + // Clean up old entries BEFORE adding new one to enforce limit
234 + // This ensures we don't exceed max_entries even when adding a new quote
235 + $this->_cleanup_old_quote_sessions();
236 +
237 + $created_at = time();
238 + $cached = array(
239 + 'createdAt' => $created_at,
240 + 'result' => $essential_data
241 + );
223 242 WC()->session->set($key, $cached);
224 - $lastCalledMade = $key. '-latest';
225 - WC()->session->set($lastCalledMade, $cached['result']);
243 +
244 + // Store latest quote data (also minimal)
245 + // Format: array with 'body' key for compatibility with routeapp_save_quote_to_order
246 + $lastCalledMade = $key . '-latest';
247 + WC()->session->set($lastCalledMade, $essential_data);
248 +
249 + // Track this key for cleanup and limit total entries
250 + $this->_track_quote_session_key($key, $created_at);
251 +
252 + // Clean up again after adding to ensure limit is strictly enforced
253 + // This handles the case where we had exactly max_entries before adding
254 + $this->_cleanup_old_quote_sessions();
226 255 }
227 - return $cached['result'];
256 +
257 + return $essential_data;
228 258 }
229 259
230 260 /**
261 + * Extract only essential data from API response
262 + * Prevents storing full HTTP response objects in session
263 + * Stores only: id, premium.amount, premium.currency, payment_responsible.type, payment_responsible.ToggleState
264 + *
265 + * @param array|WP_Error $api_response The full API response from wp_remote_request
266 + * @return array Minimal quote data in expected format (compatible with existing code)
267 + */
268 + private function _extract_essential_quote_data($api_response)
269 + {
270 + // Handle errors - return in expected format
271 + if (is_wp_error($api_response)) {
272 + return array(
273 + 'response' => array('code' => 500),
274 + 'body' => json_encode(array('premium' => array('amount' => '0')))
275 + );
276 + }
277 +
278 + // Extract body from response
279 + $response_code = wp_remote_retrieve_response_code($api_response);
280 + $response_body = wp_remote_retrieve_body($api_response);
281 +
282 + // If API call failed, return original format for error handling
283 + if ($response_code !== 200 && $response_code !== 201) {
284 + return $api_response; // Return original for error handling
285 + }
286 +
287 + // Parse JSON body
288 + $body_data = json_decode($response_body, true);
289 +
290 + if (!$body_data || empty($body_data)) {
291 + return array(
292 + 'response' => array('code' => $response_code),
293 + 'body' => json_encode(array('premium' => array('amount' => '0')))
294 + );
295 + }
296 +
297 + $essential_quote = array();
298 +
299 + // Extract essential fields
300 + if (isset($body_data['id'])) {
301 + $essential_quote['id'] = $body_data['id'];
302 + }
303 +
304 + if (isset($body_data['premium'])) {
305 + $essential_quote['premium'] = array(
306 + 'currency' => isset($body_data['premium']['currency']) ? $body_data['premium']['currency'] : 'USD',
307 + 'amount' => isset($body_data['premium']['amount']) ? $body_data['premium']['amount'] : '0'
308 + );
309 + }
310 +
311 + if (isset($body_data['payment_responsible'])) {
312 + $payment = $body_data['payment_responsible'];
313 + $essential_quote['payment_responsible'] = array(
314 + 'type' => isset($payment['type']) ? $payment['type'] : 'paid_by_merchant'
315 + );
316 +
317 + // Convert to boolean ToggleState for compatibility with existing code
318 + if (array_key_exists('toggle_state', $payment)) {
319 + $toggle_state_value = $payment['toggle_state'];
320 + // Convert "checked" -> true, "unchecked" -> false
321 + $essential_quote['payment_responsible']['ToggleState'] = ($toggle_state_value === 'checked' || $toggle_state_value === true || $toggle_state_value === 1);
322 + } elseif (array_key_exists('ToggleState', $payment)) {
323 + // Fallback for camelCase format (if API changes)
324 + $essential_quote['payment_responsible']['ToggleState'] = $payment['ToggleState'];
325 + }
326 + }
327 +
328 + // Return in expected format (compatible with routeapp_get_quote_from_api)
329 + return array(
330 + 'response' => array('code' => $response_code),
331 + 'body' => json_encode($essential_quote, JSON_FORCE_OBJECT)
332 + );
333 + }
334 +
335 + /**
336 + * Track quote session keys for cleanup management
337 + *
338 + * @param string $key Session key
339 + * @param int $created_at Timestamp
340 + */
341 + private function _track_quote_session_key($key, $created_at)
342 + {
343 + if (!WC()->session) {
344 + return;
345 + }
346 +
347 + $tracker_key = $this->get_cache_api_session_key() . '_keys';
348 + $tracked_keys = WC()->session->get($tracker_key);
349 +
350 + if (!is_array($tracked_keys)) {
351 + $tracked_keys = array();
352 + }
353 +
354 + // Add current key to tracker (avoid duplicates)
355 + $key_exists = false;
356 + foreach ($tracked_keys as $index => $tracked) {
357 + if (isset($tracked['key']) && $tracked['key'] === $key) {
358 + $tracked_keys[$index]['time'] = $created_at; // Update timestamp
359 + $key_exists = true;
360 + break;
361 + }
362 + }
363 +
364 + if (!$key_exists) {
365 + $tracked_keys[] = array('key' => $key, 'time' => $created_at);
366 + }
367 +
368 + WC()->session->set($tracker_key, $tracked_keys);
369 + }
370 +
371 + /**
372 + * Clean up old quote session entries to prevent session bloat
373 + * Removes entries older than 30 minutes and limits total entries per session
374 + * This prevents accumulation of hundreds of quote entries
375 + */
376 + private function _cleanup_old_quote_sessions()
377 + {
378 + if (!WC()->session) {
379 + return;
380 + }
381 +
382 + $cache_prefix = $this->get_cache_api_session_key();
383 + $current_time = time();
384 + $max_age = 1800; // 30 minutes
385 + $max_entries = 5; // Maximum number of quote entries per session (reduced from potential hundreds)
386 +
387 + // Get tracked keys
388 + $tracker_key = $cache_prefix . '_keys';
389 + $tracked_keys = WC()->session->get($tracker_key);
390 +
391 + if (!is_array($tracked_keys) || empty($tracked_keys)) {
392 + return;
393 + }
394 +
395 + // Clean up old entries (expired) and verify they still exist in session
396 + $valid_keys = array();
397 + foreach ($tracked_keys as $key_with_timestamp) {
398 + if (!is_array($key_with_timestamp) || !isset($key_with_timestamp['key']) || !isset($key_with_timestamp['time'])) {
399 + continue;
400 + }
401 +
402 + $key = $key_with_timestamp['key'];
403 + $created_at = $key_with_timestamp['time'];
404 + $age = $current_time - $created_at;
405 +
406 + // Check if entry still exists in session (might have been manually removed)
407 + $session_entry = WC()->session->get($key);
408 +
409 + if ($age > $max_age || !$session_entry) {
410 + // Remove expired or missing entry
411 + WC()->session->__unset($key);
412 + WC()->session->__unset($key . '-latest');
413 + } else {
414 + // Keep valid entry
415 + $valid_keys[] = $key_with_timestamp;
416 + }
417 + }
418 +
419 + // Always sort by time (newest first) to prepare for limiting
420 + usort($valid_keys, function($a, $b) {
421 + return $b['time'] - $a['time'];
422 + });
423 +
424 + // Limit total entries (keep most recent) - STRICTLY enforce max_entries
425 + if (count($valid_keys) > $max_entries) {
426 + // Keep only max_entries (most recent)
427 + $keys_to_keep = array_slice($valid_keys, 0, $max_entries);
428 +
429 + // Remove excess entries (older ones beyond limit)
430 + $keys_to_remove = array_slice($valid_keys, $max_entries);
431 + foreach ($keys_to_remove as $excess_entry) {
432 + if (isset($excess_entry['key'])) {
433 + $excess_key = $excess_entry['key'];
434 + WC()->session->__unset($excess_key);
435 + WC()->session->__unset($excess_key . '-latest');
436 + }
437 + }
438 +
439 + // Update tracker with only kept keys (maintain sorted order)
440 + $valid_keys = $keys_to_keep;
441 + }
442 +
443 + // Always update tracker to maintain correct order and remove any orphaned entries
444 + WC()->session->set($tracker_key, $valid_keys);
445 + }
446 +
447 + /**
231 448 * Create the order shipment, currently only status update suported by API
232 449 * @param integer $tracking_id
233 450 * @param array $data
234 451 * @return mixed|json string
@@ -235,18 +452,64 @@
235 452 */
236 453 public function create_shipment($tracking_id, $data = array())
237 454 {
238 455 if (empty($tracking_id)) return false;
239 - return $this->_make_private_api_call('shipments', array(
456 + $payload = array(
240 457 'tracking_number' => $this->sanitize_value($tracking_id),
241 - 'source_order_id' => $data['source_order_id'],
242 - 'source_product_ids' => $data['source_product_ids'],
243 - 'courier_id' => $this->sanitize_value($data['courier_id']),
244 - ), 'POST');
458 + 'source_order_id' => isset($data['source_order_id']) ? $data['source_order_id'] : null,
459 + 'source_product_ids' => isset($data['source_product_ids']) ? $data['source_product_ids'] : array(),
460 + 'courier_id' => $this->sanitize_value(isset($data['courier_id']) ? $data['courier_id'] : ''),
461 + );
462 + $response = $this->_make_private_api_call('shipments', $payload, 'POST');
463 +
464 + return $this->retry_create_shipment_with_parent_product_ids($payload, $response);
245 465 }
246 466
247 467 /**
468 + * If the first create-shipment is rejected (HTTP 400, typically Invalid
469 + * ProductIDs), retry once with parent product_ids from the Woo order.
470 + * Those match webhook order-create when variation meta never reaches Route.
248 471 *
472 + * @param array $payload Original shipment payload.
473 + * @param array|\WP_Error $response First create-shipment response.
474 + * @return array|\WP_Error
475 + */
476 + private function retry_create_shipment_with_parent_product_ids($payload, $response)
477 + {
478 + if ( ! class_exists('Routeapp_WooCommerce_Common_Tracking_Provider') ) {
479 + return $response;
480 + }
481 +
482 + if ( Routeapp_WooCommerce_Common_Tracking_Provider::is_successful_shipment_create_response($response) ) {
483 + return $response;
484 + }
485 +
486 + $code = 0;
487 + if ( isset($response['response']['code']) ) {
488 + $code = (int) $response['response']['code'];
489 + } elseif ( function_exists('wp_remote_retrieve_response_code') ) {
490 + $code = (int) wp_remote_retrieve_response_code($response);
491 + }
492 + if ( $code !== 400 || empty($payload['source_order_id']) ) {
493 + return $response;
494 + }
495 +
496 + $parent_ids = Routeapp_WooCommerce_Common_Tracking_Provider::get_order_products_parent_ids($payload['source_order_id']);
497 + if ( empty($parent_ids) ) {
498 + return $response;
499 + }
500 +
501 + $original_ids = array_map('strval', (array) $payload['source_product_ids']);
502 + if ( $original_ids === $parent_ids ) {
503 + return $response;
504 + }
505 +
506 + $payload['source_product_ids'] = $parent_ids;
507 + return $this->_make_private_api_call('shipments', $payload, 'POST');
508 + }
509 +
510 + /**
511 + *
249 512 * Sanitize shipstation tracking numbers. Moved to here from the
250 513 * class-routeapp-shipstation.php script because it is sometimes
251 514 * getting bypassed and orders are coming through with "-(SHIPSTATION)"
252 515 * at the end. Also added a more specific check for both a shipstation
@@ -579,6 +842,62 @@
579 842
580 843 protected function _make_private_api_call_using_user_token($endpoint, $params = array(), $method = 'GET', $version='v1')
581 844 {
582 845 return $this->_make_api_call($this->get_user_token(), $endpoint, $params, $method, $version);
846 + }
847 +
848 + /**
849 + * Exchange a one-time token for merchant data (no merchant API auth; Route v1/otp/verify).
850 + *
851 + * @param string $token OTP from Route Dashboard after signing in with platformUrl.
852 + * @return array|\WP_Error Merchant payload with id, public_api_key, prod_api_secret, store_domain on success.
853 + */
854 + public function verify_otp( $token ) {
855 + $url = $this->_api_url . 'otp/verify';
856 + $headers = array(
857 + 'Content-Type' => 'application/json',
858 + 'platform' => 'woocommerce',
859 + );
860 + $wooVersion = '';
861 + if ( in_array( 'woocommerce/woocommerce.php', apply_filters( 'active_plugins', get_option( 'active_plugins' ) ), true ) &&
862 + defined( 'WC_VERSION' ) ) {
863 + $wooVersion = WC_VERSION;
864 + }
865 + $wordpressVersion = function_exists( 'get_bloginfo' ) ? get_bloginfo( 'version' ) : '';
866 + $headers['platform_version'] = 'WooCommerce: ' . $wooVersion . ' WordPress: ' . $wordpressVersion;
867 + $headers['module_version'] = defined( 'ROUTEAPP_VERSION' ) ? ROUTEAPP_VERSION : '';
868 +
869 + $args = array(
870 + 'timeout' => 15,
871 + 'method' => 'POST',
872 + 'headers' => $headers,
873 + 'body' => wp_json_encode( array( 'token' => $token ) ),
874 + );
875 +
876 + $response = wp_remote_request( $url, $args );
877 +
878 + if ( is_wp_error( $response ) ) {
879 + return $response;
880 + }
881 +
882 + $code = wp_remote_retrieve_response_code( $response );
883 + $body_raw = wp_remote_retrieve_body( $response );
884 + $data = json_decode( $body_raw, true );
885 +
886 + if ( 200 !== (int) $code ) {
887 + $message = is_array( $data ) && ! empty( $data['error'] ) ? $data['error'] : 'OTP verification failed';
888 + return new \WP_Error( 'route_otp_verify_failed', $message, array( 'status' => $code ) );
889 + }
890 +
891 + if ( ! is_array( $data ) ) {
892 + return new \WP_Error( 'route_otp_invalid_response', 'Unexpected response from Route API' );
893 + }
894 +
895 + $result = isset( $data['result'] ) && is_array( $data['result'] ) ? $data['result'] : $data;
896 +
897 + if ( empty( $result['id'] ) || empty( $result['public_api_key'] ) || empty( $result['prod_api_secret'] ) ) {
898 + return new \WP_Error( 'route_otp_incomplete', 'OTP response missing merchant credentials' );
899 + }
900 +
901 + return $result;
583 902 }
584 903 }