tokens = $tokens ?: new QBWS_Token_Manager(); } public function get(string $path, array $query = []): ?array { return $this->request('GET', $path, $query); } public function post(string $path, array $body): ?array { return $this->request('POST', $path, [], $body); } public function query(string $sql): array { $realm = QBWS_Config::get('realm_id'); $result = $this->get("v3/company/{$realm}/query", ['query' => $sql]); return $result['QueryResponse'] ?? []; } /** * Like get(), but returns the raw response body instead of decoding * JSON — used for QB's binary PDF endpoints * (v3/company/{realm}/invoice/{id}/pdf and the salesreceipt equivalent). * * @return array{body:string,content_type:string}|null */ public function get_raw(string $path, array $extraHeaders = []): ?array { $token = $this->tokens->getAccessToken(); if (empty($token)) { QBWS_Logger::error('API', 'No access token available — aborting raw request to ' . $path); return null; } $url = trailingslashit(QBWS_Config::base_url()) . ltrim($path, '/') . '?minorversion=' . QBWS_Config::MINOR_VERSION; $response = wp_remote_get($url, [ 'timeout' => 30, 'headers' => array_merge(['Authorization' => 'Bearer ' . $token], $extraHeaders), ]); if (is_wp_error($response)) { QBWS_Logger::error('API', "Transport error on raw GET {$path}: " . $response->get_error_message()); return null; } $code = wp_remote_retrieve_response_code($response); if ($code < 200 || $code >= 300) { QBWS_Logger::error('API', "HTTP {$code} on raw GET {$path}"); return null; } return [ 'body' => wp_remote_retrieve_body($response), 'content_type' => wp_remote_retrieve_header($response, 'content-type'), ]; } private function request(string $method, string $path, array $query = [], array $body = null, int $retry = 0): ?array { $token = $this->tokens->getAccessToken(); if (empty($token)) { QBWS_Logger::error('API', 'No access token available — aborting request to ' . $path); return null; } $url = trailingslashit(QBWS_Config::base_url()) . ltrim($path, '/'); // Always pin a recent minorversion. Several Item/Category features // (including the Type="Category" hierarchy this plugin's category // sync relies on) only exist from a specific minor version onward — // omitting this param makes QB silently fall back to its oldest // supported API behavior, which is what was causing categories // that genuinely exist in the company file to come back empty. $query['minorversion'] = $query['minorversion'] ?? QBWS_Config::MINOR_VERSION; $url .= '?' . http_build_query($query); $args = [ 'method' => $method, 'timeout' => 25, 'headers' => [ 'Authorization' => 'Bearer ' . $token, 'Accept' => 'application/json', 'Content-Type' => 'application/json', ], ]; if ($body !== null) { $args['body'] = wp_json_encode($body); } $response = wp_remote_request($url, $args); if (is_wp_error($response)) { QBWS_Logger::error('API', "Transport error on {$path}: " . $response->get_error_message()); return null; } $code = wp_remote_retrieve_response_code($response); // 401 → token might have just expired despite our early-refresh // margin (clock skew, concurrent request). Force one refresh+retry. if ($code === 401 && $retry === 0) { QBWS_Logger::warn('API', "401 from QB on {$path}, forcing token refresh and retrying once"); delete_option(QBWS_Token_Manager::OPT_EXPIRES_AT); return $this->request($method, $path, $query, $body, $retry + 1); } // 429 / 503 → QB rate limit or transient outage. Backoff and retry // up to 3 times; caller's queue mechanism handles anything beyond. if (in_array($code, [429, 503], true) && $retry < 3) { $wait = (int) (wp_remote_retrieve_header($response, 'retry-after') ?: (2 ** $retry)); QBWS_Logger::warn('API', "HTTP {$code} from QB on {$path}, backing off {$wait}s (retry {$retry})"); sleep(min($wait, 10)); return $this->request($method, $path, $query, $body, $retry + 1); } $decoded = json_decode(wp_remote_retrieve_body($response), true); if ($code < 200 || $code >= 300) { QBWS_Logger::error('API', "HTTP {$code} on {$method} {$path}", ['response' => $decoded]); return null; } return $decoded ?: []; } }