$url, 'method' => $method, ) ); // Initialize cURL $ch = curl_init(); // Set base options $curl_options = array( CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => self::TIMEOUT, CURLOPT_HTTPHEADER => array( 'Authorization: Bearer ' . $token, 'Content-Type: application/json', 'Accept: application/json', ), ); // Set method-specific options switch ( $method ) { case 'POST': $curl_options[ CURLOPT_POST ] = true; if ( ! empty( $params ) ) { $curl_options[ CURLOPT_POSTFIELDS ] = wp_json_encode( $params ); } break; case 'PATCH': $curl_options[ CURLOPT_CUSTOMREQUEST ] = 'PATCH'; if ( ! empty( $params ) ) { $curl_options[ CURLOPT_POSTFIELDS ] = wp_json_encode( $params ); } // Add If-Match header for PATCH requests $curl_options[ CURLOPT_HTTPHEADER ][] = 'If-Match: *'; break; case 'DELETE': $curl_options[ CURLOPT_CUSTOMREQUEST ] = 'DELETE'; break; default: // GET $curl_options[ CURLOPT_HTTPGET ] = true; break; } curl_setopt_array( $ch, $curl_options ); // Execute request $response = curl_exec( $ch ); $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); $curl_error = curl_error( $ch ); curl_close( $ch ); // Handle cURL errors if ( $response === false ) { WBC_Logger::error( 'API', 'cURL error', array( 'error' => $curl_error, 'url' => $url, ) ); return new WP_Error( 'wbc_curl_error', $curl_error ); } // Parse response $data = json_decode( $response, true ); // Handle 401 (unauthorized) - token might be expired if ( $http_code === 401 && $retry_count < self::MAX_RETRIES ) { WBC_Logger::warning( 'API', 'Received 401, refreshing token and retrying', array( 'retry_count' => $retry_count + 1, ) ); // Clear token cache and retry WBC_OAuth::clear_token_cache(); sleep( self::RETRY_DELAY ); return self::request( $method, $endpoint, $params, $include_company, $retry_count + 1 ); } // Handle rate limiting (429) if ( $http_code === 429 && $retry_count < self::MAX_RETRIES ) { $retry_after = isset( $data['error']['retryAfterSeconds'] ) ? (int) $data['error']['retryAfterSeconds'] : self::RETRY_DELAY * ( $retry_count + 1 ); WBC_Logger::warning( 'API', 'Rate limited, retrying after delay', array( 'retry_after' => $retry_after, 'retry_count' => $retry_count + 1, ) ); sleep( $retry_after ); return self::request( $method, $endpoint, $params, $include_company, $retry_count + 1 ); } // Handle server errors (5xx) if ( $http_code >= 500 && $retry_count < self::MAX_RETRIES ) { WBC_Logger::warning( 'API', 'Server error, retrying', array( 'http_code' => $http_code, 'retry_count' => $retry_count + 1, ) ); sleep( self::RETRY_DELAY * ( $retry_count + 1 ) ); return self::request( $method, $endpoint, $params, $include_company, $retry_count + 1 ); } // Handle other errors if ( $http_code >= 400 ) { $error_message = isset( $data['error']['message'] ) ? $data['error']['message'] : __( 'API request failed', 'woo-business-central' ); WBC_Logger::error( 'API', 'Request failed', array( 'http_code' => $http_code, 'error' => $data['error'] ?? null, 'url' => $url, ) ); return new WP_Error( 'wbc_api_error', $error_message, array( 'status' => $http_code, 'error' => $data['error'] ?? null, ) ); } WBC_Logger::debug( 'API', 'Request successful', array( 'http_code' => $http_code, 'url' => $url, ) ); return $data; } /** * GET request * * @param string $endpoint API endpoint. * @param array $params Query parameters. * @param bool $include_company Whether to include company ID. * @return array|WP_Error Response data or error. */ public static function get( $endpoint, $params = array(), $include_company = true ) { return self::request( 'GET', $endpoint, $params, $include_company ); } /** * POST request * * @param string $endpoint API endpoint. * @param array $data Request body data. * @param bool $include_company Whether to include company ID. * @return array|WP_Error Response data or error. */ public static function post( $endpoint, $data = array(), $include_company = true ) { return self::request( 'POST', $endpoint, $data, $include_company ); } /** * PATCH request * * @param string $endpoint API endpoint. * @param array $data Request body data. * @param bool $include_company Whether to include company ID. * @return array|WP_Error Response data or error. */ public static function patch( $endpoint, $data = array(), $include_company = true ) { return self::request( 'PATCH', $endpoint, $data, $include_company ); } /** * DELETE request * * @param string $endpoint API endpoint. * @param bool $include_company Whether to include company ID. * @return array|WP_Error Response data or error. */ public static function delete( $endpoint, $include_company = true ) { return self::request( 'DELETE', $endpoint, array(), $include_company ); } /** * Get all companies * * @return array|WP_Error Array of companies or error. */ public static function get_companies() { return self::get( '/companies', array(), false ); } /** * Get items with pagination support * * @param int $top Number of items to fetch. * @param int $skip Number of items to skip. * @param string $select Fields to select. * @return array|WP_Error Response data or error. */ public static function get_items( $top = 100, $skip = 0, $select = '' ) { $params = array( '$top' => $top, '$skip' => $skip, ); if ( ! empty( $select ) ) { $params['$select'] = $select; } return self::get( '/items', $params ); } /** * Get customers with optional filter * * @param string $filter OData filter expression. * @return array|WP_Error Response data or error. */ public static function get_customers( $filter = '' ) { $params = array(); if ( ! empty( $filter ) ) { $params['$filter'] = $filter; } return self::get( '/customers', $params ); } /** * Create a customer * * @param array $data Customer data. * @return array|WP_Error Response data or error. */ public static function create_customer( $data ) { return self::post( '/customers', $data ); } /** * Create a sales order * * @param array $data Sales order data. * @return array|WP_Error Response data or error. */ public static function create_sales_order( $data ) { return self::post( '/salesOrders', $data ); } /** * Create a sales order line * * @param string $order_id Sales order ID. * @param array $data Sales order line data. * @return array|WP_Error Response data or error. */ public static function create_sales_order_line( $order_id, $data ) { return self::post( '/salesOrders(' . $order_id . ')/salesOrderLines', $data ); } /** * Escape a string value for use in OData filter expressions * * @param string $value The value to escape. * @return string Escaped value safe for OData filters. */ public static function escape_odata_string( $value ) { return str_replace( "'", "''", $value ); } /** * Build the OData V4 base URL for custom endpoints * * @return string|WP_Error Base URL or error if company name not configured. */ private static function get_odata_base_url() { $tenant_id = WBC_OAuth::get_tenant_id(); $environment = WBC_OAuth::get_environment(); $company_name = get_option( 'wbc_company_name', '' ); if ( empty( $company_name ) ) { return new WP_Error( 'wbc_missing_company_name', __( 'Company Name is not configured (required for OData endpoints).', 'woo-business-central' ) ); } return sprintf( self::ODATA_URL, $tenant_id, $environment, rawurlencode( $company_name ) ); } /** * GET request to a custom OData V4 endpoint * * @param string $endpoint OData endpoint name (e.g. 'ItemByLocation'). * @param array $params OData query parameters ($filter, $select, etc.). * @return array|WP_Error Response data or error. */ public static function odata_get( $endpoint, $params = array() ) { $base_url = self::get_odata_base_url(); if ( is_wp_error( $base_url ) ) { return $base_url; } $url = $base_url . '/' . $endpoint; if ( ! empty( $params ) ) { $url .= '?' . http_build_query( $params ); } // Get access token $token = WBC_OAuth::get_access_token(); if ( is_wp_error( $token ) ) { return $token; } WBC_Logger::debug( 'API', 'OData GET request', array( 'url' => $url ) ); $ch = curl_init(); curl_setopt_array( $ch, array( CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => self::TIMEOUT, CURLOPT_HTTPGET => true, CURLOPT_HTTPHEADER => array( 'Authorization: Bearer ' . $token, 'Accept: application/json', ), ) ); $response = curl_exec( $ch ); $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); $curl_error = curl_error( $ch ); curl_close( $ch ); if ( $response === false ) { WBC_Logger::error( 'API', 'OData cURL error', array( 'error' => $curl_error, 'url' => $url ) ); return new WP_Error( 'wbc_curl_error', $curl_error ); } $data = json_decode( $response, true ); // Handle 401 - retry with fresh token if ( $http_code === 401 ) { WBC_OAuth::clear_token_cache(); $token = WBC_OAuth::get_access_token( true ); if ( is_wp_error( $token ) ) { return $token; } $ch = curl_init(); curl_setopt_array( $ch, array( CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => self::TIMEOUT, CURLOPT_HTTPGET => true, CURLOPT_HTTPHEADER => array( 'Authorization: Bearer ' . $token, 'Accept: application/json', ), ) ); $response = curl_exec( $ch ); $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); curl_close( $ch ); $data = json_decode( $response, true ); } if ( $http_code >= 400 ) { $error_message = isset( $data['error']['message'] ) ? $data['error']['message'] : __( 'OData request failed', 'woo-business-central' ); WBC_Logger::error( 'API', 'OData request failed', array( 'http_code' => $http_code, 'error' => $data['error'] ?? null, 'url' => $url, ) ); return new WP_Error( 'wbc_odata_error', $error_message, array( 'status' => $http_code ) ); } WBC_Logger::debug( 'API', 'OData request successful', array( 'http_code' => $http_code, 'url' => $url ) ); return $data; } /** * Get a specific item by number * * @param string $item_number Item number. * @return array|WP_Error Response data or error. */ public static function get_item_by_number( $item_number ) { $params = array( '$filter' => "number eq '" . self::escape_odata_string( $item_number ) . "'", ); return self::get( '/items', $params ); } }