merchant_id = $merchant_id; $this->logger = $logger; // Parse service account JSON. $this->credentials = json_decode( $service_account, true ); if ( json_last_error() !== JSON_ERROR_NONE ) { throw new Exception( 'Invalid service account JSON: ' . json_last_error_msg() ); } if ( empty( $this->credentials['client_email'] ) || empty( $this->credentials['private_key'] ) ) { throw new Exception( 'Service account JSON must contain client_email and private_key' ); } } /** * 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 still valid (with 60 second buffer). if ( $this->access_token && $this->token_expires > ( time() + 60 ) ) { return $this->access_token; } // Create JWT for token exchange. $jwt = $this->create_jwt(); // Exchange JWT for access token. $response = wp_remote_post( self::TOKEN_URL, array( 'body' => array( 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $jwt, ), 'timeout' => 30, ) ); if ( is_wp_error( $response ) ) { throw new Exception( 'Token request 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'] ) ) { throw new Exception( 'No access token in response' ); } $this->access_token = $body['access_token']; $this->token_expires = time() + ( $body['expires_in'] ?? 3600 ); return $this->access_token; } /** * Create a signed JWT for authentication. * * @return string Signed JWT. * @throws Exception If JWT creation fails. */ private function create_jwt() { $now = time(); // JWT Header. $header = array( 'alg' => 'RS256', 'typ' => 'JWT', ); // JWT Claims. $claims = array( 'iss' => $this->credentials['client_email'], 'scope' => self::API_SCOPE, 'aud' => self::TOKEN_URL, 'iat' => $now, 'exp' => $now + 3600, ); // Encode header and claims. $header_encoded = $this->base64url_encode( wp_json_encode( $header ) ); $claims_encoded = $this->base64url_encode( wp_json_encode( $claims ) ); // Create signature input. $signature_input = $header_encoded . '.' . $claims_encoded; // Sign with private key. $private_key = openssl_pkey_get_private( $this->credentials['private_key'] ); if ( ! $private_key ) { throw new Exception( 'Invalid private key in service account' ); } $signature = ''; $success = openssl_sign( $signature_input, $signature, $private_key, OPENSSL_ALGO_SHA256 ); if ( ! $success ) { throw new Exception( 'Failed to sign JWT' ); } // Return complete JWT. return $signature_input . '.' . $this->base64url_encode( $signature ); } /** * Base64url encode (URL-safe base64). * * @param string $data Data to encode. * @return string Encoded data. */ private function base64url_encode( $data ) { return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' ); } /** * 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. * * @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 (SKU) matches. if ( isset( $product['offerId'] ) && $product['offerId'] === $sku ) { return $product; } // Check if GTIN 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"; // Query for price competitiveness report. $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; } } } 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(); } } }