Logs trail; * reimplementing that inside a plugin means reimplementing its failure modes * too. Signing is HMAC-SHA256, base64-encoded, in X-WC-Webhook-Signature — * exactly what WooCommerceAdapter::verifyWebhookSignature() checks. */ class Affclix_Webhook { const OPTION_IDS = 'affclix_webhook_ids'; /** * Topics we deliver. * * order.deleted and order.restored are supported by the AffClix parser but * deliberately not registered: WooCommerce sends only `{"id":N}` for a * delete, which normalises to a zero-value cancellation and would claw back * commission on an order that was merely moved to the trash by an admin * tidying up. Cancellations arrive through order.updated with a real status. * * @var string[] */ private static $topics = ['order.created', 'order.updated']; /** * Hook the refresh cron. */ public static function init() { add_action('affclix_daily_refresh', [__CLASS__, 'refresh']); if (! wp_next_scheduled('affclix_daily_refresh')) { wp_schedule_event(time() + HOUR_IN_SECONDS, 'daily', 'affclix_daily_refresh'); } } /** * Re-handshake and re-assert the webhooks. * * Runs daily because both halves can drift without anyone noticing: the * merchant can delete a webhook from WooCommerce -> Settings -> Advanced, * and the attribution window can change in the AffClix dashboard. */ public static function refresh() { $result = Affclix_Client::connect(); if ($result['ok']) { self::sync(); } } /** * Make the store's webhooks match the manifest. * * @return array{created:int,updated:int,errors:string[]} */ public static function sync() { $delivery_url = Affclix_Settings::get('webhook_url', ''); $secret = Affclix_Settings::get('webhook_secret', ''); $out = [ 'created' => 0, 'updated' => 0, 'errors' => [], ]; if ($delivery_url === '' || $secret === '') { $out['errors'][] = __('Connect to AffClix before creating webhooks.', 'affclix-for-woocommerce'); return $out; } $ids = self::ids(); foreach (self::$topics as $topic) { $existing = isset($ids[$topic]) ? new WC_Webhook((int) $ids[$topic]) : null; // get_id() returns 0 when the row is gone — the merchant deleted it, // or the store was migrated. Treat that as "create a new one". $webhook = ($existing && $existing->get_id()) ? $existing : new WC_Webhook; $is_new = ! ($existing && $existing->get_id()); $webhook->set_name('AffClix — '.$topic); $webhook->set_topic($topic); $webhook->set_delivery_url($delivery_url); $webhook->set_secret($secret); $webhook->set_status('active'); $webhook->set_api_version('wp_api_v3'); // Webhooks are delivered as the assigned user. Without one that can // read orders, WooCommerce sends an empty payload and AffClix // records nothing while every delivery reports success. if (! $webhook->get_user_id()) { $webhook->set_user_id(self::delivery_user_id()); } $saved = $webhook->save(); if (! $saved) { /* translators: %s: webhook topic. */ $out['errors'][] = sprintf(__('Could not save the %s webhook.', 'affclix-for-woocommerce'), $topic); continue; } $ids[$topic] = $webhook->get_id(); $is_new ? $out['created']++ : $out['updated']++; } update_option(self::OPTION_IDS, $ids, false); return $out; } /** * Whether a webhook id is one of ours. * * @param int $webhook_id Webhook ID. * @return bool */ public static function is_ours($webhook_id) { return in_array((int) $webhook_id, array_map('intval', array_values(self::ids())), true); } /** * Remove the webhooks this plugin created. */ public static function delete_all() { foreach (class_exists('WC_Webhook') ? self::ids() : [] as $id) { $webhook = new WC_Webhook((int) $id); if ($webhook->get_id()) { $webhook->delete(true); } } delete_option(self::OPTION_IDS); wp_clear_scheduled_hook('affclix_daily_refresh'); } /** * Current state, for the settings screen. * * @return array */ public static function status() { $out = []; foreach (self::$topics as $topic) { $ids = self::ids(); $id = isset($ids[$topic]) ? (int) $ids[$topic] : 0; $webhook = $id ? new WC_Webhook($id) : null; $exists = $webhook && $webhook->get_id(); $out[$topic] = [ 'id' => $id, 'status' => $exists ? $webhook->get_status() : 'missing', 'exists' => (bool) $exists, ]; } return $out; } /** * Topic => webhook id map. * * @return array */ private static function ids() { $ids = get_option(self::OPTION_IDS, []); return is_array($ids) ? $ids : []; } /** * A user id whose capabilities allow reading orders. * * Prefers whoever is setting the plugin up, because they are demonstrably an * admin; falls back to the first administrator so the cron path (which has * no current user) still produces a usable webhook. * * @return int */ private static function delivery_user_id() { if (current_user_can('manage_woocommerce')) { return get_current_user_id(); } $admins = get_users( [ 'role' => 'administrator', 'number' => 1, 'fields' => 'ID', 'orderby' => 'ID', ] ); return ! empty($admins) ? (int) $admins[0] : 0; } }