true). * * @var array */ private static $pending_sync = array(); /** * Whether the shutdown flush handler has been registered this request. * * @var bool */ private static $sync_shutdown_hooked = false; /** * Per-request dedup so the same order is queued for sync at most once even * when several hooks (status_changed + status_{x} + meta change) all fire. * * @var array */ private static $sync_seen = array(); /** * Register hooks. * * @return void */ public static function init(): void { try { // Generic transition catch-all. ALWAYS registered: it drives delayed // follow-up scheduling (independent of AI sync) and also queues a sync. // queue_sync() self-guards on the sync kill-switch, so when sync is off // this only schedules follow-ups. add_action( 'woocommerce_order_status_changed', array( __CLASS__, 'on_status_changed' ), 20, 4 ); // Follow-up + reconcile/sync cron handlers — always registered so any // already-queued events (and follow-ups) still drain. add_action( 'sixrsh_send_followup', array( __CLASS__, 'send_followup' ), 10, 2 ); add_action( 'sixrsh_sync_order', array( __CLASS__, 'sync_order' ), 10, 1 ); add_action( 'sixrsh_order_reconcile', array( __CLASS__, 'reconcile' ), 10, 0 ); // --------------------------------------------------------------- // AI order sync hooks — KILL-SWITCH gated (sixrsh_order_sync_enabled). // When the switch is off NONE of these attach, so there is zero // per-request / per-order overhead from the sync path. The broad // added_post_meta/updated_post_meta hooks were REMOVED in 1.5.2: they // fired the callback on every meta write for every post type site-wide // (and never caught order meta on HPOS stores anyway). Late tracking is // covered by the dedicated shipment-tracking hooks below plus the hourly // reconcile sweep. // --------------------------------------------------------------- if ( '1' === (string) get_option( 'sixrsh_order_sync_enabled', '1' ) ) { // Brand-new orders. woocommerce_order_status_changed can MISS the first // entry into a status (POS / instore-completed, orders created directly // in a status, async gateway callbacks). These fire reliably at creation. add_action( 'woocommerce_checkout_order_created', array( __CLASS__, 'queue_sync' ), 20, 1 ); add_action( 'woocommerce_new_order', array( __CLASS__, 'queue_sync' ), 20, 1 ); // Dedicated per-status hooks — fire whenever an order ENTERS the status. // 'shipped' covers stores using a custom shipped status. foreach ( array( 'processing', 'completed', 'on-hold', 'cancelled', 'refunded', 'failed', 'pending', 'shipped' ) as $st ) { add_action( 'woocommerce_order_status_' . $st, array( __CLASS__, 'queue_sync' ), 20, 1 ); } // LATE tracking — AST / WooCommerce Shipment Tracking save actions. add_action( 'wc_shipment_tracking_updated', array( __CLASS__, 'queue_sync' ), 20, 1 ); add_action( 'woocommerce_shipment_tracking_updated', array( __CLASS__, 'queue_sync' ), 20, 1 ); // Self-heal the hourly reconcile sweep — the activation hook does not // fire on an in-place "replace current with uploaded" plugin update. self::ensure_reconcile_scheduled(); } else { // Switch is OFF — make sure the hourly sweep is not left scheduled. if ( wp_next_scheduled( 'sixrsh_order_reconcile' ) ) { wp_clear_scheduled_hook( 'sixrsh_order_reconcile' ); } } } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Followups::init exception: ' . $e->getMessage() ); } } /** * Deactivation cleanup. * * @return void */ public static function deactivate(): void { wp_clear_scheduled_hook( 'sixrsh_send_followup' ); wp_clear_scheduled_hook( 'sixrsh_sync_order' ); wp_clear_scheduled_hook( 'sixrsh_order_reconcile' ); } // ------------------------------------------------------------------------- // Robust order sync (shutdown push, no WP-Cron dependency) // ------------------------------------------------------------------------- /** * Queue an order for a sync push on shutdown. Deduped per request so firing * on several hooks for one order results in a single idempotent upsert. * * @param \WC_Order|int $order_or_id Order object or ID. * * @return void */ public static function queue_sync( $order_or_id ): void { try { $order_id = $order_or_id instanceof \WC_Order ? $order_or_id->get_id() : (int) $order_or_id; if ( ! $order_id ) { return; } if ( ! get_option( 'sixrsh_api_key' ) || ! get_option( 'sixrsh_order_sync_enabled', '1' ) ) { return; } if ( isset( self::$sync_seen[ $order_id ] ) ) { return; } self::$sync_seen[ $order_id ] = true; self::$pending_sync[ $order_id ] = true; // NOTE: the +120s wp_schedule_single_event fallback was REMOVED in 1.5.2. // It churned the autoloaded `cron` option on every order event and was // fully redundant: the shutdown push is now a non-blocking fire-and-forget // upsert, and the hourly reconcile sweep already re-pushes recent orders. if ( ! self::$sync_shutdown_hooked ) { self::$sync_shutdown_hooked = true; add_action( 'shutdown', array( __CLASS__, 'flush_sync' ), 1 ); } } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Followups::queue_sync exception: ' . $e->getMessage() ); } } /** * Deliver all queued order syncs. Runs on shutdown; closes the client * connection first (FPM) so the push never blocks the page/checkout/admin. * * @return void */ public static function flush_sync(): void { if ( empty( self::$pending_sync ) ) { return; } $ids = array_keys( self::$pending_sync ); self::$pending_sync = array(); // Return the response to the user before doing the network call. if ( function_exists( 'fastcgi_finish_request' ) ) { @fastcgi_finish_request(); } foreach ( $ids as $order_id ) { try { self::sync_order( (int) $order_id ); } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Followups::flush_sync exception: ' . $e->getMessage() ); } } } // ------------------------------------------------------------------------- // Hourly reconciliation sweep (safety net) // ------------------------------------------------------------------------- /** * Ensure the hourly reconcile sweep is scheduled (idempotent / self-healing). * * @return void */ public static function ensure_reconcile_scheduled(): void { try { if ( ! get_option( 'sixrsh_api_key' ) ) { return; } if ( ! wp_next_scheduled( 'sixrsh_order_reconcile' ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', 'sixrsh_order_reconcile' ); } } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Followups::ensure_reconcile_scheduled exception: ' . $e->getMessage() ); } } /** * Hourly safety net: re-push orders that CHANGED since the last sweep (incl. * tracking added later). Catches anything the live shutdown push missed. * * 1.5.2: this used to re-push the entire trailing 7-day window (up to 100 * orders) EVERY hour regardless of whether anything changed — 100 HTTP calls * an hour forever, and (when calls were blocking) a worker pinned for minutes. * It now only sweeps orders modified since the previous run (with a small * overlap) and dispatches each upsert non-blocking, so a quiet store does * almost no work. First run / missing marker falls back to a short 2h window, * not 7 days. * * @return void */ public static function reconcile(): void { try { if ( ! get_option( 'sixrsh_api_key' ) || '1' !== (string) get_option( 'sixrsh_order_sync_enabled', '1' ) ) { return; } if ( ! function_exists( 'wc_get_orders' ) ) { return; } $now = time(); $last = (int) get_option( 'sixrsh_last_reconcile', 0 ); // Only orders modified since the last sweep (15-min overlap to avoid // edge misses). Cold start: a short 2h window, capped at 7 days. $since = $last > 0 ? max( $last - 15 * MINUTE_IN_SECONDS, $now - 7 * DAY_IN_SECONDS ) : $now - 2 * HOUR_IN_SECONDS; $order_ids = wc_get_orders( array( 'limit' => 100, 'orderby' => 'date_modified', 'order' => 'DESC', 'date_modified' => '>=' . $since, 'return' => 'ids', ) ); // Advance the marker even when nothing matched, so the window doesn't // keep widening on a quiet store. update_option( 'sixrsh_last_reconcile', $now, false ); if ( empty( $order_ids ) || ! is_array( $order_ids ) ) { return; } foreach ( $order_ids as $oid ) { self::sync_order( (int) $oid ); } } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Followups::reconcile exception: ' . $e->getMessage() ); } } /** * Order status transition: queue a sync to 6rsh, schedule or cancel follow-ups. * * @param int $order_id Order ID. * @param string $from Previous status (no wc- prefix). * @param string $to New status (no wc- prefix). * @param \WC_Order $order Order object. * * @return void */ public static function on_status_changed( $order_id, $from, $to, $order ): void { try { // Always (re)sync the latest status to 6rsh (robust shutdown push). self::queue_sync( (int) $order_id ); // Cancellation statuses kill any pending follow-ups for this order. if ( in_array( $to, self::CANCEL_STATUSES, true ) ) { self::cancel_followups( (int) $order_id ); return; } // Schedule rules whose trigger matches the new status. for ( $i = 1; $i <= self::MAX_RULES; $i++ ) { $rule = self::get_rule( $i ); if ( empty( $rule['enabled'] ) || empty( $rule['template_id'] ) ) { continue; } if ( $rule['trigger'] !== $to ) { continue; } // Never double-schedule the same rule for the same order. $meta_key = '_sixrsh_followup_' . $i; $wc_order = wc_get_order( $order_id ); if ( ! $wc_order || $wc_order->get_meta( $meta_key, true ) ) { continue; } $delay_seconds = max( 60, (int) $rule['delay_value'] * ( 'weeks' === $rule['delay_unit'] ? WEEK_IN_SECONDS : DAY_IN_SECONDS ) ); $timestamp = time() + $delay_seconds; // Dedupe (standing rule): never schedule a single event when the // same hook+args is already queued — un-deduplicated single events // caused the pre-1.5.2 cron-blob pileup. if ( ! wp_next_scheduled( 'sixrsh_send_followup', array( (int) $order_id, $i ) ) ) { wp_schedule_single_event( $timestamp, 'sixrsh_send_followup', array( (int) $order_id, $i ) ); } $wc_order->update_meta_data( $meta_key, $timestamp ); $wc_order->save(); $wc_order->add_order_note( sprintf( /* translators: 1: rule number 2: date */ __( '[6rsh] Follow-up #%1$d scheduled for %2$s.', '6rsh-messaging-notifications' ), $i, date_i18n( get_option( 'date_format' ) . ' H:i', $timestamp ) ) ); } } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Followups::on_status_changed exception: ' . $e->getMessage() ); } } /** * Unschedule pending follow-ups for an order (refund/cancel). * * @param int $order_id Order ID. * * @return void */ public static function cancel_followups( int $order_id ): void { $order = wc_get_order( $order_id ); for ( $i = 1; $i <= self::MAX_RULES; $i++ ) { wp_unschedule_event( (int) ( $order ? $order->get_meta( '_sixrsh_followup_' . $i, true ) : 0 ), 'sixrsh_send_followup', array( $order_id, $i ) ); // Belt & braces — clear by args regardless of stored timestamp. wp_clear_scheduled_hook( 'sixrsh_send_followup', array( $order_id, $i ) ); if ( $order && $order->get_meta( '_sixrsh_followup_' . $i, true ) ) { $order->delete_meta_data( '_sixrsh_followup_' . $i ); $order->add_order_note( sprintf( __( '[6rsh] Follow-up #%d cancelled (order refunded/cancelled).', '6rsh-messaging-notifications' ), $i ) ); } } if ( $order ) { $order->save(); } } /** * Cron handler: send a delayed marketing follow-up. * * @param int $order_id Order ID. * @param int $rule_idx Rule number (1 or 2). * * @return void */ public static function send_followup( int $order_id, int $rule_idx ): void { try { $order = wc_get_order( $order_id ); if ( ! $order ) { return; } $rule = self::get_rule( $rule_idx ); if ( empty( $rule['enabled'] ) || empty( $rule['template_id'] ) ) { return; } // Last-second safety: order must not be cancelled/refunded since scheduling. if ( in_array( $order->get_status(), self::CANCEL_STATUSES, true ) || (float) $order->get_total_refunded() >= (float) $order->get_total() && (float) $order->get_total() > 0 ) { SixRsh_Logger::log( 'followup_' . $rule_idx, $order->get_billing_phone(), '', 'skipped', 'Order refunded/cancelled before follow-up.' ); return; } // Marketing consent: respect the same checkout consent as notifications. $settings = get_option( 'sixrsh_settings', array() ); if ( ! empty( $settings['consent_required'] ) && '1' !== $order->get_meta( '_sixrsh_consent', true ) ) { return; } $country_code = get_option( 'sixrsh_country_code', 'AE' ); $phone = SixRsh_Phone::normalize( (string) $order->get_billing_phone(), (string) $country_code ); if ( ! $phone ) { SixRsh_Logger::log( 'followup_' . $rule_idx, (string) $order->get_billing_phone(), '', 'failed', 'Invalid phone number.' ); return; } $variables = SixRsh_Cron::build_order_variables( 'followup_' . $rule_idx, (string) ( $rule['template_name'] ?? '' ), $rule['template_id'], $order ); $api = SixRsh_API::instance(); $result = $api ? $api->send_followup( array( 'template' => $rule['template_name'] ?: (string) $rule['template_id'], 'phone' => $phone, 'variables' => $variables, 'order_id' => (string) $order->get_order_number(), 'rule' => $rule_idx, 'first_name' => $order->get_billing_first_name(), 'last_name' => $order->get_billing_last_name(), ), 'fu-' . md5( home_url() ) . '-' . $order_id . '-' . $rule_idx ) : array( 'success' => false, 'error' => 'No API key configured.' ); if ( ! empty( $result['success'] ) ) { SixRsh_Logger::log( 'followup_' . $rule_idx, $phone, (string) ( $rule['template_name'] ?? '' ), 'success' ); $order->add_order_note( sprintf( __( '[6rsh] Marketing follow-up #%1$d sent to %2$s.', '6rsh-messaging-notifications' ), $rule_idx, $phone ) ); } else { SixRsh_Logger::log( 'followup_' . $rule_idx, $phone, (string) ( $rule['template_name'] ?? '' ), 'failed', $result['error'] ?? 'Unknown error' ); $order->add_order_note( sprintf( __( '[6rsh] Follow-up #%1$d failed: %2$s', '6rsh-messaging-notifications' ), $rule_idx, $result['error'] ?? 'Unknown error' ) ); } } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Followups::send_followup exception: ' . $e->getMessage() ); } } /** * Push a live order snapshot (status + tracking) to 6rsh for AI lookups. * * @param int $order_id Order ID. * * @return bool True when the (non-blocking) upsert was dispatched. */ public static function sync_order( int $order_id ): bool { try { $order = wc_get_order( $order_id ); if ( ! $order ) { return false; } $country_code = get_option( 'sixrsh_country_code', 'AE' ); $phone = SixRsh_Phone::normalize( (string) $order->get_billing_phone(), (string) $country_code ); if ( ! $phone ) { return false; } $items = array(); foreach ( $order->get_items() as $item ) { $items[] = $item->get_quantity() . '× ' . $item->get_name(); if ( count( $items ) >= 10 ) { break; } } $tracking = self::extract_tracking( $order ); $api = SixRsh_API::instance(); if ( ! $api ) { return false; } // Fire-and-forget: the upsert is idempotent on (user_id, order_number) // server-side and we never read its response, so this is dispatched // non-blocking and the worker is freed immediately — this is what stops // the sync path (and the hourly reconcile) from pinning CPU/workers. return $api->upsert_order_async( array( 'order_number' => (string) $order->get_order_number(), 'phone' => $phone, 'email' => (string) $order->get_billing_email(), 'customer_name' => trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() ), 'status' => $order->get_status(), 'total' => (string) $order->get_total(), 'currency' => $order->get_currency(), 'tracking' => $tracking['number'], 'tracking_company' => $tracking['company'], 'tracking_url' => $tracking['url'], 'shipped_at' => $tracking['shipped_at'], 'items' => implode( ', ', $items ), 'placed_at' => $order->get_date_created() ? $order->get_date_created()->date( 'c' ) : null, ) ); } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Followups::sync_order exception: ' . $e->getMessage() ); return false; } } /** * Extract tracking number + carrier + URL + ship date from order meta. * * Supports WooCommerce Shipment Tracking / AST (`_wc_shipment_tracking_items`, * an array of { tracking_number, tracking_provider | custom_tracking_provider, * date_shipped, custom_tracking_link }) and the legacy single-field metas. * * @param \WC_Order $order Order object. * * @return array{number:string,company:string,url:string,shipped_at:?string} */ private static function extract_tracking( \WC_Order $order ): array { $number = ''; $company = ''; $url = ''; $shipped_at = null; $order_id = $order->get_id(); // Preferred: Shipment Tracking's own API. For KNOWN providers (Aramex, DHL…) // the URL is generated from the provider format, NOT stored in meta — // get_tracking_items(..., true) computes formatted_tracking_provider/link. if ( class_exists( 'WC_Shipment_Tracking_Actions' ) && method_exists( 'WC_Shipment_Tracking_Actions', 'get_instance' ) ) { try { $st = WC_Shipment_Tracking_Actions::get_instance(); $items = $st->get_tracking_items( $order_id, true ); if ( is_array( $items ) && ! empty( $items[0] ) ) { $it = $items[0]; $number = (string) ( $it['tracking_number'] ?? '' ); $company = (string) ( $it['formatted_tracking_provider'] ?? ( $it['tracking_provider'] ?? '' ) ); if ( '' === $company && ! empty( $it['custom_tracking_provider'] ) ) { $company = (string) $it['custom_tracking_provider']; } $url = (string) ( $it['formatted_tracking_link'] ?? ( $it['custom_tracking_link'] ?? '' ) ); $shipped_at = self::to_iso_date( $it['date_shipped'] ?? null ); } } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Shipment Tracking API read failed: ' . $e->getMessage() ); } } // Fallback: read the raw Shipment Tracking meta directly. if ( '' === $number ) { $wc_items = $order->get_meta( '_wc_shipment_tracking_items', true ); if ( is_array( $wc_items ) && ! empty( $wc_items[0] ) ) { $first = $wc_items[0]; $number = (string) ( $first['tracking_number'] ?? '' ); $company = (string) ( $first['tracking_provider'] ?? ( $first['custom_tracking_provider'] ?? '' ) ); $url = (string) ( $first['custom_tracking_link'] ?? '' ); $shipped_at = self::to_iso_date( $first['date_shipped'] ?? null ); } } // AfterShip / generic single-value meta fallbacks. if ( '' === $number ) { foreach ( array( '_aftership_tracking_number', '_tracking_number' ) as $key ) { $val = $order->get_meta( $key, true ); if ( is_string( $val ) && '' !== $val ) { $number = $val; break; } } } if ( '' === $company ) { foreach ( array( '_aftership_tracking_provider_name', '_tracking_provider', '_shipping_carrier' ) as $key ) { $val = $order->get_meta( $key, true ); if ( is_string( $val ) && '' !== $val ) { $company = $val; break; } } } return array( 'number' => $number, 'company' => $company, 'url' => $url, 'shipped_at' => $shipped_at, ); } /** * Normalize a shipment date_shipped value (unix ts or Y-m-d) to ISO 8601. * * @param mixed $raw Raw date value. * * @return string|null */ private static function to_iso_date( $raw ): ?string { if ( empty( $raw ) ) { return null; } if ( is_numeric( $raw ) ) { return gmdate( 'c', (int) $raw ); } $ts = strtotime( (string) $raw ); return $ts ? gmdate( 'c', $ts ) : null; } /** * Load a follow-up rule's saved configuration. * * @param int $idx Rule number (1 or 2). * * @return array{enabled:bool,trigger:string,delay_value:int,delay_unit:string,template_id:mixed,template_name:string} */ public static function get_rule( int $idx ): array { $raw = get_option( 'sixrsh_followup_rule_' . $idx, array() ); $rule = is_array( $raw ) ? $raw : array(); return array( 'enabled' => ! empty( $rule['enabled'] ), 'trigger' => sanitize_key( $rule['trigger'] ?? 'completed' ), 'delay_value' => max( 1, (int) ( $rule['delay_value'] ?? 7 ) ), 'delay_unit' => in_array( $rule['delay_unit'] ?? 'days', array( 'days', 'weeks' ), true ) ? $rule['delay_unit'] : 'days', 'template_id' => $rule['template_id'] ?? null, 'template_name' => (string) ( $rule['template_name'] ?? '' ), ); } }