Files
IQ-Dynamic-Google-Pricing/includes/class-informatiq-sp-google-api.php
Malin d1f3607895 feat: Migrate to Google Merchant API and remove Composer dependencies
- 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>
2026-01-21 08:29:25 +01:00

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();
}
}
}