getMessage() ); } } /** * Get (or generate) a stable cart session token. * * @return string */ public static function get_cart_token(): string { try { if ( function_exists( 'WC' ) && WC()->session ) { $token = WC()->session->get_customer_id(); if ( $token ) { return (string) $token; } } } catch ( \Throwable $e ) { // Fall through to generate a token. } // Fallback: store a token in a cookie. $cookie_name = 'sixrsh_cart_token'; if ( ! empty( $_COOKIE[ $cookie_name ] ) ) { return sanitize_text_field( wp_unslash( $_COOKIE[ $cookie_name ] ) ); } $token = wp_generate_uuid4(); // phpcs:ignore WordPress.Arrays.ArrayKeySpacingRestrictions -- non-sensitive token cookie setcookie( $cookie_name, $token, time() + ( 30 * DAY_IN_SECONDS ), COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true ); return $token; } /** Recovery links stop working after this many seconds. */ const RECOVERY_TTL = 30 * DAY_IN_SECONDS; /** * Build the signed value for ?6rsh_recover=. * * Format: {cart_token}.{expires}.{hmac}. The bare cart token is the WC * customer id — for logged-in shoppers that is their numeric user id — so an * unsigned link let anyone load any customer's saved cart. * * @param string $cart_token Cart session token. * @param int $expires Unix expiry; defaults to now + RECOVERY_TTL. * * @return string */ public static function recovery_param( string $cart_token, int $expires = 0 ): string { $expires = $expires > 0 ? $expires : time() + self::RECOVERY_TTL; return $cart_token . '.' . $expires . '.' . self::recovery_sig( $cart_token, $expires ); } /** * Verify a ?6rsh_recover= value and return its cart token, or '' if the * value is unsigned, tampered with or expired. * * @param string $param Raw parameter value. * * @return string */ public static function verify_recovery_param( string $param ): string { $parts = explode( '.', $param ); if ( count( $parts ) !== 3 ) { return ''; } list( $token, $expires, $sig ) = $parts; if ( '' === $token || ! ctype_digit( $expires ) || (int) $expires < time() ) { return ''; } return hash_equals( self::recovery_sig( $token, (int) $expires ), $sig ) ? $token : ''; } /** * HMAC for a recovery link, keyed by the site's own salt (never leaves the store). * * @param string $cart_token Cart session token. * @param int $expires Unix expiry. * * @return string */ private static function recovery_sig( string $cart_token, int $expires ): string { return substr( hash_hmac( 'sha256', 'sixrsh_recover|' . $cart_token . '|' . $expires, wp_salt( 'auth' ) ), 0, 32 ); } // schedule_cart_sync() used to live here. DO NOT reintroduce per-cart // wp_schedule_single_event() calls: an immediately-due, un-deduplicated // single event per cart change piled up ~14,700 stale `sixrsh_sync_cart` // entries in the `cron` option (2.5 MB, autoload=on) on a production site, // and stacked wp-cron runners rewriting that blob on every tick saturated // the server's CPU. If cart sync ever needs an async path again, use ONE // recurring event processing a queue (the delta-window reconcile pattern // from the 1.5.2 order-sync CPU fix), gate it behind the // `sixrsh_order_sync_enabled` kill-switch family, and ALWAYS check // wp_next_scheduled() for the same hook+args before scheduling. /** * Ensure WooCommerce's cart + session are loaded for the current request. * * On REST requests (our /6rsh/v1/capture-cart endpoint) WooCommerce does not * initialize the cart/session, so WC()->cart is null. wc_load_cart() hydrates * both from the customer's existing session cookie (sent by checkout.js with * credentials:'same-origin'), so the live cart contents become readable. * No-op when they are already loaded (normal frontend requests). * * @return void */ private static function ensure_cart_loaded(): void { try { if ( ! function_exists( 'WC' ) ) { return; } if ( ( ! WC()->cart || ! WC()->session ) && function_exists( 'wc_load_cart' ) ) { wc_load_cart(); } } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Cart_Tracker::ensure_cart_loaded exception: ' . $e->getMessage() ); } } /** * Perform the actual cart sync to the 6rsh API and local table. * * @param string $cart_token Cart session token. * * @return void */ public static function do_sync( string $cart_token ): void { global $wpdb; try { if ( ! function_exists( 'WC' ) ) { return; } // Paid-plan gate (spec B3): skip the /carts/upsert call entirely when // not entitled. Passive check (transient-cached) so this adds no // network cost on the storefront. Prevents pointless 402s. if ( class_exists( 'SixRsh_Entitlements' ) && ! SixRsh_Entitlements::can_use_abandoned_carts() ) { return; } // AUTO-YIELD: CartRevival owns cart recovery on this store, so 6rsh // does NOT push carts (that would double-message the shopper). // Order notifications / follow-ups / widget are unaffected. if ( class_exists( 'SixRsh_Coexistence' ) && SixRsh_Coexistence::should_yield_cart_engine() ) { return; } // Hydrate the cart/session from the customer's cookie when it isn't // already loaded. WooCommerce does NOT load the cart on REST requests // (is_request('frontend') excludes REST), so without this the call below // always saw a null cart and bailed — nothing was ever captured. self::ensure_cart_loaded(); if ( ! WC()->cart ) { return; } $cart = WC()->cart; if ( $cart->is_empty() ) { return; } // Build items array. $items = array(); foreach ( $cart->get_cart() as $item ) { $product = $item['data'] ?? null; $items[] = array( 'product_id' => $item['product_id'] ?? 0, // Variable products can't be re-added without these. 'variation_id' => (int) ( $item['variation_id'] ?? 0 ), 'variation' => is_array( $item['variation'] ?? null ) ? $item['variation'] : array(), 'name' => $product ? $product->get_name() : '', 'qty' => $item['quantity'] ?? 1, 'price' => $product ? (float) $product->get_price() : 0.0, ); } $cart_value = (float) $cart->get_cart_contents_total(); $currency = get_woocommerce_currency(); $checkout_url = add_query_arg( '6rsh_recover', self::recovery_param( $cart_token ), wc_get_checkout_url() ); $now = current_time( 'mysql', true ); $settings = get_option( 'sixrsh_settings', array() ); // Read the contact details captured live at checkout (session). $raw_phone = ''; $user_email = ''; $opt_in_sess = '1'; try { if ( WC()->session ) { $raw_phone = (string) ( WC()->session->get( 'sixrsh_guest_phone', '' ) ?? '' ); $user_email = (string) ( WC()->session->get( 'sixrsh_guest_email', '' ) ?? '' ); $opt_in_sess = (string) ( WC()->session->get( 'sixrsh_opt_in', '1' ) ?? '1' ); } } catch ( \Throwable $e ) { // Ignore — fall through to the gates below. } // Effective consent: when opt-in is required, honor the box; when it is // NOT required, every captured cart is recoverable. $required = self::opt_in_required( $settings ); $opt_in = $required ? ( '1' === $opt_in_sess ) : true; // Opt-in gate (Meta/WABA compliance) — never store or sync a recovery // target without consent when opt-in is required (default on). if ( $required && ! $opt_in ) { return; } // Phone is the ONLY valid WhatsApp recovery target (email cannot be a // send target). Normalize to E.164 and discard if invalid — no point // tracking a cart we can never recover. $country = SixRsh_Phone::country_for( ( function_exists( 'WC' ) && WC()->customer ) ? (string) WC()->customer->get_billing_country() : '' ); $phone = SixRsh_Phone::normalize( $raw_phone, (string) $country ); if ( ! $phone ) { return; } $payload = array( 'cart_token' => $cart_token, 'phone' => $phone, // server field is `phone` (was user_phone) 'email' => '' !== $user_email ? $user_email : null, // stored, NOT a send target ('' fails email validation) 'opt_in' => $opt_in, 'items' => $items, 'value' => $cart_value, // server field is `value` (was cart_value) 'currency' => $currency, 'checkout_url' => $checkout_url, 'last_activity_at' => $now, ); // Call API. $api = SixRsh_API::instance(); $result = $api ? $api->upsert_cart( $payload ) : array( 'success' => false ); // Server paid-gate 402: the plan lost entitlement. Bust the local // entitlement cache so the very next check/render reflects locked // state and stops calling — no error notices, no retry storm. if ( ! empty( $result['error_code'] ) && 'abandoned_carts_requires_paid_plan' === $result['error_code'] ) { if ( class_exists( 'SixRsh_Entitlements' ) ) { SixRsh_Entitlements::bust(); } return; } $status = $result['success'] ? 'synced' : 'open'; // Upsert local record. $table = $wpdb->prefix . 'sixrsh_carts'; // phpcs:disable WordPress.DB.DirectDatabaseQuery $existing = $wpdb->get_var( $wpdb->prepare( "SELECT cart_id FROM {$wpdb->prefix}sixrsh_carts WHERE cart_token = %s", $cart_token ) ); if ( $existing ) { $wpdb->update( $table, array( 'items_json' => wp_json_encode( $items ), 'cart_value' => $cart_value, 'currency' => $currency, 'checkout_url' => $checkout_url, 'last_activity_at' => $now, 'status' => $status, 'synced_at' => $result['success'] ? $now : null, 'user_phone' => $phone, 'user_email' => $user_email, 'opt_in' => $opt_in ? 1 : 0, ), array( 'cart_token' => $cart_token ), array( '%s', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d' ), array( '%s' ) ); } else { $wpdb->insert( $table, array( 'cart_token' => $cart_token, 'user_phone' => $phone, 'user_email' => $user_email, 'opt_in' => $opt_in ? 1 : 0, 'items_json' => wp_json_encode( $items ), 'cart_value' => $cart_value, 'currency' => $currency, 'checkout_url' => $checkout_url, 'captured_at' => $now, 'last_activity_at' => $now, 'status' => $status, 'synced_at' => $result['success'] ? $now : null, ), array( '%s', '%s', '%s', '%d', '%s', '%f', '%s', '%s', '%s', '%s', '%s', '%s' ) ); } // phpcs:enable } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Cart_Tracker::do_sync exception: ' . $e->getMessage() ); } } /** * Whether checkout opt-in is required before a cart is eligible for recovery. * Defaults to TRUE (protects WABA quality / Meta compliance). * * @param array $settings Plugin settings (passed in to avoid a re-fetch). * * @return bool */ public static function opt_in_required( array $settings ): bool { $ac = $settings['abandoned_cart'] ?? array(); // Missing key => required (safe default). Only an explicit false disables it. return ! isset( $ac['require_opt_in'] ) || ! empty( $ac['require_opt_in'] ); } /** * Live capture handler — called by the REST endpoint as the customer fills in * the checkout (phone/email blur, opt-in toggle), BEFORE the order is placed. * * Normalizes + validates the phone (discards if invalid), enforces the opt-in * gate, stashes the details in the WC session, and syncs the cart server-side. * * @param string $phone Raw billing phone. * @param string $email Raw billing email (stored, never a send target). * @param bool $opt_in Whether the WhatsApp opt-in box is checked. * * @return array{success:bool, reason?:string, phone?:string, opt_in?:bool} */ public static function capture_live( string $phone, string $email, bool $opt_in ): array { try { $settings = get_option( 'sixrsh_settings', array() ); if ( empty( $settings['abandoned_cart']['enabled'] ) ) { return array( 'success' => false, 'reason' => 'disabled' ); } // Paid-plan gate (spec B3): don't capture / sync when not entitled. if ( class_exists( 'SixRsh_Entitlements' ) && ! SixRsh_Entitlements::can_use_abandoned_carts() ) { return array( 'success' => false, 'reason' => 'not_entitled' ); } // AUTO-YIELD to CartRevival (see SixRsh_Coexistence) — it captures // and recovers this cart instead; 6rsh must not duplicate. if ( class_exists( 'SixRsh_Coexistence' ) && SixRsh_Coexistence::should_yield_cart_engine() ) { return array( 'success' => false, 'reason' => 'yielded_to_cartrevival' ); } // This runs inside a REST request where WooCommerce hasn't loaded the // cart/session — hydrate them first so the session writes below land in // the customer's real session and do_sync() can read the live cart. self::ensure_cart_loaded(); // Opt-in gate — if required and unchecked, withdraw any prior consent so // we never recover a cart the customer opted out of. if ( self::opt_in_required( $settings ) && ! $opt_in ) { self::withdraw_consent(); return array( 'success' => false, 'reason' => 'no_opt_in' ); } $country = SixRsh_Phone::country_for( ( function_exists( 'WC' ) && WC()->customer ) ? (string) WC()->customer->get_billing_country() : '' ); $normalized = SixRsh_Phone::normalize( $phone, (string) $country ); if ( ! $normalized ) { return array( 'success' => false, 'reason' => 'invalid_phone' ); } if ( function_exists( 'WC' ) && WC()->session ) { WC()->session->set( 'sixrsh_guest_phone', $normalized ); WC()->session->set( 'sixrsh_guest_email', sanitize_email( $email ) ); WC()->session->set( 'sixrsh_opt_in', $opt_in ? '1' : '0' ); } // Persist + push the pending cart now (do_sync re-applies the gates and // only stores when there is a non-empty cart). self::do_sync( self::get_cart_token() ); // cart_items lets us confirm the REST request actually saw the live cart // (0 would mean the session/cart didn't hydrate). $cart_items = ( WC()->cart ) ? (int) WC()->cart->get_cart_contents_count() : 0; return array( 'success' => true, 'phone' => $normalized, 'opt_in' => $opt_in, 'cart_items' => $cart_items ); } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Cart_Tracker::capture_live exception: ' . $e->getMessage() ); return array( 'success' => false, 'reason' => 'error' ); } } /** * Withdraw consent for the current session's cart (customer unchecked the * opt-in box). Flips any already-stored recovery row to opt_in=0 locally and * on the 6rsh platform so the pending recovery is cancelled. Does NOT store a * phone for customers who never consented (no row => nothing to withdraw). * * @return void */ private static function withdraw_consent(): void { global $wpdb; try { $token = self::get_cart_token(); $table = $wpdb->prefix . 'sixrsh_carts'; // phpcs:disable WordPress.DB.DirectDatabaseQuery $row = $wpdb->get_row( $wpdb->prepare( "SELECT user_phone FROM {$wpdb->prefix}sixrsh_carts WHERE cart_token = %s", $token ) ); if ( $row && ! empty( $row->user_phone ) ) { $wpdb->update( $table, array( 'opt_in' => 0 ), array( 'cart_token' => $token ), array( '%d' ), array( '%s' ) ); // phpcs:enable $api = SixRsh_API::instance(); if ( $api ) { $api->upsert_cart( array( 'cart_token' => $token, 'phone' => $row->user_phone, // consent previously given for this number 'opt_in' => false, ) ); } } if ( function_exists( 'WC' ) && WC()->session ) { WC()->session->set( 'sixrsh_opt_in', '0' ); } } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Cart_Tracker::withdraw_consent exception: ' . $e->getMessage() ); } } /** * Capture the billing phone/email + opt-in from the POSTed checkout data as a * server-side fallback (covers JS-disabled clients). This is a read-only tap * on the woocommerce_checkout_posted_data FILTER. * * @param array $data Posted checkout data. * * @return array The data, returned UNCHANGED (checkout must never be altered). */ public static function capture_guest_phone( $data ) { try { if ( ! is_array( $data ) ) { return $data; // Pass through untouched — checkout must never be altered. } $settings = get_option( 'sixrsh_settings', array() ); if ( ! empty( $settings['abandoned_cart']['capture_guests'] ) && function_exists( 'WC' ) && WC()->session ) { // Opt-in comes from the dedicated checkout field (posted alongside data). // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce verifies its own checkout nonce; we only read. $opt_in = isset( $_POST['sixrsh_wa_opt_in'] ) ? '1' : '0'; WC()->session->set( 'sixrsh_opt_in', $opt_in ); $phone = $data['billing_phone'] ?? ''; if ( $phone ) { WC()->session->set( 'sixrsh_guest_phone', sanitize_text_field( $phone ) ); } $email = $data['billing_email'] ?? ''; if ( $email ) { WC()->session->set( 'sixrsh_guest_email', sanitize_email( $email ) ); } } } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Cart_Tracker::capture_guest_phone exception: ' . $e->getMessage() ); } // ALWAYS return the data unchanged — this is a read-only tap on a filter. return $data; } /** * When an order is created, close the matching cart via the API. * * Hooked to both the classic and the Store API (Block Checkout) order * hooks; the per-request guard keeps a store that fires both from closing twice. * * @param \WC_Order $order The completed order. * * @return void */ public static function on_order_complete( $order ): void { global $wpdb; static $closed = array(); try { if ( ! $order instanceof \WC_Order || isset( $closed[ $order->get_id() ] ) ) { return; } $closed[ $order->get_id() ] = true; $cart_token = self::get_cart_token(); if ( ! $cart_token ) { return; } $api = SixRsh_API::instance(); if ( $api ) { $api->close_cart( array( 'cart_token' => $cart_token ) ); } $table = $wpdb->prefix . 'sixrsh_carts'; // phpcs:disable WordPress.DB.DirectDatabaseQuery $wpdb->update( $table, array( 'status' => 'closed', 'synced_at' => current_time( 'mysql', true ), ), array( 'cart_token' => $cart_token ), array( '%s', '%s' ), array( '%s' ) ); // phpcs:enable } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Cart_Tracker::on_order_complete exception: ' . $e->getMessage() ); } } /** * Handle a cart recovery URL (?6rsh_recover=TOKEN). * * Restores the cart from the local DB and redirects to checkout. Items are * merged into whatever the shopper already has; a line already in the cart * is left alone so tapping the link twice doesn't double quantities. * * @return void */ public static function handle_recovery_url(): void { global $wpdb; try { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- recovery link, no form submission if ( ! isset( $_GET['6rsh_recover'] ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended $token = self::verify_recovery_param( sanitize_text_field( wp_unslash( $_GET['6rsh_recover'] ) ) ); if ( ! $token ) { // Unsigned (pre-1.10 link), tampered or expired: just go to checkout. wp_safe_redirect( remove_query_arg( '6rsh_recover' ) ); exit; } $table = $wpdb->prefix . 'sixrsh_carts'; // phpcs:disable WordPress.DB.DirectDatabaseQuery $row = $wpdb->get_row( // Restore any cart that hasn't been completed/closed. A successful sync // sets status='synced' (not 'open'), so filtering on 'open' alone meant a // tapped recovery link found nothing and never repopulated the cart. $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}sixrsh_carts WHERE cart_token = %s AND status NOT IN ( 'closed', 'recovered' )", $token ), ARRAY_A ); // phpcs:enable if ( ! $row ) { return; } // Restore cart items. self::ensure_cart_loaded(); if ( function_exists( 'WC' ) && WC()->cart ) { WC()->cart->get_cart(); // Make sure the session cart is loaded before merging. $items = json_decode( $row['items_json'], true ); if ( is_array( $items ) ) { foreach ( $items as $item ) { self::restore_item( WC()->cart, is_array( $item ) ? $item : array() ); } } // A shopper tapping the link in WhatsApp's in-app browser has no // session yet. WooCommerce only sets its cookies on a later hook, and // the exit below skips that, so the restored cart was lost. Set them now. if ( WC()->session && method_exists( WC()->session, 'set_customer_session_cookie' ) ) { WC()->session->set_customer_session_cookie( true ); } if ( isset( WC()->cart->session ) && method_exists( WC()->cart->session, 'maybe_set_cart_cookies' ) ) { WC()->cart->session->maybe_set_cart_cookies(); } } wp_safe_redirect( remove_query_arg( '6rsh_recover', wc_get_checkout_url() ) ); exit; } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Cart_Tracker::handle_recovery_url exception: ' . $e->getMessage() ); } } /** * Re-add one saved line to the cart, including its variation, unless the * same line is already there. * * @param \WC_Cart $cart Live cart. * @param array $item Saved line from items_json. * * @return void */ public static function restore_item( $cart, array $item ): void { $pid = (int) ( $item['product_id'] ?? 0 ); $qty = (int) ( $item['qty'] ?? 1 ); $vid = (int) ( $item['variation_id'] ?? 0 ); $variation = is_array( $item['variation'] ?? null ) ? array_map( 'strval', $item['variation'] ) : array(); if ( ! $pid || $qty < 1 ) { return; } if ( $cart->find_product_in_cart( $cart->generate_cart_id( $pid, $vid, $variation ) ) ) { return; } $cart->add_to_cart( $pid, $qty, $vid, $variation ); } }