The v1beta version was discontinued on February 28, 2026, causing all API calls to return HTTP 409 errors. Updated all three endpoint paths (products list, test connection, reports search) to use v1. Also updated product attribute access to match v1 schema changes: - Product.attributes → Product.productAttributes - attributes.gtin / attributes.gtins → productAttributes.gtins Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
610 lines
17 KiB
PHP
610 lines
17 KiB
PHP
<?php
|
|
/**
|
|
* Google Merchant API integration using OAuth 2.0 authentication.
|
|
*
|
|
* This class implements authentication and API calls to the Google Merchant API
|
|
* using OAuth 2.0 user authentication flow.
|
|
*
|
|
* @package InformatiqSmartPricing
|
|
*/
|
|
|
|
// Exit if accessed directly.
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Google Merchant API class.
|
|
*/
|
|
class Informatiq_SP_Google_API {
|
|
/**
|
|
* Merchant API base URL.
|
|
*
|
|
* @var string
|
|
*/
|
|
const API_BASE_URL = 'https://merchantapi.googleapis.com';
|
|
|
|
/**
|
|
* OAuth authorization endpoint.
|
|
*
|
|
* @var string
|
|
*/
|
|
const AUTH_URL = 'https://accounts.google.com/o/oauth2/auth';
|
|
|
|
/**
|
|
* OAuth token endpoint.
|
|
*
|
|
* @var string
|
|
*/
|
|
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
|
|
/**
|
|
* API scope for Merchant Center.
|
|
*
|
|
* @var string
|
|
*/
|
|
const API_SCOPE = 'https://www.googleapis.com/auth/content';
|
|
|
|
/**
|
|
* Access token.
|
|
*
|
|
* @var string
|
|
*/
|
|
private $access_token;
|
|
|
|
/**
|
|
* Merchant ID (account ID).
|
|
*
|
|
* @var string
|
|
*/
|
|
private $merchant_id;
|
|
|
|
/**
|
|
* OAuth Client ID.
|
|
*
|
|
* @var string
|
|
*/
|
|
private $client_id;
|
|
|
|
/**
|
|
* OAuth Client Secret.
|
|
*
|
|
* @var string
|
|
*/
|
|
private $client_secret;
|
|
|
|
/**
|
|
* Refresh token.
|
|
*
|
|
* @var string
|
|
*/
|
|
private $refresh_token;
|
|
|
|
/**
|
|
* Logger instance.
|
|
*
|
|
* @var Informatiq_SP_Logger
|
|
*/
|
|
private $logger;
|
|
|
|
/**
|
|
* Constructor.
|
|
*
|
|
* @param string $merchant_id Google Merchant ID.
|
|
* @param string $client_id OAuth Client ID.
|
|
* @param string $client_secret OAuth Client Secret.
|
|
* @param string $refresh_token OAuth Refresh Token.
|
|
* @param Informatiq_SP_Logger $logger Logger instance.
|
|
* @throws Exception If initialization fails.
|
|
*/
|
|
public function __construct( $merchant_id, $client_id, $client_secret, $refresh_token, $logger ) {
|
|
$this->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.
|
|
// In v1, price is nested inside productAttributes.
|
|
if ( isset( $product['productAttributes']['price']['amountMicros'] ) ) {
|
|
$price = (float) $product['productAttributes']['price']['amountMicros'] / 1000000;
|
|
$this->logger->warning( "No competitive data available for SKU={$sku}, using own price as reference" );
|
|
return $price;
|
|
}
|
|
// Also check top-level price as fallback.
|
|
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 (top-level)" );
|
|
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 gtins (for stores where SKU is the barcode)
|
|
* 3. GTIN matches gtins
|
|
*
|
|
* @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 {
|
|
// v1 endpoint (v1beta was discontinued on February 28, 2026).
|
|
$endpoint = "/products/v1/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;
|
|
}
|
|
|
|
// In v1, GTIN is stored in productAttributes.gtins as an array.
|
|
if ( ! empty( $product['productAttributes']['gtins'] ) && is_array( $product['productAttributes']['gtins'] ) ) {
|
|
if ( in_array( $sku, $product['productAttributes']['gtins'], true ) ) {
|
|
$this->logger->info( "Product matched by productAttributes.gtins={$sku} (SKU used as barcode)" );
|
|
return $product;
|
|
}
|
|
}
|
|
|
|
// Check if separate GTIN parameter matches.
|
|
if ( ! empty( $gtin ) ) {
|
|
if ( ! empty( $product['productAttributes']['gtins'] ) && is_array( $product['productAttributes']['gtins'] ) ) {
|
|
if ( in_array( $gtin, $product['productAttributes']['gtins'], true ) ) {
|
|
$this->logger->info( "Product matched by GTIN parameter={$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 {
|
|
// v1 reports endpoint (v1beta was discontinued on February 28, 2026).
|
|
$endpoint = "/reports/v1/accounts/{$this->merchant_id}/reports:search";
|
|
|
|
// Try searching by offer_id first (fields prefixed with product_view.).
|
|
$query = array(
|
|
'query' => "SELECT
|
|
product_view.offer_id,
|
|
benchmark_price
|
|
FROM price_competitiveness_product_view
|
|
WHERE product_view.offer_id = '{$sku}'",
|
|
);
|
|
|
|
$response = $this->api_request( 'POST', $endpoint, $query );
|
|
|
|
if ( ! empty( $response['results'] ) ) {
|
|
foreach ( $response['results'] as $result ) {
|
|
if ( isset( $result['benchmarkPrice']['amountMicros'] ) ) {
|
|
return (float) $result['benchmarkPrice']['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
|
|
product_view.gtin,
|
|
benchmark_price
|
|
FROM price_competitiveness_product_view
|
|
WHERE product_view.gtin = '{$sku}'",
|
|
);
|
|
|
|
$response = $this->api_request( 'POST', $endpoint, $query );
|
|
|
|
if ( ! empty( $response['results'] ) ) {
|
|
foreach ( $response['results'] as $result ) {
|
|
if ( isset( $result['benchmarkPrice']['amountMicros'] ) ) {
|
|
return (float) $result['benchmarkPrice']['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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get price insights from Google's price_insights_product_view.
|
|
*
|
|
* Returns suggested prices and predicted performance impact.
|
|
*
|
|
* @return array Associative array of offer_id => price insight data.
|
|
*/
|
|
public function get_price_insights() {
|
|
try {
|
|
// v1 reports endpoint (v1beta was discontinued on February 28, 2026).
|
|
$endpoint = "/reports/v1/accounts/{$this->merchant_id}/reports:search";
|
|
|
|
$all_insights = array();
|
|
$page_token = null;
|
|
|
|
do {
|
|
$query = array(
|
|
'query' => "SELECT
|
|
id,
|
|
offer_id,
|
|
title,
|
|
brand,
|
|
price,
|
|
suggested_price,
|
|
predicted_impressions_change_fraction,
|
|
predicted_clicks_change_fraction,
|
|
predicted_conversions_change_fraction
|
|
FROM price_insights_product_view",
|
|
);
|
|
|
|
if ( $page_token ) {
|
|
$query['pageToken'] = $page_token;
|
|
}
|
|
|
|
$response = $this->api_request( 'POST', $endpoint, $query );
|
|
|
|
// Log first result for debugging.
|
|
if ( empty( $all_insights ) && ! empty( $response['results'][0] ) ) {
|
|
$this->logger->info( 'Sample price insight: ' . wp_json_encode( $response['results'][0] ) );
|
|
}
|
|
|
|
if ( ! empty( $response['results'] ) ) {
|
|
foreach ( $response['results'] as $result ) {
|
|
$insight = $result['priceInsightsProductView'] ?? $result;
|
|
|
|
$offer_id = $insight['offerId'] ?? ( $insight['offer_id'] ?? null );
|
|
$product_id = $insight['id'] ?? null;
|
|
|
|
// Extract offer_id from product_id if needed (format: lang~country~offerId).
|
|
if ( ! $offer_id && $product_id ) {
|
|
$parts = explode( '~', $product_id );
|
|
if ( count( $parts ) >= 3 ) {
|
|
$offer_id = $parts[ count( $parts ) - 1 ];
|
|
}
|
|
}
|
|
|
|
$current_price = null;
|
|
if ( isset( $insight['price']['amountMicros'] ) ) {
|
|
$current_price = (float) $insight['price']['amountMicros'] / 1000000;
|
|
}
|
|
|
|
$suggested_price = null;
|
|
if ( isset( $insight['suggestedPrice']['amountMicros'] ) ) {
|
|
$suggested_price = (float) $insight['suggestedPrice']['amountMicros'] / 1000000;
|
|
}
|
|
|
|
if ( $offer_id ) {
|
|
$data = array(
|
|
'offer_id' => $offer_id,
|
|
'product_id' => $product_id,
|
|
'title' => $insight['title'] ?? '',
|
|
'brand' => $insight['brand'] ?? '',
|
|
'google_price' => $current_price,
|
|
'suggested_price' => $suggested_price,
|
|
'predicted_impressions_change' => isset( $insight['predictedImpressionsChangeFraction'] )
|
|
? (float) $insight['predictedImpressionsChangeFraction'] * 100
|
|
: null,
|
|
'predicted_clicks_change' => isset( $insight['predictedClicksChangeFraction'] )
|
|
? (float) $insight['predictedClicksChangeFraction'] * 100
|
|
: null,
|
|
'predicted_conversions_change' => isset( $insight['predictedConversionsChangeFraction'] )
|
|
? (float) $insight['predictedConversionsChangeFraction'] * 100
|
|
: null,
|
|
);
|
|
|
|
$all_insights[ 'offer_' . $offer_id ] = $data;
|
|
}
|
|
}
|
|
}
|
|
|
|
$page_token = $response['nextPageToken'] ?? null;
|
|
|
|
} while ( $page_token );
|
|
|
|
$this->logger->info( sprintf( 'Fetched price insights for %d products', count( $all_insights ) ) );
|
|
|
|
return $all_insights;
|
|
|
|
} catch ( Exception $e ) {
|
|
$this->logger->error( 'Error fetching price insights: ' . $e->getMessage() );
|
|
return array();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test API connection.
|
|
*
|
|
* @return bool True if connection successful.
|
|
*/
|
|
public function test_connection() {
|
|
try {
|
|
// v1 endpoint (v1beta was discontinued on February 28, 2026).
|
|
$endpoint = "/products/v1/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 {
|
|
// v1 endpoint (v1beta was discontinued on February 28, 2026).
|
|
$endpoint = "/products/v1/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();
|
|
}
|
|
}
|
|
}
|