Files
IQ-Dynamic-Google-Pricing/includes/class-informatiq-sp-google-api.php
Malin bce0ccc0d4 fix: Improve product matching to support SKU as barcode/GTIN
- Match products where WooCommerce SKU is stored as GTIN in Google
- Also search Reports API by GTIN when SKU looks like a barcode
- Supports stores that use barcodes (UPC/EAN) as their SKU

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 09:03:24 +01:00

492 lines
13 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.
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();
}
}
}