-
-
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…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?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