| @@ -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 | } |