logger = $logger; $this->rate_limiter = new Rate_Limiter($logger); $this->load_token(); } /** * Load stored token from database */ private function load_token() { $this->token = get_option('dszsync_api_token', ''); $this->token_expiry = intval(get_option('dszsync_token_expiry', 0)); } /** * Check if token is valid * * @return bool */ public function is_token_valid() { return !empty($this->token) && ($this->token_expiry - self::TOKEN_BUFFER_SECONDS) > time(); } /** * Get current token status * * @return array Token status info */ public function get_token_status() { return [ 'has_token' => !empty($this->token), 'is_valid' => $this->is_token_valid(), 'expires_at' => $this->token_expiry, 'expires_in' => max(0, $this->token_expiry - time()), ]; } /** * Authenticate with API * * @param string $email API email (optional, uses stored if not provided) * @param string $password API password (optional, uses stored if not provided) * @return array|WP_Error Token data or error */ public function authenticate($email = null, $password = null) { // Use stored credentials if not provided if (empty($email)) { $email = get_option('dszsync_api_email', ''); } if (empty($password)) { $encrypted_password = get_option('dszsync_api_password', ''); $password = dszsync_decrypt($encrypted_password); } // Validate credentials if (empty($email) || empty($password)) { $this->logger->error('Authentication failed: Missing credentials'); return new \WP_Error('missing_credentials', __('API email and password are required.', '3s-soft-price-stock-sync-for-dropshipzone')); } // Make authentication request $response = $this->make_request('POST', '/auth', [ 'email' => $email, 'password' => $password, ], false); // Don't use auth header for auth request if (is_wp_error($response)) { $this->logger->error('Authentication failed', ['error' => $response->get_error_message()]); return $response; } // Extract token data if (isset($response['token'])) { $this->token = $response['token']; $this->token_expiry = isset($response['exp']) ? intval($response['exp']) : (time() + 900); // 15 minutes default // Store token update_option('dszsync_api_token', $this->token); update_option('dszsync_token_expiry', $this->token_expiry); $this->logger->info('Authentication successful', [ 'expires_at' => gmdate('Y-m-d H:i:s', $this->token_expiry), ]); return [ 'success' => true, 'token' => $this->token, 'expires_at' => $this->token_expiry, ]; } $this->logger->error('Authentication failed: Invalid response', ['response' => $response]); return new \WP_Error('invalid_response', __('Invalid API response. Please check your credentials.', '3s-soft-price-stock-sync-for-dropshipzone')); } /** * Ensure we have a valid token, refresh if needed * * @return bool|WP_Error True if valid, error otherwise */ public function ensure_valid_token() { // Reload token from database in case it was updated elsewhere $this->load_token(); if ($this->is_token_valid()) { $this->logger->debug('Token is valid', [ 'expires_in' => $this->token_expiry - time(), ]); return true; } // Token expired or missing, need to refresh $this->logger->info('Token expired or missing, refreshing...', [ 'had_token' => !empty($this->token), 'expiry_was' => $this->token_expiry > 0 ? gmdate('Y-m-d H:i:s', $this->token_expiry) : 'never set', ]); // Try to refresh token $result = $this->authenticate(); if (is_wp_error($result)) { $this->logger->error('Token refresh failed', [ 'error' => $result->get_error_message(), ]); return $result; } $this->logger->info('Token refreshed successfully', [ 'expires_at' => gmdate('Y-m-d H:i:s', $this->token_expiry), 'expires_in' => $this->token_expiry - time(), ]); return true; } /** * Get products from API * * @param array $params Query parameters * @return array|WP_Error Products data or error */ public function get_products($params = []) { // Ensure valid token $token_check = $this->ensure_valid_token(); if (is_wp_error($token_check)) { return $token_check; } // Default parameters - don't include enabled when searching by SKU $defaults = [ 'page_no' => 1, 'limit' => 200, // Max allowed ]; // Only add enabled filter if not searching by SKUs if (empty($params['skus'])) { $defaults['enabled'] = true; } $params = wp_parse_args($params, $defaults); // API constraint: limit minimum 40, maximum 200 $params['limit'] = max(40, min(intval($params['limit']), 200)); // Log the API request details $this->logger->debug('Making products API request', [ 'params' => $params, ]); // Make request $response = $this->make_request('GET', '/v2/products', $params, true); if (is_wp_error($response)) { $this->logger->error('Failed to fetch products', [ 'error' => $response->get_error_message(), 'params' => $params, ]); return $response; } // Log success with more details $this->logger->debug('Products fetched successfully', [ 'page' => $params['page_no'], 'total' => isset($response['total']) ? $response['total'] : 0, 'result_count' => isset($response['result']) ? count($response['result']) : 0, ]); return $response; } /** * Get products by SKUs * * @param array $skus Array of SKUs (max 100 per the API) * @return array|WP_Error Products data or error */ public function get_products_by_skus($skus) { if (empty($skus)) { return ['result' => []]; } // API allows max 100 SKUs $skus = array_slice($skus, 0, 100); $skus_string = implode(',', $skus); $this->logger->debug('Fetching products by SKUs', [ 'sku_count' => count($skus), 'sample_skus' => array_slice($skus, 0, 5), ]); $response = $this->get_products([ 'skus' => $skus_string, 'limit' => 200, ]); // Log the response for debugging if (!is_wp_error($response)) { $result_count = isset($response['result']) ? count($response['result']) : 0; $this->logger->debug('API response for SKUs', [ 'requested' => count($skus), 'returned' => $result_count, 'total_in_api' => isset($response['total']) ? $response['total'] : 0, ]); // If no results, log the first few SKUs that weren't found if ($result_count === 0) { $this->logger->warning('No products found in API for requested SKUs', [ 'skus_requested' => array_slice($skus, 0, 10), ]); } } return $response; } /** * Get stock data (historical) * * @param array $skus Array of SKUs * @param string $start_time Start time (Y-m-d H:i:s) * @param string $end_time End time (Y-m-d H:i:s) * @param int $page_no Page number * @param int $limit Items per page * @return array|WP_Error Stock data or error */ public function get_stock($skus, $start_time = null, $end_time = null, $page_no = 1, $limit = 160) { // Ensure valid token $token_check = $this->ensure_valid_token(); if (is_wp_error($token_check)) { return $token_check; } // Default time range (last 10 days - API max) if (empty($start_time)) { $start_time = gmdate('Y-m-d H:i:s', strtotime('-9 days')); } if (empty($end_time)) { $end_time = gmdate('Y-m-d H:i:s'); } // API rejects ranges of 10 days or more ("must be less than 10 days apart") $start_ts = strtotime($start_time); $end_ts = strtotime($end_time); if ($start_ts && $end_ts && ($end_ts - $start_ts) >= 10 * DAY_IN_SECONDS) { return new \WP_Error( 'invalid_range', __('Stock history time range must be less than 10 days.', '3s-soft-price-stock-sync-for-dropshipzone') ); } $body = [ 'start_time' => $start_time, 'end_time' => $end_time, 'page_no' => $page_no, 'limit' => max(40, min($limit, 160)), // API limit range: 40-160 ]; if (!empty($skus)) { $body['skus'] = implode(',', array_slice($skus, 0, 100)); } $response = $this->make_request('POST', '/stock', $body, true); if (is_wp_error($response)) { $this->logger->error('Failed to fetch stock data', [ 'error' => $response->get_error_message(), ]); return $response; } return $response; } /** * Test API connection * * @param string $email API email * @param string $password API password * @return array|WP_Error Connection test result */ public function test_connection($email, $password) { $result = $this->authenticate($email, $password); if (is_wp_error($result)) { return $result; } // Try to fetch a single product to verify full access $products = $this->get_products(['limit' => 40, 'page_no' => 1]); if (is_wp_error($products)) { return $products; } return [ 'success' => true, 'message' => __('Connection successful!', '3s-soft-price-stock-sync-for-dropshipzone'), 'products_available' => isset($products['total']) ? $products['total'] : 0, ]; } /** * Make API request with load balancing and rate limit protection * * @param string $method HTTP method * @param string $endpoint API endpoint * @param array $data Request data * @param bool $use_auth Whether to include auth header * @param int $retry Current retry attempt * @return array|WP_Error Response data or error */ private function make_request($method, $endpoint, $data = [], $use_auth = true, $retry = 0) { // Apply smart load balancing - this handles: // 1. Hard rate limit enforcement (waits if limits hit) // 2. Adaptive delay based on current usage (proactive throttling) // 3. Minimum delay between requests (prevents bursting) $wait_info = $this->rate_limiter->smart_wait(); // Log if we had to wait significantly if ($wait_info['waited'] > 1) { $this->logger->info('Load balancer delay applied', [ 'endpoint' => $endpoint, 'waited' => round($wait_info['waited'], 2), 'reason' => $wait_info['reason'], 'usage' => $wait_info['usage'], ]); } // Double-check: if we still can't make a request, either do a short // capped wait or hand a retryable error back to the caller — request // threads must never block for minutes if (!$this->rate_limiter->can_make_request()) { $wait_time = $this->rate_limiter->get_wait_time(); if ($wait_time <= Rate_Limiter::MAX_BLOCKING_WAIT) { $this->logger->info('Rate limit: Additional wait needed', [ 'endpoint' => $endpoint, 'wait_seconds' => $wait_time, ]); $this->rate_limiter->wait_if_needed(Rate_Limiter::MAX_BLOCKING_WAIT); } if (!$this->rate_limiter->can_make_request()) { $wait_time = max(1, $this->rate_limiter->get_wait_time()); $this->logger->warning('Rate limit: Deferring request', [ 'endpoint' => $endpoint, 'retry_after' => $wait_time, ]); return new \WP_Error( 'rate_limited', sprintf( /* translators: %d: seconds to wait */ __('API rate limit reached. Retry in %d seconds.', '3s-soft-price-stock-sync-for-dropshipzone'), $wait_time ), ['retry_after' => intval($wait_time)] ); } } $url = self::API_BASE_URL . $endpoint; // Build headers $headers = [ 'Content-Type' => 'application/json', ]; if ($use_auth && !empty($this->token)) { $headers['Authorization'] = 'jwt ' . $this->token; } // Build request args $args = [ 'method' => $method, 'headers' => $headers, 'timeout' => 60, // Increased timeout for large requests 'sslverify' => true, ]; // Add body or query params based on method if ($method === 'GET') { if (!empty($data)) { // API documents filter params as Boolean and expects literal // true/false; add_query_arg would serialize PHP bools as 1/0. foreach ($data as $key => $value) { if (is_bool($value)) { $data[$key] = $value ? 'true' : 'false'; } } $url = add_query_arg($data, $url); } } else { $args['body'] = wp_json_encode($data); } // Log the actual API URL for debugging $this->logger->debug('API request', [ 'method' => $method, 'url' => $url, 'rate_limit_status' => $this->rate_limiter->get_status(), ]); // Record the request BEFORE making it $this->rate_limiter->record_request(); // Make request $response = wp_remote_request($url, $args); // Check for WP error if (is_wp_error($response)) { // Retry on connection errors with exponential backoff if ($retry < $this->max_retries) { $wait_time = pow(2, $retry) * 2; // 2, 4, 8 seconds $this->logger->warning('Request failed, retrying...', [ 'endpoint' => $endpoint, 'retry' => $retry + 1, 'wait_seconds' => $wait_time, 'error' => $response->get_error_message(), ]); sleep($wait_time); return $this->make_request($method, $endpoint, $data, $use_auth, $retry + 1); } return $response; } // Get response code and body $response_code = wp_remote_retrieve_response_code($response); $response_body = wp_remote_retrieve_body($response); $response_data = json_decode($response_body, true); // Handle different response codes if ($response_code === 401) { // Token expired during request if ($use_auth && $retry < $this->max_retries) { $this->logger->info('Token expired during request (401), refreshing...', [ 'endpoint' => $endpoint, 'retry' => $retry + 1, ]); $auth_result = $this->authenticate(); if (!is_wp_error($auth_result)) { $this->logger->info('Token refreshed, retrying request...'); sleep(1); // Small delay after re-auth return $this->make_request($method, $endpoint, $data, $use_auth, $retry + 1); } $this->logger->error('Token refresh failed during retry', [ 'error' => $auth_result->get_error_message(), ]); } return new \WP_Error('unauthorized', __('Authentication failed. Please check your credentials.', '3s-soft-price-stock-sync-for-dropshipzone')); } if ($response_code === 429) { // Rate limited by the API. One short in-request retry; anything // longer is returned as a retryable error instead of sleeping if ($retry < 1) { $wait_time = 10; $this->logger->warning('Rate limited (429), waiting...', [ 'endpoint' => $endpoint, 'wait_seconds' => $wait_time, ]); sleep($wait_time); return $this->make_request($method, $endpoint, $data, $use_auth, $retry + 1); } return new \WP_Error( 'rate_limited', __('API rate limit exceeded. Please try again later.', '3s-soft-price-stock-sync-for-dropshipzone'), ['retry_after' => 30] ); } if ($response_code >= 400) { $error_message = isset($response_data['message']) ? $response_data['message'] : __('API request failed.', '3s-soft-price-stock-sync-for-dropshipzone'); return new \WP_Error('api_error', $error_message, ['code' => $response_code, 'response' => $response_data]); } // Request spacing is handled by Rate_Limiter::smart_wait() (adaptive // delay + MIN_DELAY) — no extra fixed sleep needed here return $response_data; } /** * Clear stored token */ public function clear_token() { $this->token = null; $this->token_expiry = 0; delete_option('dszsync_api_token'); delete_option('dszsync_token_expiry'); } /** * Place an order in Dropshipzone * * Creates the order as "Not Submitted" status. User must login to DSZ to pay. * * @param array $order_data Order data with required fields * @return array|WP_Error Response with serial_number or error */ public function place_order($order_data) { // Validate required fields $required = ['your_order_no', 'first_name', 'last_name', 'address1', 'suburb', 'state', 'postcode', 'telephone', 'order_items']; foreach ($required as $field) { if (empty($order_data[$field])) { return new \WP_Error( 'missing_field', /* translators: %s: field name */ sprintf(__('Missing required field: %s', '3s-soft-price-stock-sync-for-dropshipzone'), $field) ); } } // Validate order_items if (!is_array($order_data['order_items']) || empty($order_data['order_items'])) { return new \WP_Error('invalid_items', __('Order items must be a non-empty array', '3s-soft-price-stock-sync-for-dropshipzone')); } foreach ($order_data['order_items'] as $item) { if (empty($item['sku']) || !isset($item['qty'])) { return new \WP_Error('invalid_item', __('Each order item must have sku and qty', '3s-soft-price-stock-sync-for-dropshipzone')); } } $this->logger->info('Placing order in Dropshipzone', [ 'order_no' => $order_data['your_order_no'], 'items_count' => count($order_data['order_items']), ]); $response = $this->make_request('POST', '/placingOrder', $order_data); if (is_wp_error($response)) { $this->logger->error('Failed to place order', [ 'order_no' => $order_data['your_order_no'], 'error' => $response->get_error_message(), ]); return $response; } // API returns an array of result entries with HTTP 200 even on failure; // any entry with status != 1 carries an errmsg. Treat any failure as an error. if (is_array($response) && !empty($response[0])) { $errors = []; foreach ($response as $entry) { if (!is_array($entry)) { continue; } if (!isset($entry['status']) || intval($entry['status']) !== 1) { $errors[] = $entry['errmsg'] ?? __('Unknown error', '3s-soft-price-stock-sync-for-dropshipzone'); } } if (empty($errors)) { $result = $response[0]; $this->logger->info('Order placed successfully', [ 'order_no' => $order_data['your_order_no'], 'dszsync_serial' => $result['serial_number'] ?? '', ]); return $result; } $error_msg = implode('; ', array_unique($errors)); $this->logger->error('Order placement failed', [ 'order_no' => $order_data['your_order_no'], 'error' => $error_msg, ]); return new \WP_Error('order_failed', $error_msg, $response); } return new \WP_Error('invalid_response', __('Invalid response from Dropshipzone API', '3s-soft-price-stock-sync-for-dropshipzone')); } /** * Get orders from Dropshipzone * * @param array $params Query parameters (order_ids, start_date, end_date, status, page_no, limit) * @return array|WP_Error Orders data or error */ public function get_orders($params = []) { $defaults = [ 'page_no' => 1, 'limit' => 40, ]; $params = wp_parse_args($params, $defaults); // API constraint: limit range 40-160 $params['limit'] = max(40, min(intval($params['limit']), 160)); // API rejects date ranges over 14 days apart if (!empty($params['start_date']) && !empty($params['end_date'])) { $start_ts = strtotime($params['start_date']); $end_ts = strtotime($params['end_date']); if ($start_ts && $end_ts && ($end_ts - $start_ts) > 14 * DAY_IN_SECONDS) { return new \WP_Error( 'invalid_range', __('Order date range must be 14 days or less.', '3s-soft-price-stock-sync-for-dropshipzone') ); } } $this->logger->debug('Fetching orders from Dropshipzone', $params); return $this->make_request('GET', '/orders', $params); } /** * Get all categories from Dropshipzone * * @return array|WP_Error Categories data or error */ public function get_categories() { $this->logger->debug('Fetching categories from Dropshipzone'); return $this->make_request('GET', '/v2/categories'); } /** * Get zone mapping for postcodes (V2) * * Maps Australian postcodes to shipping zones (standard, defined, advanced) * * @param string|array $postcodes Postcode(s) to map * @param array $params Additional params (page_no, limit) * @return array|WP_Error Zone mapping data or error */ public function get_zone_mapping($postcodes, $params = []) { $defaults = [ 'page_no' => 1, 'limit' => 40, ]; $params = wp_parse_args($params, $defaults); $data = [ 'postcode' => is_array($postcodes) ? implode(',', $postcodes) : $postcodes, 'page_no' => $params['page_no'], 'limit' => max(40, min(intval($params['limit']), 160)), // API limit range: 40-160 ]; $this->logger->debug('Fetching zone mapping from Dropshipzone', ['postcodes' => $data['postcode']]); return $this->make_request('POST', '/v2/get_zone_mapping', $data); } /** * Get zone rates for SKUs (V2) * * Returns shipping rates per zone for specified product SKUs * * @param string|array $skus SKU(s) to get rates for * @param array $params Additional params (page_no, limit) * @return array|WP_Error Zone rates data or error */ public function get_zone_rates($skus, $params = []) { $defaults = [ 'page_no' => 1, 'limit' => 40, ]; $params = wp_parse_args($params, $defaults); $data = [ 'skus' => is_array($skus) ? implode(',', $skus) : $skus, 'page_no' => $params['page_no'], 'limit' => max(40, min(intval($params['limit']), 160)), // API limit range: 40-160 ]; $this->logger->debug('Fetching zone rates from Dropshipzone', ['skus' => $data['skus']]); return $this->make_request('POST', '/v2/get_zone_rates', $data); } }