Files
IQ-Dynamic-Google-Pricing/includes/class-informatiq-sp-price-updater.php
Malin e313fce197 feat: Switch to OAuth 2.0 authentication for Google Merchant API
- Replace service account authentication with OAuth 2.0 user flow
- Add "Authorize with Google" button in admin settings
- Handle OAuth callback and token exchange
- Store refresh token for automatic access token renewal
- Add revoke authorization functionality
- Update admin UI to show authorization status
- Update price updater to use new OAuth credentials
- Add CSRF protection with state parameter

This change supports organizations that have disabled service account
key creation via iam.disableServiceAccountKeyCreation policy.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 08:52:57 +01:00

397 lines
11 KiB
PHP

<?php
/**
* Price updater with tax-aware logic and minimum margin safeguards.
*
* @package InformatiqSmartPricing
*/
// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Price updater class.
*/
class Informatiq_SP_Price_Updater {
/**
* Logger instance.
*
* @var Informatiq_SP_Logger
*/
private $logger;
/**
* Minimum offset from competitor price.
*
* @var float
*/
private $min_offset = 0.05;
/**
* Maximum offset from competitor price.
*
* @var float
*/
private $max_offset = 0.20;
/**
* Constructor.
*
* @param Informatiq_SP_Logger $logger Logger instance.
*/
public function __construct( $logger ) {
$this->logger = $logger;
}
/**
* Process all in-stock products.
*
* @return array Results summary.
*/
public function process_all_products() {
$results = array(
'processed' => 0,
'updated' => 0,
'skipped' => 0,
'errors' => 0,
);
// Get plugin settings.
$merchant_id = get_option( 'informatiq_sp_merchant_id' );
$client_id = get_option( 'informatiq_sp_client_id' );
$client_secret = get_option( 'informatiq_sp_client_secret' );
$refresh_token = get_option( 'informatiq_sp_refresh_token' );
$minimum_margin = (float) get_option( 'informatiq_sp_minimum_margin', 10 );
// Validate settings.
if ( empty( $merchant_id ) || empty( $refresh_token ) ) {
$this->logger->error( 'Google Merchant settings not configured. Please configure and authorize first.' );
return $results;
}
// Initialize Google API.
try {
$google_api = new Informatiq_SP_Google_API( $merchant_id, $client_id, $client_secret, $refresh_token, $this->logger );
} catch ( Exception $e ) {
$this->logger->error( 'Failed to initialize Google API: ' . $e->getMessage() );
return $results;
}
// Get all in-stock products.
$products = $this->get_instock_products();
$this->logger->info( sprintf( 'Starting price update for %d in-stock products', count( $products ) ) );
foreach ( $products as $product ) {
$results['processed']++;
try {
$updated = $this->process_single_product( $product, $google_api, $minimum_margin );
if ( $updated ) {
$results['updated']++;
} else {
$results['skipped']++;
}
} catch ( Exception $e ) {
$results['errors']++;
$this->logger->error(
sprintf(
'Error processing product #%d: %s',
$product->get_id(),
$e->getMessage()
)
);
}
}
$this->logger->info(
sprintf(
'Price update completed. Processed: %d, Updated: %d, Skipped: %d, Errors: %d',
$results['processed'],
$results['updated'],
$results['skipped'],
$results['errors']
)
);
return $results;
}
/**
* Get all in-stock products.
*
* @return array Array of WC_Product objects.
*/
private function get_instock_products() {
$args = array(
'status' => 'publish',
'stock_status' => 'instock',
'limit' => -1,
'return' => 'objects',
'type' => array( 'simple', 'variable' ),
);
return wc_get_products( $args );
}
/**
* Process a single product.
*
* @param WC_Product $product Product object.
* @param Informatiq_SP_Google_API $google_api Google API instance.
* @param float $minimum_margin Minimum margin percentage.
* @return bool True if price was updated.
*/
private function process_single_product( $product, $google_api, $minimum_margin ) {
// Get product identifiers.
$sku = $product->get_sku();
$gtin = get_post_meta( $product->get_id(), '_gtin', true );
if ( empty( $sku ) ) {
$this->logger->warning( sprintf( 'Product #%d has no SKU, skipping', $product->get_id() ) );
return false;
}
// Get competitor price from Google.
$competitor_price = $google_api->get_competitive_price( $sku, $gtin );
if ( is_null( $competitor_price ) || $competitor_price <= 0 ) {
$this->logger->info( sprintf( 'No competitor price found for product #%d (SKU: %s)', $product->get_id(), $sku ) );
return false;
}
// Calculate new price.
$new_price = $this->calculate_new_price( $product, $competitor_price, $minimum_margin );
if ( is_null( $new_price ) ) {
return false;
}
// Update product price.
return $this->update_product_price( $product, $new_price, $competitor_price );
}
/**
* Calculate new price based on competitor price and minimum margin.
*
* @param WC_Product $product Product object.
* @param float $competitor_price Competitor price (tax-inclusive).
* @param float $minimum_margin Minimum margin percentage.
* @return float|null New price or null if cannot be calculated.
*/
private function calculate_new_price( $product, $competitor_price, $minimum_margin ) {
// Generate random offset.
$random_offset = $this->get_random_offset();
// Calculate target price (tax-inclusive).
$target_price_with_tax = $competitor_price - $random_offset;
// Get minimum allowed price based on cost and margin.
$minimum_price = $this->get_minimum_price( $product, $minimum_margin );
// Determine if we need to handle taxes.
$prices_include_tax = wc_prices_include_tax();
if ( $prices_include_tax ) {
// Store prices include tax, so we can set the sale price directly.
$new_price = $target_price_with_tax;
// Check against minimum price.
if ( $new_price < $minimum_price ) {
$this->logger->warning(
sprintf(
'Product #%d: Calculated price %s is below minimum margin %s, using minimum price',
$product->get_id(),
wc_price( $new_price ),
wc_price( $minimum_price )
)
);
$new_price = $minimum_price;
}
} else {
// Store prices exclude tax, need to reverse-calculate base price.
$new_price = $this->calculate_price_excluding_tax( $product, $target_price_with_tax );
// Check against minimum price (also excluding tax).
$minimum_price_excl_tax = $this->calculate_price_excluding_tax( $product, $minimum_price );
if ( $new_price < $minimum_price_excl_tax ) {
$this->logger->warning(
sprintf(
'Product #%d: Calculated price %s is below minimum margin %s, using minimum price',
$product->get_id(),
wc_price( $new_price ),
wc_price( $minimum_price_excl_tax )
)
);
$new_price = $minimum_price_excl_tax;
}
}
return $new_price;
}
/**
* Calculate price excluding tax from tax-inclusive price.
*
* @param WC_Product $product Product object.
* @param float $price_with_tax Price including tax.
* @return float Price excluding tax.
*/
private function calculate_price_excluding_tax( $product, $price_with_tax ) {
// Get tax rates for the product.
$tax_rates = WC_Tax::get_rates( $product->get_tax_class() );
if ( empty( $tax_rates ) ) {
// No tax, return as-is.
return $price_with_tax;
}
// Calculate total tax rate.
$tax_rate = 0;
foreach ( $tax_rates as $rate ) {
$tax_rate += (float) $rate['rate'];
}
// Calculate price excluding tax.
$price_excl_tax = $price_with_tax / ( 1 + ( $tax_rate / 100 ) );
return round( $price_excl_tax, wc_get_price_decimals() );
}
/**
* Get minimum allowed price based on cost and margin.
*
* @param WC_Product $product Product object.
* @param float $minimum_margin Minimum margin percentage.
* @return float Minimum price.
*/
private function get_minimum_price( $product, $minimum_margin ) {
// Get product cost from WooCommerce Cost of Goods.
$cost = get_post_meta( $product->get_id(), '_wc_cog_cost', true );
// Fallback to regular price if cost is not set.
if ( empty( $cost ) || $cost <= 0 ) {
$cost = $product->get_regular_price();
$this->logger->info(
sprintf(
'Product #%d has no cost set, using regular price %s as cost',
$product->get_id(),
wc_price( $cost )
)
);
}
// Calculate minimum price with margin.
$minimum_price = $cost * ( 1 + ( $minimum_margin / 100 ) );
return $minimum_price;
}
/**
* Get random offset between min and max.
*
* @return float Random offset.
*/
private function get_random_offset() {
return $this->min_offset + ( mt_rand() / mt_getrandmax() ) * ( $this->max_offset - $this->min_offset );
}
/**
* Update product price.
*
* @param WC_Product $product Product object.
* @param float $new_price New sale price.
* @param float $competitor_price Competitor price.
* @return bool True if updated successfully.
*/
private function update_product_price( $product, $new_price, $competitor_price ) {
$old_price = $product->get_sale_price();
// Only update if price has changed.
if ( (float) $old_price === (float) $new_price ) {
$this->logger->info(
sprintf(
'Product #%d price unchanged at %s',
$product->get_id(),
wc_price( $new_price )
)
);
return false;
}
// Set sale price.
$product->set_sale_price( $new_price );
$product->save();
// Log price update.
$this->logger->log_price_update(
$product->get_id(),
$product->get_name(),
$old_price ? $old_price : $product->get_regular_price(),
$new_price,
$competitor_price,
'Price updated via automated sync'
);
return true;
}
/**
* Process single product by ID (for manual sync).
*
* @param int $product_id Product ID.
* @return array Result with success/error message.
*/
public function process_product_by_id( $product_id ) {
$product = wc_get_product( $product_id );
if ( ! $product ) {
return array(
'success' => false,
'message' => 'Product not found',
);
}
if ( $product->get_stock_status() !== 'instock' ) {
return array(
'success' => false,
'message' => 'Product is not in stock',
);
}
// Get settings.
$merchant_id = get_option( 'informatiq_sp_merchant_id' );
$client_id = get_option( 'informatiq_sp_client_id' );
$client_secret = get_option( 'informatiq_sp_client_secret' );
$refresh_token = get_option( 'informatiq_sp_refresh_token' );
$minimum_margin = (float) get_option( 'informatiq_sp_minimum_margin', 10 );
if ( empty( $merchant_id ) || empty( $refresh_token ) ) {
return array(
'success' => false,
'message' => 'Google Merchant settings not configured or not authorized',
);
}
try {
$google_api = new Informatiq_SP_Google_API( $merchant_id, $client_id, $client_secret, $refresh_token, $this->logger );
$updated = $this->process_single_product( $product, $google_api, $minimum_margin );
return array(
'success' => true,
'updated' => $updated,
'message' => $updated ? 'Price updated successfully' : 'No update needed',
);
} catch ( Exception $e ) {
return array(
'success' => false,
'message' => 'Error: ' . $e->getMessage(),
);
}
}
}