- Fix ISE 500 error on plugin activation: - Defer wc_get_logger() call in Logger class (lazy initialization) - Move plugin init to plugins_loaded hook with priority 20 - Remove invalid Google_Service_ShoppingContent_Reports instantiation - Migrate from deprecated Content API to new Merchant API: - Rewrite Google API class with direct REST calls - Implement JWT authentication using PHP OpenSSL - Use WordPress wp_remote_* functions for HTTP requests - Support Price Competitiveness reports for competitor pricing - Remove all Composer dependencies: - Delete vendor/ directory (~50 packages) - Delete composer.json and composer.lock - Remove setup scripts and related documentation - Plugin is now fully self-contained with no external dependencies Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
430 lines
11 KiB
PHP
430 lines
11 KiB
PHP
<?php
|
|
/**
|
|
* Google Merchant API integration using direct REST calls.
|
|
*
|
|
* This class implements authentication and API calls to the new Google Merchant API
|
|
* without requiring Composer or external dependencies.
|
|
*
|
|
* @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 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;
|
|
|
|
/**
|
|
* Token expiration time.
|
|
*
|
|
* @var int
|
|
*/
|
|
private $token_expires;
|
|
|
|
/**
|
|
* Merchant ID (account ID).
|
|
*
|
|
* @var string
|
|
*/
|
|
private $merchant_id;
|
|
|
|
/**
|
|
* Service account credentials.
|
|
*
|
|
* @var array
|
|
*/
|
|
private $credentials;
|
|
|
|
/**
|
|
* Logger instance.
|
|
*
|
|
* @var Informatiq_SP_Logger
|
|
*/
|
|
private $logger;
|
|
|
|
/**
|
|
* Constructor.
|
|
*
|
|
* @param string $merchant_id Google Merchant ID.
|
|
* @param string $service_account Service account JSON.
|
|
* @param Informatiq_SP_Logger $logger Logger instance.
|
|
* @throws Exception If authentication fails.
|
|
*/
|
|
public function __construct( $merchant_id, $service_account, $logger ) {
|
|
$this->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();
|
|
}
|
|
}
|
|
}
|