*/ private static $pending = array(); /** * Whether the shutdown flush handler has been registered this request. * * @var bool */ private static $shutdown_hooked = false; /** * Per-request dedup so the status_changed hook and the dedicated * status_{x} hook don't both queue the same order+status. * * @var array */ private static $seen = array(); /** * Register all WooCommerce order hooks. * * Uses a single woocommerce_order_status_changed hook for all WC status * transitions, plus dedicated hooks for order_placed and new_customer. * * @return void */ public static function init(): void { try { // Any WC status transition — slug = "wc-{to_status}". add_action( 'woocommerce_order_status_changed', static function ( $order_id, $from, $to, $order ) { SixRsh_Order_Hooks::maybe_dispatch_status( (int) $order_id, $to, 'changed ' . $from . '->' . $to ); }, 10, 4 ); // Dedicated per-status hooks. woocommerce_order_status_changed can miss the // FIRST entry into a status when an order is created directly in that status // or set via an async gateway callback (common for "processing"). These fire // reliably whenever an order ENTERS the status; maybe_dispatch_status() dedups // so a transition never double-sends. foreach ( array( 'processing', 'completed', 'on-hold', 'cancelled', 'refunded', 'failed', 'pending' ) as $st ) { add_action( 'woocommerce_order_status_' . $st, static function ( $order_id, $order = null ) use ( $st ) { SixRsh_Order_Hooks::maybe_dispatch_status( (int) $order_id, $st, 'status_' . $st ); }, 10, 2 ); } // Order placed at checkout (before any status). Classic checkout fires // woocommerce_checkout_order_created; Block Checkout fires only the // Store API hook, so both are bound and deduped per order. $on_placed = static function ( $order ) { static $done = array(); if ( ! $order instanceof \WC_Order || isset( $done[ $order->get_id() ] ) ) { return; } $done[ $order->get_id() ] = true; // COD order + COD confirmation on → ask the customer to // confirm instead of sending "Order Placed". if ( SixRsh_COD::handle_new_order( $order ) ) { return; } if ( ! get_option( 'sixrsh_event_order_placed_enabled', false ) ) { return; } SixRsh_Order_Hooks::dispatch( $order, 'order_placed' ); }; add_action( 'woocommerce_checkout_order_created', $on_placed, 10, 1 ); add_action( 'woocommerce_store_api_checkout_order_processed', $on_placed, 10, 1 ); // New customer welcome message. add_action( 'woocommerce_created_customer', static function ( $customer_id ) { SixRsh_Order_Hooks::dispatch_customer( (int) $customer_id ); }, 10, 1 ); // Consent checkbox hooks. add_action( 'woocommerce_checkout_after_customer_details', array( __CLASS__, 'render_consent_checkbox' ) ); add_action( 'woocommerce_checkout_create_order', array( __CLASS__, 'save_consent_to_order' ), 10, 2 ); } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Order_Hooks::init exception: ' . $e->getMessage() ); } } /** * Dispatch a status notification once per order+status, deduped so the * status_changed hook and the dedicated status_{x} hook can't double-send. * * @param int $order_id Order ID. * @param string $status WooCommerce status without the wc- prefix. * * @return void */ public static function maybe_dispatch_status( int $order_id, string $status, string $via = '' ): void { try { if ( ! $order_id || ! $status ) { return; } $slug = 'wc-' . $status; // Per-request dedup only. The status_changed hook AND the dedicated // status_{x} hook both fire in the same request — act once. Cross-request // / re-fire dedup is handled server-side by the Idempotency-Key, so we no // longer use a persistent 5-minute transient (it silently swallowed // legitimate repeat status changes / re-tests). $rk = $order_id . '|' . $slug; if ( isset( self::$seen[ $rk ] ) ) { return; } self::$seen[ $rk ] = true; // Only act on enabled events (keeps the Logs page focused — busy stores // fire many disabled transitions, e.g. POS instore-completed). if ( ! get_option( 'sixrsh_event_' . $slug . '_enabled', false ) ) { return; } // COD order still waiting for the customer's Confirm / Cancel: don't // tell them "order confirmed / processing" before they confirmed. if ( SixRsh_COD::suppress_status( $order_id, $status ) ) { SixRsh_Logger::trace( 'event:' . $slug, '', 'order ' . $order_id . ' held — awaiting COD confirmation' ); return; } // Diagnostic: prove the enabled event's hook fired (owner-visible). SixRsh_Logger::trace( 'event:' . $slug, '', 'order ' . $order_id . ' hook fired (' . $via . ')' ); SixRsh_Order_Hooks::dispatch( $order_id, $slug ); } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Order_Hooks::maybe_dispatch_status exception: ' . $e->getMessage() ); } } /** * Queue an order notification for delivery. Sends run on `shutdown` AFTER the * HTTP response is returned to the client — so there is ZERO dependency on * WP-Cron (the historic cause of missed notifications) and ZERO user-facing * latency. fastcgi_finish_request() (when available) closes the connection * first, then the 6rsh API call runs. * * @param \WC_Order|int $order_or_id Order object or ID. * @param string $event_key Event identifier. * * @return void */ public static function dispatch( $order_or_id, string $event_key ): void { try { $order_id = $order_or_id instanceof \WC_Order ? $order_or_id->get_id() : (int) $order_or_id; if ( ! $order_id ) { return; } self::$pending[ $order_id . '|' . $event_key ] = array( $order_id, $event_key ); SixRsh_Logger::trace( 'queued:' . $event_key, '', 'order ' . $order_id . ' queued for send' ); if ( ! self::$shutdown_hooked ) { self::$shutdown_hooked = true; add_action( 'shutdown', array( __CLASS__, 'flush_pending' ), 1 ); } } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Order_Hooks::dispatch exception: ' . $e->getMessage() ); } } /** * Deliver all queued notifications. Runs on shutdown; closes the client * connection first (FPM) so the send never blocks the page/admin/checkout. * * @return void */ public static function flush_pending(): void { if ( empty( self::$pending ) ) { return; } $jobs = self::$pending; self::$pending = array(); // Return the response to the user before doing the network call. if ( function_exists( 'fastcgi_finish_request' ) ) { @fastcgi_finish_request(); } foreach ( $jobs as $job ) { try { SixRsh_Cron::send_order_notification( (int) $job[0], (string) $job[1] ); } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Order_Hooks::flush_pending exception: ' . $e->getMessage() ); } } } /** * Trigger WP-Cron right away via a non-blocking loopback so scheduled * 6rsh notifications run within seconds rather than waiting on traffic. * * @return void */ public static function nudge_cron(): void { try { // Direct non-blocking loopback to wp-cron.php. We intentionally do NOT // use spawn_cron() (it short-circuits under DISABLE_WP_CRON and an active // lock) and we do NOT bail on DISABLE_WP_CRON: many hosts set that flag // but have no reliable system cron, which is exactly why a status change // produced no WhatsApp message. This fires the due event within seconds // without blocking the current request. Throttled to once per 20s. if ( get_transient( 'sixrsh_cron_nudge' ) ) { return; } set_transient( 'sixrsh_cron_nudge', 1, 20 ); $cron_url = site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ); wp_remote_post( $cron_url, array( 'timeout' => 0.01, 'blocking' => false, 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), 'headers' => array( 'Cache-Control' => 'no-cache' ), ) ); } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Order_Hooks::nudge_cron exception: ' . $e->getMessage() ); } } /** * Schedule an async cron event for a new customer registration. * * @param int $customer_id WP user ID. * * @return void */ public static function dispatch_customer( int $customer_id ): void { try { if ( ! $customer_id ) { return; } // 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_customer_notification', array( $customer_id ) ) ) { wp_schedule_single_event( time(), 'sixrsh_send_customer_notification', array( $customer_id ) ); } self::nudge_cron(); } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Order_Hooks::dispatch_customer exception: ' . $e->getMessage() ); } } /** * Render the WhatsApp opt-in consent checkbox on checkout. * * Only shown when consent_required setting is true. * * @return void */ public static function render_consent_checkbox(): void { try { $settings = get_option( 'sixrsh_settings', array() ); if ( empty( $settings['consent_required'] ) ) { return; } ?> getMessage() ); } } /** * Save the consent checkbox value to order meta. * * @param \WC_Order $order Order object. * @param array $data Posted checkout data. * * @return void */ public static function save_consent_to_order( \WC_Order $order, array $data ): void { try { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified by WC checkout $consent = isset( $_POST['sixrsh_consent'] ) ? '1' : '0'; $order->update_meta_data( '_sixrsh_consent', $consent ); } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Order_Hooks::save_consent_to_order exception: ' . $e->getMessage() ); } } }