merchant_id = $merchant_id; $this->client_id = $client_id; $this->client_secret = $client_secret; $this->refresh_token = $refresh_token; $this->logger = $logger; if ( empty( $client_id ) || empty( $client_secret ) ) { throw new Exception( 'OAuth Client ID and Client Secret are required' ); } if ( empty( $refresh_token ) ) { throw new Exception( 'Not authorized. Please authorize with Google first.' ); } } /** * Get the authorization URL for OAuth flow. * * @param string $client_id OAuth Client ID. * @param string $redirect_uri Redirect URI after authorization. * @param string $state State parameter for CSRF protection. * @return string Authorization URL. */ public static function get_authorization_url( $client_id, $redirect_uri, $state ) { $params = array( 'client_id' => $client_id, 'redirect_uri' => $redirect_uri, 'response_type' => 'code', 'scope' => self::API_SCOPE, 'access_type' => 'offline', 'prompt' => 'consent', 'state' => $state, ); return self::AUTH_URL . '?' . http_build_query( $params ); } /** * Exchange authorization code for tokens. * * @param string $code Authorization code from Google. * @param string $client_id OAuth Client ID. * @param string $client_secret OAuth Client Secret. * @param string $redirect_uri Redirect URI used in authorization. * @return array Tokens array with access_token and refresh_token. * @throws Exception If token exchange fails. */ public static function exchange_code_for_tokens( $code, $client_id, $client_secret, $redirect_uri ) { $response = wp_remote_post( self::TOKEN_URL, array( 'body' => array( 'code' => $code, 'client_id' => $client_id, 'client_secret' => $client_secret, 'redirect_uri' => $redirect_uri, 'grant_type' => 'authorization_code', ), 'timeout' => 30, ) ); if ( is_wp_error( $response ) ) { throw new Exception( 'Token exchange failed: ' . $response->get_error_message() ); } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( isset( $body['error'] ) ) { throw new Exception( 'Token error: ' . ( $body['error_description'] ?? $body['error'] ) ); } if ( empty( $body['access_token'] ) || empty( $body['refresh_token'] ) ) { throw new Exception( 'Invalid token response from Google' ); } return array( 'access_token' => $body['access_token'], 'refresh_token' => $body['refresh_token'], 'expires_in' => $body['expires_in'] ?? 3600, ); } /** * Get access token, refreshing if necessary. * * @return string Access token. * @throws Exception If token retrieval fails. */ private function get_access_token() { // Return cached token if available. if ( $this->access_token ) { return $this->access_token; } // Refresh the access token. $response = wp_remote_post( self::TOKEN_URL, array( 'body' => array( 'client_id' => $this->client_id, 'client_secret' => $this->client_secret, 'refresh_token' => $this->refresh_token, 'grant_type' => 'refresh_token', ), 'timeout' => 30, ) ); if ( is_wp_error( $response ) ) { throw new Exception( 'Token refresh failed: ' . $response->get_error_message() ); } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( isset( $body['error'] ) ) { // If refresh token is invalid, user needs to re-authorize. if ( $body['error'] === 'invalid_grant' ) { // Clear the stored refresh token. delete_option( 'informatiq_sp_refresh_token' ); throw new Exception( 'Authorization expired. Please re-authorize with Google.' ); } throw new Exception( 'Token error: ' . ( $body['error_description'] ?? $body['error'] ) ); } if ( empty( $body['access_token'] ) ) { throw new Exception( 'No access token in response' ); } $this->access_token = $body['access_token']; return $this->access_token; } /** * Make an API request to the Merchant API. * * @param string $method HTTP method (GET, POST, etc.). * @param string $endpoint API endpoint (relative to base URL). * @param array $data Request data for POST/PUT requests. * @return array Response data. * @throws Exception If request fails. */ private function api_request( $method, $endpoint, $data = array() ) { $token = $this->get_access_token(); $args = array( 'method' => $method, 'headers' => array( 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ), 'timeout' => 60, ); if ( ! empty( $data ) && in_array( $method, array( 'POST', 'PUT', 'PATCH' ), true ) ) { $args['body'] = wp_json_encode( $data ); } $url = self::API_BASE_URL . $endpoint; $response = wp_remote_request( $url, $args ); if ( is_wp_error( $response ) ) { throw new Exception( 'API request failed: ' . $response->get_error_message() ); } $status_code = wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $status_code >= 400 ) { $error_message = isset( $body['error']['message'] ) ? $body['error']['message'] : 'Unknown error'; throw new Exception( "API error ($status_code): $error_message" ); } return $body ?? array(); } /** * Get competitive pricing for a product. * * @param string $sku Product SKU. * @param string $gtin Product GTIN (optional). * @return float|null Lowest competitor price or null if not found. */ public function get_competitive_price( $sku, $gtin = '' ) { try { // Search for the product using SKU or GTIN. $product = $this->find_product_by_identifier( $sku, $gtin ); if ( ! $product ) { $this->logger->warning( "Product not found in Google Merchant Center: SKU={$sku}, GTIN={$gtin}" ); return null; } // Try to get competitive pricing data from reports. $competitive_price = $this->fetch_competitive_price_from_reports( $sku, $gtin ); if ( $competitive_price ) { return $competitive_price; } // Fallback: Use product's own price as reference. if ( isset( $product['price']['amountMicros'] ) ) { $price = (float) $product['price']['amountMicros'] / 1000000; $this->logger->warning( "No competitive data available for SKU={$sku}, using own price as reference" ); return $price; } return null; } catch ( Exception $e ) { $this->logger->error( 'Error fetching competitive price: ' . $e->getMessage() ); return null; } } /** * Find product by SKU or GTIN. * * Matching logic: * 1. SKU matches offerId * 2. SKU matches gtin (for stores where SKU is the barcode) * 3. GTIN matches gtin * * @param string $sku Product SKU. * @param string $gtin Product GTIN. * @return array|null Product data or null if not found. */ private function find_product_by_identifier( $sku, $gtin ) { try { $page_token = null; do { $endpoint = "/products/v1beta/accounts/{$this->merchant_id}/products"; $endpoint .= '?pageSize=250'; if ( $page_token ) { $endpoint .= '&pageToken=' . urlencode( $page_token ); } $response = $this->api_request( 'GET', $endpoint ); if ( ! empty( $response['products'] ) ) { foreach ( $response['products'] as $product ) { // Check if offerId matches SKU. if ( isset( $product['offerId'] ) && $product['offerId'] === $sku ) { return $product; } // Check if SKU matches Google's GTIN (for stores where SKU is the barcode). if ( isset( $product['gtin'] ) && $product['gtin'] === $sku ) { $this->logger->info( "Product matched by GTIN={$sku} (SKU used as barcode)" ); return $product; } // Check if separate GTIN field matches. if ( ! empty( $gtin ) && isset( $product['gtin'] ) && $product['gtin'] === $gtin ) { return $product; } } } $page_token = $response['nextPageToken'] ?? null; } while ( $page_token ); return null; } catch ( Exception $e ) { $this->logger->error( 'Error finding product: ' . $e->getMessage() ); return null; } } /** * Fetch competitive pricing data from Reports API. * * @param string $sku Product SKU. * @param string $gtin Product GTIN. * @return float|null Lowest competitor price or null. */ private function fetch_competitive_price_from_reports( $sku, $gtin ) { try { // Use the Reports API to search for competitive visibility data. $endpoint = "/reports/v1beta/accounts/{$this->merchant_id}/reports:search"; // Try searching by offer_id first. $query = array( 'query' => "SELECT offer_id, price_benchmark.price_benchmark_value, price_benchmark.price_benchmark_currency_code FROM PriceCompetitivenessProductView WHERE offer_id = '{$sku}'", ); $response = $this->api_request( 'POST', $endpoint, $query ); if ( ! empty( $response['results'] ) ) { foreach ( $response['results'] as $result ) { if ( isset( $result['priceBenchmark']['priceBenchmarkValue']['amountMicros'] ) ) { return (float) $result['priceBenchmark']['priceBenchmarkValue']['amountMicros'] / 1000000; } } } // If SKU looks like a GTIN (numeric, 8-14 digits), also try searching by gtin. if ( preg_match( '/^\d{8,14}$/', $sku ) ) { $query = array( 'query' => "SELECT gtin, price_benchmark.price_benchmark_value, price_benchmark.price_benchmark_currency_code FROM PriceCompetitivenessProductView WHERE gtin = '{$sku}'", ); $response = $this->api_request( 'POST', $endpoint, $query ); if ( ! empty( $response['results'] ) ) { foreach ( $response['results'] as $result ) { if ( isset( $result['priceBenchmark']['priceBenchmarkValue']['amountMicros'] ) ) { return (float) $result['priceBenchmark']['priceBenchmarkValue']['amountMicros'] / 1000000; } } } } return null; } catch ( Exception $e ) { // Reports API might not be available for all accounts. $this->logger->info( 'Competitive pricing data not available: ' . $e->getMessage() ); return null; } } /** * Test API connection. * * @return bool True if connection successful. */ public function test_connection() { try { // Try to list products to test connection. $endpoint = "/products/v1beta/accounts/{$this->merchant_id}/products?pageSize=1"; $this->api_request( 'GET', $endpoint ); $this->logger->info( 'Google Merchant API connection test successful' ); return true; } catch ( Exception $e ) { $this->logger->error( 'Google Merchant API connection test failed: ' . $e->getMessage() ); return false; } } /** * Get all products from Merchant Center. * * @return array Array of products. */ public function get_all_products() { try { $all_products = array(); $page_token = null; do { $endpoint = "/products/v1beta/accounts/{$this->merchant_id}/products"; $endpoint .= '?pageSize=250'; if ( $page_token ) { $endpoint .= '&pageToken=' . urlencode( $page_token ); } $response = $this->api_request( 'GET', $endpoint ); $products = $response['products'] ?? array(); $all_products = array_merge( $all_products, $products ); $page_token = $response['nextPageToken'] ?? null; } while ( $page_token ); return $all_products; } catch ( Exception $e ) { $this->logger->error( 'Error fetching products: ' . $e->getMessage() ); return array(); } } }