- 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>
397 lines
11 KiB
PHP
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(),
|
|
);
|
|
}
|
|
}
|
|
}
|