Features: - Fetch competitor benchmark prices from Google Price Competitiveness Report - New get_all_benchmark_prices() method in Google API class - Display competitor price instead of own Google price - Calculate recommended price (slightly below competitor) - Show potential gain/loss per product if price is optimized - Color-coded status: - Green: Your price is cheaper (opportunity to increase) - Blue: Competitive (within 2% of competitor) - Red: Expensive (above competitor) - Summary statistics showing: - Products with benchmark data - Count by status (cheaper/competitive/expensive) - Total potential gain if all prices optimized Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
597 lines
17 KiB
PHP
597 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.
|
|
// Price is nested inside attributes in the new Merchant API.
|
|
if ( isset( $product['attributes']['price']['amountMicros'] ) ) {
|
|
$price = (float) $product['attributes']['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 (in case API changes).
|
|
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 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 array (for stores where SKU is the barcode).
|
|
// GTIN is stored in attributes.gtin as an array.
|
|
if ( ! empty( $product['attributes']['gtin'] ) && is_array( $product['attributes']['gtin'] ) ) {
|
|
if ( in_array( $sku, $product['attributes']['gtin'], true ) ) {
|
|
$this->logger->info( "Product matched by attributes.gtin={$sku} (SKU used as barcode)" );
|
|
return $product;
|
|
}
|
|
}
|
|
|
|
// Also check attributes.gtins (alternative field).
|
|
if ( ! empty( $product['attributes']['gtins'] ) && is_array( $product['attributes']['gtins'] ) ) {
|
|
if ( in_array( $sku, $product['attributes']['gtins'], true ) ) {
|
|
$this->logger->info( "Product matched by attributes.gtins={$sku} (SKU used as barcode)" );
|
|
return $product;
|
|
}
|
|
}
|
|
|
|
// Check if separate GTIN parameter matches.
|
|
if ( ! empty( $gtin ) ) {
|
|
if ( ! empty( $product['attributes']['gtin'] ) && is_array( $product['attributes']['gtin'] ) ) {
|
|
if ( in_array( $gtin, $product['attributes']['gtin'], 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 {
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all benchmark prices from Price Competitiveness Report.
|
|
*
|
|
* @return array Associative array of offer_id => benchmark_price.
|
|
*/
|
|
public function get_all_benchmark_prices() {
|
|
try {
|
|
$endpoint = "/reports/v1beta/accounts/{$this->merchant_id}/reports:search";
|
|
|
|
$all_benchmarks = array();
|
|
$page_token = null;
|
|
|
|
do {
|
|
$query = array(
|
|
'query' => "SELECT
|
|
offer_id,
|
|
product_view.gtin,
|
|
price_benchmark.price_benchmark_value,
|
|
price_benchmark.price_benchmark_currency_code,
|
|
product_view.price,
|
|
product_view.title
|
|
FROM PriceCompetitivenessProductView",
|
|
);
|
|
|
|
if ( $page_token ) {
|
|
$query['pageToken'] = $page_token;
|
|
}
|
|
|
|
$response = $this->api_request( 'POST', $endpoint, $query );
|
|
|
|
if ( ! empty( $response['results'] ) ) {
|
|
foreach ( $response['results'] as $result ) {
|
|
$offer_id = $result['offerId'] ?? null;
|
|
$gtin = $result['productView']['gtin'] ?? null;
|
|
|
|
$benchmark_price = null;
|
|
if ( isset( $result['priceBenchmark']['priceBenchmarkValue']['amountMicros'] ) ) {
|
|
$benchmark_price = (float) $result['priceBenchmark']['priceBenchmarkValue']['amountMicros'] / 1000000;
|
|
}
|
|
|
|
$own_price = null;
|
|
if ( isset( $result['productView']['price']['amountMicros'] ) ) {
|
|
$own_price = (float) $result['productView']['price']['amountMicros'] / 1000000;
|
|
}
|
|
|
|
$title = $result['productView']['title'] ?? '';
|
|
|
|
if ( $offer_id && $benchmark_price ) {
|
|
$all_benchmarks[ 'offer_' . $offer_id ] = array(
|
|
'benchmark_price' => $benchmark_price,
|
|
'own_price' => $own_price,
|
|
'title' => $title,
|
|
'gtin' => $gtin,
|
|
);
|
|
|
|
// Also index by GTIN if available.
|
|
if ( $gtin ) {
|
|
$all_benchmarks[ 'gtin_' . $gtin ] = array(
|
|
'benchmark_price' => $benchmark_price,
|
|
'own_price' => $own_price,
|
|
'title' => $title,
|
|
'offer_id' => $offer_id,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$page_token = $response['nextPageToken'] ?? null;
|
|
|
|
} while ( $page_token );
|
|
|
|
$this->logger->info( sprintf( 'Fetched benchmark prices for %d products', count( $all_benchmarks ) ) );
|
|
|
|
return $all_benchmarks;
|
|
|
|
} catch ( Exception $e ) {
|
|
$this->logger->error( 'Error fetching benchmark prices: ' . $e->getMessage() );
|
|
return array();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
}
|
|
}
|