Major rewrite using correct Google Merchant API: - Use price_insights_product_view table (correct API endpoint) - Fetch suggested_price and predicted performance changes - Show predicted impact on impressions, clicks, conversions New features: - Individual "Update" button per product - Bulk update with checkbox selection - Pagination (50 products per page) - Sort by potential gain (highest first) Price handling: - Always use tax-inclusive prices for comparison with Google - Convert back to store format when saving (handles tax-exclusive stores) - Set as sale price when updating UI improvements: - Color-coded gain/loss values - Color-coded predicted changes - Summary stats showing products that can increase/decrease - Total potential gain calculation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
616 lines
18 KiB
PHP
616 lines
18 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 (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 {
|
|
$endpoint = "/reports/v1beta/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 {
|
|
// 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();
|
|
}
|
|
}
|
|
}
|