Skip to content

Instantly share code, notes, and snippets.

@Auke1810
Last active August 26, 2025 10:57
Show Gist options
  • Select an option

  • Save Auke1810/7010469a9c795353a52b735988655555 to your computer and use it in GitHub Desktop.

Select an option

Save Auke1810/7010469a9c795353a52b735988655555 to your computer and use it in GitHub Desktop.
lightweight watchdog that: reads your channel/feed prices, compares them to the live WooCommerce prices, alerts when the delta exceeds a threshold, and stores every discrepancy for pattern analysis. Below is a minimal, production-ready WordPress plugin you can drop in today. It runs hourly via WP-Cron or on demand via WP-CLI, emails a compact su…
<?php
/**
* Plugin Name: WPML Feed Monitor
* Description: Monitor WooCommerce prices vs feed, alert on discrepancies, and log for analysis.
* Version: 0.1.0
* Author: AukeJomm, WPmarketingrobot
*/
if (!defined('ABSPATH')) exit;
/**
* Class WPML_Price_Monitor
*
* Monitors WooCommerce product prices against a reference feed and logs
* discrepancies above a configurable threshold. Sends summary alerts via email.
*
* @since 0.1.0
*/
class WPML_Price_Monitor {
/**
* Option key (reserved for future settings page).
*
* @var string
*/
const OPT_KEY = 'wpm_wcpmon_settings';
/**
* WP-Cron hook name.
*
* @var string
*/
const CRON_HOOK = 'wpm_wcpmon_cron_run';
/**
* Singleton instance.
*
* @var self|null
*/
private static $instance = null;
// --- SIMPLE CONFIG (move to settings page later) ---
/**
* Absolute URL of the source feed (CSV with header row).
*
* @var string
*/
private $FEED_URL = 'https://example.com/products.csv';
/**
* Column name in the feed representing product identifier (e.g., SKU or Woo ID).
*
* @var string
*/
private $FEED_ID_COLUMN = 'id';
/**
* Column name in the feed representing the product price.
*
* @var string
*/
private $FEED_PRICE_COLUMN = 'price';
/**
* How to match feed rows to Woo products: 'sku' or 'id' (Woo product ID).
*
* @var 'sku'|'id'
*/
private $MATCH_BY = 'sku';
/**
* Percentage threshold to trigger an alert: |wc - feed| / feed * 100.
*
* @var float
*/
private $THRESHOLD_PERCENT = 5.0;
/**
* Recipient of alert emails.
*
* @var string
*/
private $EMAIL_TO = 'alerts@example.com';
/**
* Currency code used for logging (no conversion performed in MVP).
*
* @var string
*/
private $CURRENCY = 'EUR';
/**
* Whether feed prices are gross (incl. VAT). Affects Woo tax normalization.
*
* @var bool
*/
private $ASSUME_FEED_IS_GROSS = true;
/**
* Constructor.
*
* Registers activation/deactivation hooks, cron schedule, and WP-CLI command.
*
* @since 0.1.0
*/
private function __construct() {
register_activation_hook(__FILE__, [$this, 'on_activate']);
register_deactivation_hook(__FILE__, [$this, 'on_deactivate']);
add_action(self::CRON_HOOK, [$this, 'run_monitor']);
add_filter('cron_schedules', [$this, 'add_cron_interval']);
if (defined('WP_CLI') && WP_CLI) {
\WP_CLI::add_command('price-monitor run', [$this, 'cli_run']);
}
}
/**
* Get singleton instance.
*
* @return self
*/
public static function instance() {
if (!self::$instance) self::$instance = new self();
return self::$instance;
}
/**
* Add custom cron intervals (e.g., every 5 minutes for debugging).
*
* @param array<string, array{interval:int, display:string}> $schedules
* @return array<string, array{interval:int, display:string}>
*/
public function add_cron_interval($schedules) {
$schedules['wpm_five_min'] = [
'interval' => 300,
'display' => __('Every 5 Minutes')
];
return $schedules;
}
/**
* Plugin activation hook.
*
* - Creates the log table if it does not exist.
* - Schedules the hourly cron job.
*
* @return void
*/
public function on_activate() {
global $wpdb;
$table = $this->table_name();
$charset = $wpdb->get_charset_collate();
$sql = "
CREATE TABLE IF NOT EXISTS `$table` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`ts` DATETIME NOT NULL,
`product_id` BIGINT UNSIGNED NULL,
`sku` VARCHAR(191) NULL,
`feed_id` VARCHAR(191) NULL,
`wc_price` DECIMAL(18,6) NULL,
`feed_price` DECIMAL(18,6) NULL,
`diff_abs` DECIMAL(18,6) NULL,
`diff_pct` DECIMAL(9,4) NULL,
`currency` VARCHAR(10) NULL,
`meta` LONGTEXT NULL,
PRIMARY KEY (`id`),
KEY `product_id` (`product_id`),
KEY `sku` (`sku`),
KEY `feed_id` (`feed_id`),
KEY `ts` (`ts`)
) $charset;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql);
if (!wp_next_scheduled(self::CRON_HOOK)) {
wp_schedule_event(time() + 60, 'hourly', self::CRON_HOOK);
}
}
/**
* Plugin deactivation hook.
*
* - Unschedules the cron job if present.
*
* @return void
*/
public function on_deactivate() {
$ts = wp_next_scheduled(self::CRON_HOOK);
if ($ts) wp_unschedule_event($ts, self::CRON_HOOK);
}
/**
* Get fully qualified name of the log table.
*
* @global \wpdb $wpdb
* @return string
*/
private function table_name() {
global $wpdb;
return $wpdb->prefix . 'wc_price_monitor_log';
}
/**
* WP-CLI entrypoint: runs the monitor and prints a summary.
*
* Usage: wp price-monitor run
*
* @return void
*/
public function cli_run() {
$result = $this->run_monitor(true);
if (defined('WP_CLI') && WP_CLI) {
\WP_CLI::success("Checked {$result['count_checked']} items, discrepancies: {$result['count_alerted']}");
}
}
/**
* Core monitor routine.
*
* - Fetches and parses the CSV feed.
* - Resolves Woo products by SKU or ID.
* - Compares normalized Woo price vs feed price.
* - Logs entries exceeding threshold.
* - Sends email summary when discrepancies are found.
*
* @param bool $from_cli Whether triggered by WP-CLI (for future behavior tweaks).
* @return array{count_checked:int, count_alerted:int} Counts for reporting.
*/
public function run_monitor($from_cli = false) {
$feed = $this->fetch_and_parse_csv($this->FEED_URL);
if (is_wp_error($feed)) {
$this->send_email("Price Monitor: feed error", $feed->get_error_message());
return ['count_checked' => 0, 'count_alerted' => 0];
}
$alerts = [];
$checked = 0;
foreach ($feed as $row) {
$feed_id = $row[$this->FEED_ID_COLUMN] ?? null;
$feed_price = isset($row[$this->FEED_PRICE_COLUMN]) ? floatval($row[$this->FEED_PRICE_COLUMN]) : null;
if (!$feed_id || $feed_price === null) continue;
$product = $this->resolve_product($feed_id);
if (!$product) { continue; }
$wc_price_raw = floatval($product->get_price()); // active price (sale or regular)
$wc_price = $this->normalize_wc_price($wc_price_raw, $product);
if ($feed_price <= 0) continue;
$diff_abs = abs($wc_price - $feed_price);
$diff_pct = ($diff_abs / $feed_price) * 100.0;
$checked++;
if ($diff_pct >= $this->THRESHOLD_PERCENT) {
$entry = [
'product_id' => $product->get_id(),
'sku' => $product->get_sku(),
'feed_id' => $feed_id,
'wc_price' => $wc_price,
'feed_price' => $feed_price,
'diff_abs' => $diff_abs,
'diff_pct' => $diff_pct,
'currency' => $this->CURRENCY,
'meta' => [
'name' => $product->get_name(),
'regular_price' => (float)$product->get_regular_price(),
'sale_price' => (float)$product->get_sale_price(),
'permalink' => get_permalink($product->get_id()),
]
];
$this->log_entry($entry);
$alerts[] = $entry;
}
}
if (!empty($alerts)) {
$this->email_summary($alerts, $checked);
}
return ['count_checked' => $checked, 'count_alerted' => count($alerts)];
}
/**
* Fetch and parse a CSV feed with a header row.
*
* @param string $url Absolute feed URL.
* @return array<int, array<string,string>>|\WP_Error Parsed rows keyed by header names, or WP_Error on failure.
*/
private function fetch_and_parse_csv($url) {
$resp = wp_remote_get($url, ['timeout' => 20]);
if (is_wp_error($resp)) return $resp;
$code = wp_remote_retrieve_response_code($resp);
if ($code < 200 || $code >= 300) return new WP_Error('bad_status', "Feed returned HTTP $code");
$body = wp_remote_retrieve_body($resp);
if (!$body) return new WP_Error('empty', 'Feed body empty');
// Simple CSV parser with header row
$lines = preg_split("/\r\n|\n|\r/", $body);
$rows = [];
$header = null;
foreach ($lines as $line) {
if ($line === '') continue;
$cols = str_getcsv($line);
if ($header === null) { $header = $cols; continue; }
$row = [];
foreach ($header as $i => $key) {
$row[$key] = $cols[$i] ?? '';
}
$rows[] = $row;
}
return $rows;
}
/**
* Resolve a WooCommerce product by feed identifier.
*
* @param string $feed_id SKU or Woo product ID (depending on $MATCH_BY).
* @return \WC_Product|null The resolved product or null if not found.
*/
private function resolve_product($feed_id) {
if ($this->MATCH_BY === 'id' && is_numeric($feed_id)) {
$p = wc_get_product(intval($feed_id));
return $p ? $p : null;
}
// Default: match by SKU (works for simple + variations)
$pid = wc_get_product_id_by_sku($feed_id);
if ($pid) return wc_get_product($pid);
return null;
}
/**
* Normalize Woo price for fair comparison with feed price.
*
* Uses Woo helpers to compute display price including or excluding tax,
* aligned with the feed's tax assumption.
*
* @param float $price Raw Woo price (from get_price()).
* @param \WC_Product $product Woo product.
* @return float Normalized price.
*/
private function normalize_wc_price($price, \WC_Product $product) {
// Align tax basis between feed and Woo (respect store tax settings via helper).
// If stricter control is required, compute via WC_Tax and product tax class.
$price = wc_get_price_to_display($product, ['price' => $price, 'qty' => 1]);
return floatval($price);
}
/**
* Insert a discrepancy record into the log table.
*
* @param array{
* product_id:int,
* sku:string|null,
* feed_id:string,
* wc_price:float,
* feed_price:float,
* diff_abs:float,
* diff_pct:float,
* currency:string,
* meta:array<string, mixed>
* } $e Entry payload.
* @return void
*/
private function log_entry($e) {
global $wpdb;
$table = $this->table_name();
$wpdb->insert($table, [
'ts' => current_time('mysql'),
'product_id' => $e['product_id'],
'sku' => $e['sku'],
'feed_id' => $e['feed_id'],
'wc_price' => $e['wc_price'],
'feed_price' => $e['feed_price'],
'diff_abs' => $e['diff_abs'],
'diff_pct' => $e['diff_pct'],
'currency' => $e['currency'],
'meta' => wp_json_encode($e['meta']),
], [
'%s','%d','%s','%s','%f','%f','%f','%f','%s','%s'
]);
}
/**
* Send a compact email summary of discrepancies.
*
* @param array<int, array{
* product_id:int,
* sku:string|null,
* feed_id:string,
* wc_price:float,
* feed_price:float,
* diff_abs:float,
* diff_pct:float,
* currency:string,
* meta:array{name:string,regular_price:float,sale_price:float,permalink:string}
* }> $alerts
* @param int $checked Total feed rows checked.
* @return void
*/
private function email_summary(array $alerts, int $checked) {
$lines = [];
$lines[] = sprintf("Found %d discrepancies out of %d items checked.\nThreshold: %.2f%%\n", count($alerts), $checked, $this->THRESHOLD_PERCENT);
$max = min(25, count($alerts));
for ($i=0; $i<$max; $i++) {
$a = $alerts[$i];
$lines[] = sprintf(
"- #%d | %s | SKU:%s | Feed:%0.2f vs Woo:%0.2f (%0.2f%%) | %s",
$a['product_id'], $a['meta']['name'], $a['sku'],
$a['feed_price'], $a['wc_price'], $a['diff_pct'],
$a['meta']['permalink']
);
}
if (count($alerts) > $max) {
$lines[] = sprintf("...and %d more.", count($alerts) - $max);
}
$subject = 'Woo Price Monitor: discrepancies detected';
$this->send_email($subject, implode("\n", $lines));
}
/**
* Send a plain-text email.
*
* @param string $subject Email subject.
* @param string $body Email body text.
* @return bool True if the email was sent successfully, false otherwise.
*/
private function send_email($subject, $body) {
return wp_mail($this->EMAIL_TO, $subject, $body);
}
}
WPML_Price_Monitor::instance();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment