api_client = $api_client; $this->settings = $settings; $this->init_hooks(); } /** * Check if cart tracking is enabled * * @return bool True if cart tracking is enabled. */ private function is_cart_tracking_enabled() { $settings = ActiveTrail_Settings::get_all(); return isset( $settings['enable_cart_tracking'] ) && $settings['enable_cart_tracking']; } /** * Handle cart updated event (legacy/unused). * Must not be hooked for cart add/remove debounce flow: debounce is timer-only and does not use this throttle. */ public function handle_cart_updated() { // Check if cart tracking is enabled. if ( ! $this->is_cart_tracking_enabled() ) { return; } // Check if we should throttle cart updates. $minutes = (int) ActiveTrail_Settings::get( 'cart_event_interval_minutes', 15 ); $key = 'activetrail_cart_last_sent_' . ( WC()->session ? WC()->session->get_customer_id() : get_current_user_id() ); $last = (int) get_transient( $key ); if ( $last && ( time() - $last ) < ( $minutes * 60 ) ) { // Don't send now, too soon since last update. $this->log( 'Cart update throttled - last update was ' . human_time_diff( $last, time() ) . ' ago' ); return; } // Get cart data. $cart_data = $this->prepare_cart_data(); if ( empty( $cart_data ) ) { return; } // Send cart data to ActiveTrail. $this->sync_cart( $cart_data ); // Set transient to track last sent time. set_transient( $key, time(), $minutes * 60 ); } /** * Initialize WordPress hooks */ private function init_hooks() { // Cart hooks. add_action( 'woocommerce_add_to_cart', array( $this, 'handle_add_to_cart' ), 10, 6 ); add_action( 'woocommerce_cart_item_removed', array( $this, 'handle_cart_item_removed' ), 10, 2 ); add_action( 'woocommerce_cart_item_restored', array( $this, 'handle_cart_item_restored' ), 10, 2 ); add_action( 'woocommerce_after_cart_item_quantity_update', array( $this, 'handle_cart_quantity_update' ), 10, 4 ); // Session hooks. add_action( 'wp_loaded', array( $this, 'track_cart_session' ) ); add_action( 'wp_logout', array( $this, 'clear_cart_tracking' ) ); // Checkout hooks to capture email - use checkout_process which fires earlier, and prevent duplicates. add_action( 'woocommerce_checkout_process', array( $this, 'capture_checkout_email' ) ); // Only use update_order_meta as fallback if checkout_process didn't fire. add_action( 'woocommerce_checkout_update_order_meta', array( $this, 'handle_checkout_email' ), 5 ); // AJAX hooks for cart updates. add_action( 'wp_ajax_activetrail_wc_add_to_cart', array( $this, 'handle_ajax_add_to_cart' ), 5 ); add_action( 'wp_ajax_nopriv_activetrail_wc_add_to_cart', array( $this, 'handle_ajax_add_to_cart' ), 5 ); // Scheduled event for abandoned cart sync. add_action( 'activetrail_sync_abandoned_carts', array( $this, 'sync_abandoned_carts' ) ); // Debounced cart sync event. add_action( 'activetrail_debounced_cart_sync', array( $this, 'handle_debounced_cart_sync' ) ); } /** * Handle add to cart * * @param string $cart_item_key Cart item key. * @param int $product_id Product ID. * @param int $quantity Quantity. * @param int $variation_id Variation ID. * @param array $variation Variation data. * @param array $cart_item_data Cart item data. */ public function handle_add_to_cart( $cart_item_key, $product_id, $quantity, $variation_id, $variation, $cart_item_data ) { // Check if cart tracking is enabled. if ( ! $this->is_cart_tracking_enabled() ) { return; } // Track cart update. $this->track_cart_update(); } /** * Handle cart item removed * * @param string $cart_item_key Cart item key. * @param WC_Cart $cart Cart object. */ public function handle_cart_item_removed( $cart_item_key, $cart ) { // Check if cart tracking is enabled. if ( ! $this->is_cart_tracking_enabled() ) { return; } // Track cart update. $this->track_cart_update(); } /** * Handle cart item restored * * @param string $cart_item_key Cart item key. * @param WC_Cart $cart Cart object. */ public function handle_cart_item_restored( $cart_item_key, $cart ) { // Check if cart tracking is enabled. if ( ! $this->is_cart_tracking_enabled() ) { return; } // Track cart update. $this->track_cart_update(); } /** * Handle cart quantity update * * @param string $cart_item_key Cart item key. * @param int $quantity New quantity. * @param int $old_quantity Old quantity. * @param WC_Cart $cart Cart object. */ public function handle_cart_quantity_update( $cart_item_key, $quantity, $old_quantity, $cart ) { // Check if cart tracking is enabled. if ( ! $this->is_cart_tracking_enabled() ) { return; } // Track cart update. $this->track_cart_update(); } /** * Handle AJAX add to cart */ public function handle_ajax_add_to_cart() { // This runs before the actual add to cart, so we'll track after. add_action( 'woocommerce_ajax_added_to_cart', array( $this, 'handle_ajax_cart_update' ) ); } /** * Handle AJAX cart update * * @param int $product_id Product ID. */ public function handle_ajax_cart_update( $product_id ) { // Check if cart tracking is enabled. if ( ! $this->is_cart_tracking_enabled() ) { return; } // Track cart update. $this->track_cart_update(); } /** * Handle checkout email capture (fallback if checkout_process didn't fire) * * @param int $order_id Order ID. */ public function handle_checkout_email( $order_id ) { // Check if we already synced during checkout_process to avoid duplicates. if ( WC()->session && WC()->session->get( 'activetrail_checkout_synced' ) ) { return; } $order = wc_get_order( $order_id ); if ( $order ) { $email = $order->get_billing_email(); if ( ! empty( $email ) && WC()->session ) { WC()->session->set( 'checkout_email', $email ); // Mark as synced to prevent duplicate events. WC()->session->set( 'activetrail_checkout_synced', true ); // Trigger cart sync with the captured email. $this->trigger_cart_sync_with_email( $email ); } } } /** * Capture checkout email during checkout process */ public function capture_checkout_email() { // Prevent duplicate syncs. if ( WC()->session && WC()->session->get( 'activetrail_checkout_synced' ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce checkout form handles nonce verification if ( isset( $_POST['billing_email'] ) && ! empty( $_POST['billing_email'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce checkout form handles nonce verification $email = sanitize_email( wp_unslash( $_POST['billing_email'] ) ); if ( WC()->session ) { WC()->session->set( 'checkout_email', $email ); // Mark as synced to prevent duplicate events. WC()->session->set( 'activetrail_checkout_synced', true ); // Trigger cart sync with the captured email. $this->trigger_cart_sync_with_email( $email ); } } } /** * Trigger cart sync with captured email * * @param string $email Customer email. */ private function trigger_cart_sync_with_email( $email ) { // Check if cart tracking is enabled. if ( ! $this->is_cart_tracking_enabled() ) { return; } if ( ! WC()->cart || WC()->cart->is_empty() ) { return; } $session_id = $this->get_session_id(); if ( empty( $session_id ) ) { return; } // Update cart data with the email. $cart_data = $this->prepare_cart_data(); $cart_data['customer_email'] = $email; // Store updated cart data. $this->store_cart_data( $session_id, $cart_data ); // Sync to ActiveTrail ללא ת׳רוטלינג גלובלי – שינוי בצ'קאאוט הוא גם שינוי עגלה לגיטימי. $result = $this->sync_cart( $cart_data ); if ( is_array( $result ) && ! empty( $result['success'] ) ) { $this->update_last_cart_sync_time( $session_id ); } } /** * Track cart session */ public function track_cart_session() { if ( ! WC()->session ) { return; } // Get or create session ID. $session_id = WC()->session->get_customer_id(); if ( empty( $session_id ) ) { return; } // Store session for tracking. WC()->session->set( 'activetrail_session_id', $session_id ); } /** * Clear cart tracking on logout * Also clears session-based throttling data. */ public function clear_cart_tracking() { if ( WC()->session ) { WC()->session->set( 'activetrail_session_id', null ); WC()->session->set( 'activetrail_cart_last_hash', null ); WC()->session->set( 'activetrail_cart_last_sync_time', null ); } } /** * Track cart update */ private function track_cart_update() { // Check if cart tracking is enabled. if ( ! $this->is_cart_tracking_enabled() ) { $this->log( 'track_cart_update skipped - cart tracking disabled', 'debug' ); return; } if ( ! WC()->cart || WC()->cart->is_empty() ) { $this->log( 'track_cart_update called but cart is empty, skipping', 'debug' ); return; } if ( method_exists( WC()->cart, 'calculate_totals' ) ) { WC()->cart->calculate_totals(); } $session_id = $this->get_session_id(); if ( empty( $session_id ) ) { $this->log( 'track_cart_update called but session_id missing, skipping', 'debug' ); return; } // Debounce is timer-only: we do not check "last sync" or throttle here; every change schedules a new 10s event. // Capture the time of this specific change - used to create a distinct // snapshot and a distinct debounced event for this change. $snapshot_time = time(); $cart_data = $this->prepare_cart_data(); // Only log cart data in debug mode if explicitly needed (too verbose for normal operation). // $this->log( 'track_cart_update called with cart data: ' . wp_json_encode( $cart_data ), 'debug' ); // Store a cart snapshot for this specific change. We keep one snapshot per change, // keyed by its timestamp, so שכל שינוי יקבל איוונט נפרד אחרי 10 שניות. $this->store_cart_snapshot_for_debounce( $session_id, $cart_data, $snapshot_time ); // Store cart_data in a transient so cron can load it without relying on cron args // (WordPress cron often drops or truncates large 3rd argument when serializing). $transient_key = 'activetrail_debounced_cart_' . $session_id . '_' . $snapshot_time; set_transient( $transient_key, $cart_data, 15 * MINUTE_IN_SECONDS ); // Schedule with only 2 args so cron storage never drops payload; callback loads from transient. wp_schedule_single_event( $snapshot_time + 10, 'activetrail_debounced_cart_sync', array( $session_id, $snapshot_time ) ); } /** * Store cart snapshot for debounced sync * * @param string $session_id Session ID. * @param array $cart_data Cart data. * @param int $timestamp Snapshot timestamp (per change). */ private function store_cart_snapshot_for_debounce( $session_id, $cart_data, $timestamp ) { $user_id = get_current_user_id(); if ( $user_id > 0 ) { // Logged-in user: store a list of snapshots in user_meta, keyed by timestamp. $meta_key = '_activetrail_cart_snapshot_' . $session_id; $snapshots = get_user_meta( $user_id, $meta_key, true ); if ( ! is_array( $snapshots ) ) { $snapshots = array(); } $snapshots[ $timestamp ] = array( 'cart_data' => $cart_data, 'timestamp' => $timestamp, ); update_user_meta( $user_id, $meta_key, $snapshots ); } else { // Guest user: store a list of snapshots in WooCommerce session. if ( WC()->session ) { $snapshots = WC()->session->get( 'activetrail_cart_snapshots' ); if ( ! is_array( $snapshots ) ) { $snapshots = array(); } $snapshots[ $timestamp ] = array( 'cart_data' => $cart_data, 'timestamp' => $timestamp, ); WC()->session->set( 'activetrail_cart_snapshots', $snapshots ); } } // Also store in database for abandoned cart tracking. $this->store_cart_data( $session_id, $cart_data ); } /** * Handle debounced cart sync (called by WP-Cron after 10 seconds) * * @param string $session_id Session ID. * @param int|array $snapshot_time_or_data Snapshot timestamp (per change) OR cart_data array (newer scheduler). * @param array|null $maybe_cart_data Optional cart_data when scheduled with three args. */ public function handle_debounced_cart_sync( $session_id, $snapshot_time_or_data = 0, $maybe_cart_data = null ) { // Check if cart tracking is enabled. if ( ! $this->is_cart_tracking_enabled() ) { $this->log( 'Debounced cart sync skipped - cart tracking disabled', 'debug' ); return; } $snapshot_time = is_numeric( $snapshot_time_or_data ) ? (int) $snapshot_time_or_data : 0; // Resolve cart_data: (1) 3rd arg, (2) transient (we always schedule with 2 args + transient now), (3) 2nd arg as array (legacy), (4) user_meta/session/DB below. $cart_data = null; if ( func_num_args() >= 3 && is_array( $maybe_cart_data ) && ! empty( $maybe_cart_data ) ) { $cart_data = $maybe_cart_data; $this->log( 'Debounced cart sync using direct cart_data payload (3rd arg) for session: ' . $session_id, 'debug' ); } if ( $cart_data === null && $snapshot_time > 0 ) { $transient_key = 'activetrail_debounced_cart_' . $session_id . '_' . $snapshot_time; $from_transient = get_transient( $transient_key ); if ( is_array( $from_transient ) && ! empty( $from_transient ) ) { $cart_data = $from_transient; delete_transient( $transient_key ); $this->log( 'Debounced cart sync using transient payload for session: ' . $session_id, 'debug' ); } } if ( $cart_data === null && is_array( $snapshot_time_or_data ) && ! empty( $snapshot_time_or_data ) ) { $cart_data = $snapshot_time_or_data; $this->log( 'Debounced cart sync using direct cart_data payload (2nd arg) for session: ' . $session_id, 'debug' ); } // If we already have cart data from the event payload, we can skip reading from session/user_meta. if ( is_array( $cart_data ) && ! empty( $cart_data ) ) { // Always require a valid email to sync cart - don't create placeholder contacts. if ( empty( $cart_data['customer_email'] ) || strpos( $cart_data['customer_email'], '@cart.placeholder' ) !== false ) { $this->log( 'Debounced cart sync (direct payload) skipped - valid customer email is required', 'debug' ); return; } // When the timer fires we always send this snapshot; we do not skip based on last sync time. $this->log( 'Sending debounced cart sync (direct payload) for session: ' . $session_id, 'info' ); $result = $this->sync_cart( $cart_data ); if ( is_array( $result ) && ! empty( $result['success'] ) ) { $this->update_last_cart_sync_time( $session_id ); $this->log( 'Debounced cart sync (direct payload) successful for session: ' . $session_id, 'info' ); } else { $this->log( 'Debounced cart sync (direct payload) failed for session: ' . $session_id . ' with result: ' . wp_json_encode( $result ), 'error' ); } return; } // Backward‑compatible path: no cart_data from payload/transient, try user/session snapshots then DB. // In cron there is no logged-in user; for logged-in users session_id is the user ID, so use it for user_meta lookup. $user_id_for_snapshot = ( get_current_user_id() > 0 ) ? get_current_user_id() : ( is_numeric( $session_id ) ? (int) $session_id : 0 ); $snapshots = null; // Retrieve stored cart snapshots collection. if ( $user_id_for_snapshot > 0 ) { // Logged-in user (or cron with numeric session_id = user ID): get from user_meta. $snapshots = get_user_meta( $user_id_for_snapshot, '_activetrail_cart_snapshot_' . $session_id, true ); } else { // Guest user: get from WooCommerce session (null in cron). if ( WC()->session ) { $snapshots = WC()->session->get( 'activetrail_cart_snapshots' ); } } if ( empty( $snapshots ) || ! is_array( $snapshots ) ) { // Third fallback: load cart from database by session_id. global $wpdb; $table_name = $wpdb->prefix . 'activetrail_cart_tracking'; $table = esc_sql( $table_name ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $cart_record = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE session_id = %s ORDER BY updated_at DESC LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is whitelisted (prefix + literal). $session_id ) ); if ( $cart_record && ! empty( $cart_record->cart_data ) ) { $cart_data = json_decode( $cart_record->cart_data, true ); if ( is_array( $cart_data ) ) { $cart_data = activetrail_wc_sanitize_array( $cart_data ); } if ( ! isset( $cart_data['session_id'] ) || empty( $cart_data['session_id'] ) ) { $cart_data['session_id'] = $cart_record->session_id; } if ( ! isset( $cart_data['customer_email'] ) || empty( $cart_data['customer_email'] ) ) { $cart_data['customer_email'] = $cart_record->email; } if ( ! empty( $cart_data['items'] ) && ! empty( $cart_data['customer_email'] ) && strpos( $cart_data['customer_email'], '@cart.placeholder' ) === false ) { $this->log( 'Debounced cart sync using DB fallback for session: ' . $session_id, 'debug' ); $result = $this->sync_cart( $cart_data ); if ( is_array( $result ) && ! empty( $result['success'] ) ) { $this->update_last_cart_sync_time( $session_id ); $this->log( 'Debounced cart sync (DB fallback) successful for session: ' . $session_id, 'info' ); } else { $this->log( 'Debounced cart sync (DB fallback) failed for session: ' . $session_id . ' with result: ' . wp_json_encode( $result ), 'error' ); } return; } } $this->log( 'Debounced cart sync skipped - no snapshots found for session: ' . $session_id, 'debug' ); return; } // Pick the snapshot that matches the timestamp we were scheduled with. // If none provided (backward compatibility), fall back to the latest snapshot. if ( $snapshot_time && isset( $snapshots[ $snapshot_time ] ) ) { $snapshot = $snapshots[ $snapshot_time ]; } else { // Backward compatible path: use the most recent snapshot. $latest = end( $snapshots ); $snapshot = $latest; $snapshot_time = isset( $latest['timestamp'] ) ? (int) $latest['timestamp'] : 0; } $cart_data = isset( $snapshot['cart_data'] ) ? $snapshot['cart_data'] : null; if ( empty( $cart_data ) ) { $this->log( 'Debounced cart sync skipped - empty cart data for session: ' . $session_id, 'debug' ); return; } // Always require a valid email to sync cart - don't create placeholder contacts. if ( empty( $cart_data['customer_email'] ) || strpos( $cart_data['customer_email'], '@cart.placeholder' ) !== false ) { $this->log( 'Debounced cart sync skipped - valid customer email is required', 'debug' ); return; } $this->log( 'Sending debounced cart sync for session: ' . $session_id, 'info' ); $result = $this->sync_cart( $cart_data ); if ( is_array( $result ) && ! empty( $result['success'] ) ) { $this->update_last_cart_sync_time( $session_id ); $this->log( 'Debounced cart sync successful for session: ' . $session_id, 'info' ); // Clear this specific snapshot after successful sync. if ( isset( $snapshots[ $snapshot_time ] ) ) { unset( $snapshots[ $snapshot_time ] ); } if ( $user_id_for_snapshot > 0 ) { if ( empty( $snapshots ) ) { delete_user_meta( $user_id_for_snapshot, '_activetrail_cart_snapshot_' . $session_id ); } else { update_user_meta( $user_id_for_snapshot, '_activetrail_cart_snapshot_' . $session_id, $snapshots ); } } else { if ( WC()->session ) { if ( empty( $snapshots ) ) { WC()->session->set( 'activetrail_cart_snapshots', null ); } else { WC()->session->set( 'activetrail_cart_snapshots', $snapshots ); } } } } else { $this->log( 'Debounced cart sync failed for session: ' . $session_id . ' with result: ' . wp_json_encode( $result ), 'error' ); } } /** * Check if cart update should be throttled. * Must not be used for debounced cart sync (timer-only): when the debounce timer fires we always send. * Throttling is session-based: only blocks duplicate snapshots within the same browser session. * If browser is closed and reopened, it's considered a new session. * * @param string $session_id Session ID. * @param array $cart_data Cart snapshot data. * @return bool */ private function should_throttle_cart_update( $session_id, $cart_data ) { if ( empty( $session_id ) ) { return false; } // Use WooCommerce session data instead of transients - this ensures data is cleared when session ends. if ( ! WC()->session ) { return false; } // Calculate current cart hash. $items_signature = array(); if ( ! empty( $cart_data['items'] ) ) { foreach ( $cart_data['items'] as $item ) { $product_id = isset( $item['product_id'] ) ? $item['product_id'] : 0; $variation_id = isset( $item['variation_id'] ) ? $item['variation_id'] : 0; $quantity = isset( $item['quantity'] ) ? $item['quantity'] : 0; $line_total = isset( $item['line_total'] ) ? $item['line_total'] : 0; $items_signature[] = $product_id . ':' . $variation_id . ':' . $quantity . ':' . $line_total; } sort( $items_signature ); } $current_hash = md5( wp_json_encode( array( 'items' => $items_signature, 'total' => isset( $cart_data['cart_total'] ) ? $cart_data['cart_total'] : 0, 'count' => isset( $cart_data['cart_count'] ) ? $cart_data['cart_count'] : 0, 'subtotal' => isset( $cart_data['cart_subtotal'] ) ? $cart_data['cart_subtotal'] : 0, 'tax' => isset( $cart_data['cart_tax'] ) ? $cart_data['cart_tax'] : 0, 'email' => isset( $cart_data['customer_email'] ) ? $cart_data['customer_email'] : '', // Include email in hash to allow updates when email changes. ) ) ); // Get last hash from session (not transient - this ensures it's cleared when session ends). $last_hash = WC()->session->get( 'activetrail_cart_last_hash' ); $last_sync = WC()->session->get( 'activetrail_cart_last_sync_time' ); // If the cart snapshot changed (including email), allow the update and store the new hash. if ( $last_hash !== $current_hash ) { WC()->session->set( 'activetrail_cart_last_hash', $current_hash ); return false; } // If snapshot is identical, only block if we synced very recently (within last 60 seconds). // This prevents immediate duplicate sends within the same session. if ( $last_sync && ( time() - $last_sync ) < 60 ) { $this->log( 'Cart update throttled - identical snapshot synced ' . ( time() - $last_sync ) . ' seconds ago for session: ' . $session_id, 'debug' ); return true; } // Allow update if cart changed or enough time passed (even if hash is same, for periodic sync within session). return false; } /** * Update last cart sync time * Store in session (not transient) so it's cleared when session ends. * * @param string $session_id Session ID. */ private function update_last_cart_sync_time( $session_id ) { // Store in WooCommerce session instead of transient. // This ensures data is cleared when browser session ends. if ( WC()->session ) { WC()->session->set( 'activetrail_cart_last_sync_time', time() ); } } /** * Get session ID * * @return string */ private function get_session_id() { if ( ! WC()->session ) { return ''; } $session_id = WC()->session->get( 'activetrail_session_id' ); if ( empty( $session_id ) ) { $session_id = WC()->session->get_customer_id(); WC()->session->set( 'activetrail_session_id', $session_id ); } return $session_id; } /** * Get customer email * * @return string */ private function get_customer_email() { $email = ''; // Try to get from current user. if ( is_user_logged_in() ) { $user = wp_get_current_user(); $email = $user->user_email; } // Try to get from session/checkout. if ( empty( $email ) && WC()->customer ) { $email = WC()->customer->get_billing_email(); } // Try to get from checkout session data. if ( empty( $email ) && WC()->session ) { $email = WC()->session->get( 'checkout_email' ); } // Try to get from POST data (if form was submitted). // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce checkout form handles nonce verification if ( empty( $email ) && isset( $_POST['billing_email'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce checkout form handles nonce verification $email = sanitize_email( wp_unslash( $_POST['billing_email'] ) ); } return $email; } /** * Get customer first name and last name * * @param string $cart_email Optional cart email to verify against logged-in user email. * @return array Array with 'first_name' and 'last_name' keys */ private function get_customer_name( $cart_email = '' ) { $first_name = ''; $last_name = ''; // Try to get from current user - but only if email matches (to avoid mixing data from different users). if ( is_user_logged_in() ) { $user = wp_get_current_user(); $user_email = strtolower( trim( $user->user_email ) ); // Only use logged-in user data if email matches cart email (or cart email not provided). if ( empty( $cart_email ) || $user_email === strtolower( trim( $cart_email ) ) ) { $first_name = $user->first_name; $last_name = $user->last_name; } else { $this->log( 'Skipping logged-in user data - email mismatch: user=' . $user_email . ', cart=' . $cart_email, 'debug' ); } } // Try to get from WooCommerce customer object (billing info). if ( ( empty( $first_name ) || empty( $last_name ) ) && WC()->customer ) { $billing_first_name = WC()->customer->get_billing_first_name(); $billing_last_name = WC()->customer->get_billing_last_name(); if ( ! empty( $billing_first_name ) ) { $first_name = $billing_first_name; } if ( ! empty( $billing_last_name ) ) { $last_name = $billing_last_name; } } // Try to get from checkout session data. if ( ( empty( $first_name ) || empty( $last_name ) ) && WC()->session ) { $session_first_name = WC()->session->get( 'billing_first_name' ); $session_last_name = WC()->session->get( 'billing_last_name' ); if ( ! empty( $session_first_name ) ) { $first_name = $session_first_name; } if ( ! empty( $session_last_name ) ) { $last_name = $session_last_name; } } // Try to get from POST data (if form was submitted). // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce checkout form handles nonce verification if ( ( empty( $first_name ) || empty( $last_name ) ) && isset( $_POST['billing_first_name'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce checkout form handles nonce verification $first_name = sanitize_text_field( wp_unslash( $_POST['billing_first_name'] ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce checkout form handles nonce verification if ( empty( $last_name ) && isset( $_POST['billing_last_name'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce checkout form handles nonce verification $last_name = sanitize_text_field( wp_unslash( $_POST['billing_last_name'] ) ); } return array( 'first_name' => $first_name, 'last_name' => $last_name, ); } /** * Get customer phone number * * @param string $cart_email Optional cart email to verify against logged-in user email. * @return string */ private function get_customer_phone( $cart_email = '' ) { $phone = ''; // Try to get from current user - but only if email matches (to avoid mixing data from different users). if ( is_user_logged_in() ) { $user = wp_get_current_user(); $user_email = strtolower( trim( $user->user_email ) ); // Only use logged-in user data if email matches cart email (or cart email not provided). if ( empty( $cart_email ) || $user_email === strtolower( trim( $cart_email ) ) ) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for user data $phone = get_user_meta( $user->ID, 'billing_phone', true ); } } // Try to get from WooCommerce customer object (billing info). if ( empty( $phone ) && WC()->customer ) { $phone = WC()->customer->get_billing_phone(); } // Try to get from checkout session data. if ( empty( $phone ) && WC()->session ) { $phone = WC()->session->get( 'billing_phone' ); } // Try to get from POST data (if form was submitted). // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce checkout form handles nonce verification if ( empty( $phone ) && isset( $_POST['billing_phone'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce checkout form handles nonce verification $phone = sanitize_text_field( wp_unslash( $_POST['billing_phone'] ) ); } return $phone; } /** * Get customer address * * @param string $cart_email Optional cart email to verify against logged-in user email. * @return string */ private function get_customer_address( $cart_email = '' ) { $address_1 = ''; $address_2 = ''; // Try to get from current user - but only if email matches (to avoid mixing data from different users). if ( is_user_logged_in() ) { $user = wp_get_current_user(); $user_email = strtolower( trim( $user->user_email ) ); // Only use logged-in user data if email matches cart email (or cart email not provided). if ( empty( $cart_email ) || $user_email === strtolower( trim( $cart_email ) ) ) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for user data $address_1 = get_user_meta( $user->ID, 'billing_address_1', true ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for user data $address_2 = get_user_meta( $user->ID, 'billing_address_2', true ); } } // Try to get from WooCommerce customer object (billing info). if ( ( empty( $address_1 ) || empty( $address_2 ) ) && WC()->customer ) { $billing_address_1 = WC()->customer->get_billing_address_1(); $billing_address_2 = WC()->customer->get_billing_address_2(); if ( ! empty( $billing_address_1 ) ) { $address_1 = $billing_address_1; } if ( ! empty( $billing_address_2 ) ) { $address_2 = $billing_address_2; } } // Try to get from checkout session data. if ( ( empty( $address_1 ) || empty( $address_2 ) ) && WC()->session ) { $session_address_1 = WC()->session->get( 'billing_address_1' ); $session_address_2 = WC()->session->get( 'billing_address_2' ); if ( ! empty( $session_address_1 ) ) { $address_1 = $session_address_1; } if ( ! empty( $session_address_2 ) ) { $address_2 = $session_address_2; } } // Try to get from POST data (if form was submitted). // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce checkout form handles nonce verification if ( empty( $address_1 ) && isset( $_POST['billing_address_1'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce checkout form handles nonce verification $address_1 = sanitize_text_field( wp_unslash( $_POST['billing_address_1'] ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce checkout form handles nonce verification if ( empty( $address_2 ) && isset( $_POST['billing_address_2'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce checkout form handles nonce verification $address_2 = sanitize_text_field( wp_unslash( $_POST['billing_address_2'] ) ); } return trim( $address_1 . ' ' . $address_2 ); } /** * Get customer city * * @param string $cart_email Optional cart email to verify against logged-in user email. * @return string */ private function get_customer_city( $cart_email = '' ) { $city = ''; // Try to get from current user - but only if email matches (to avoid mixing data from different users). if ( is_user_logged_in() ) { $user = wp_get_current_user(); $user_email = strtolower( trim( $user->user_email ) ); // Only use logged-in user data if email matches cart email (or cart email not provided). if ( empty( $cart_email ) || $user_email === strtolower( trim( $cart_email ) ) ) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for user data $city = get_user_meta( $user->ID, 'billing_city', true ); } } // Try to get from WooCommerce customer object (billing info). if ( empty( $city ) && WC()->customer ) { $city = WC()->customer->get_billing_city(); } // Try to get from checkout session data. if ( empty( $city ) && WC()->session ) { $city = WC()->session->get( 'billing_city' ); } // Try to get from POST data (if form was submitted). // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce checkout form handles nonce verification if ( empty( $city ) && isset( $_POST['billing_city'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce checkout form handles nonce verification $city = sanitize_text_field( wp_unslash( $_POST['billing_city'] ) ); } return $city; } /** * Get customer zip code / postcode * * @param string $cart_email Optional cart email to verify against logged-in user email. * @return string */ private function get_customer_zip_code( $cart_email = '' ) { $zip_code = ''; // Try to get from current user - but only if email matches (to avoid mixing data from different users). if ( is_user_logged_in() ) { $user = wp_get_current_user(); $user_email = strtolower( trim( $user->user_email ) ); // Only use logged-in user data if email matches cart email (or cart email not provided). if ( empty( $cart_email ) || $user_email === strtolower( trim( $cart_email ) ) ) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for user data $zip_code = get_user_meta( $user->ID, 'billing_postcode', true ); } } // Try to get from WooCommerce customer object (billing info). if ( empty( $zip_code ) && WC()->customer ) { $zip_code = WC()->customer->get_billing_postcode(); } // Try to get from checkout session data. if ( empty( $zip_code ) && WC()->session ) { $zip_code = WC()->session->get( 'billing_postcode' ); } // Try to get from POST data (if form was submitted). // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce checkout form handles nonce verification if ( empty( $zip_code ) && isset( $_POST['billing_postcode'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce checkout form handles nonce verification $zip_code = sanitize_text_field( wp_unslash( $_POST['billing_postcode'] ) ); } return $zip_code; } /** * Prepare cart data for API * * @return array */ private function prepare_cart_data() { $cart = WC()->cart; if ( $cart && method_exists( $cart, 'calculate_totals' ) ) { $cart->calculate_totals(); } $items = array(); foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) { $product = $cart_item['data']; $items[] = array( 'product_id' => $cart_item['product_id'], 'variation_id' => $cart_item['variation_id'], 'sku' => $product->get_sku(), 'name' => $product->get_name(), 'quantity' => $cart_item['quantity'], 'price' => $product->get_price(), 'line_total' => $cart_item['line_total'], 'image_url' => wp_get_attachment_image_url( $product->get_image_id(), 'medium' ), 'product_url' => get_permalink( $cart_item['product_id'] ), 'categories' => $this->get_product_categories( $product ), ); } // Tracking consent (CNIL) - cart tracking is registered-users-only, so read it // from the current user's meta. Always carried as a boolean. $tracking_consent = false; $cart_user_id = get_current_user_id(); if ( $cart_user_id ) { $tracking_consent = ( get_user_meta( $cart_user_id, '_activetrail_tracking_consent', true ) === 'yes' ); } $data = array( 'session_id' => $this->get_session_id(), 'customer_email' => $this->get_customer_email(), 'cart_total' => $cart->get_cart_contents_total(), 'cart_tax' => $cart->get_cart_contents_tax(), 'cart_subtotal' => $cart->get_subtotal(), 'cart_count' => $cart->get_cart_contents_count(), 'currency' => get_woocommerce_currency(), 'items' => $items, 'group_id' => ! empty( $this->settings['group_id'] ) ? intval( $this->settings['group_id'] ) : null, 'tracking_consent' => $tracking_consent, 'created_at' => current_time( 'mysql' ), 'updated_at' => current_time( 'mysql' ), ); return apply_filters( 'activetrail_cart_data', $data ); } /** * Store cart data in database * * @param string $session_id Session ID. * @param array $cart_data Cart data. */ private function store_cart_data( $session_id, $cart_data ) { global $wpdb; $table_name = $wpdb->prefix . 'activetrail_cart_tracking'; $table = esc_sql( $table_name ); // Check if record exists. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Direct query needed for cart tracking, table name is escaped // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for cart tracking $existing = $wpdb->get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is escaped with esc_sql() "SELECT id FROM {$table} WHERE session_id = %s", $session_id ) ); $data = array( 'session_id' => $session_id, 'user_id' => get_current_user_id() ?: null, 'cart_data' => wp_json_encode( $cart_data ), 'email' => $cart_data['customer_email'], 'updated_at' => current_time( 'mysql' ), ); if ( $existing ) { // Update existing record. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for cart tracking $result = $wpdb->update( $table_name, $data, array( 'session_id' => $session_id ), array( '%s', '%d', '%s', '%s', '%s' ), array( '%s' ) ); } else { // Insert new record. $data['created_at'] = current_time( 'mysql' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for cart tracking $result = $wpdb->insert( $table_name, $data, array( '%s', '%d', '%s', '%s', '%s', '%s' ) ); } if ( $result === false ) { $this->log( 'Database error storing cart data: ' . $wpdb->last_error, 'error' ); } else { $this->log( 'Cart data stored successfully: ' . $session_id, 'info' ); } } /** * Sync cart to ActiveTrail using the proper Cart API endpoint * * @param array $cart_data Cart data. * @return array */ public function sync_cart( $cart_data ) { // CRITICAL: Check if cart tracking is enabled (defense in depth). if ( ! $this->is_cart_tracking_enabled() ) { $this->log( 'sync_cart called but cart tracking is disabled - skipping', 'debug' ); return array( 'success' => false, 'message' => 'Cart tracking is disabled', ); } if ( empty( $this->settings['api_key'] ) ) { $this->log( 'API key not configured for cart sync', 'error' ); return array( 'success' => false, 'message' => 'API key not configured', ); } // Inject group_id into cart_data before transforming to ActiveTrail format. if ( ! empty( $this->settings['group_id'] ) && ( empty( $cart_data['group_id'] ) || $cart_data['group_id'] === null ) ) { $cart_data['group_id'] = intval( $this->settings['group_id'] ); $this->log( 'Injected group_id into cart_data for sync_cart: ' . $cart_data['group_id'], 'debug' ); } // Transform cart data to ActiveTrail format. $activetrail_cart_data = $this->transform_cart_to_activetrail_format( $cart_data ); // Validate email presence after transformation (in case overridden). if ( empty( $activetrail_cart_data['email'] ) ) { $this->log( 'Failed to sync cart: Customer email is required after transformation', 'error' ); return array( 'success' => false, 'message' => 'Customer email is required', ); } // Do not send Cart when no valid externalId (e.g. all contacts Blocked or debounce without session-stored id). if ( empty( $activetrail_cart_data['externalId'] ) ) { $this->log( 'Skip cart sync - no valid externalId (contact may be Blocked or no existing contact for email)', 'debug' ); return array( 'success' => false, 'message' => 'No valid externalId for cart sync', ); } $result = $this->api_client->submit_cart( $activetrail_cart_data ); if ( is_array( $result ) && isset( $result['success'] ) && $result['success'] ) { // Mark cart as synced. $this->mark_cart_synced( $cart_data['session_id'] ); $this->log( 'Cart synced successfully: ' . $cart_data['session_id'], 'info' ); return array( 'success' => true, 'message' => 'Cart synced successfully', ); } else { $error_message = is_array( $result ) && isset( $result['message'] ) ? $result['message'] : 'Unknown error'; $this->log( 'Failed to sync cart ' . $cart_data['session_id'] . ': ' . $error_message, 'error' ); return array( 'success' => false, 'message' => $error_message, ); } } /** * Transform cart data to ActiveTrail API format * * @param array $cart_data Internal cart data. * @return array ActiveTrail formatted cart data */ private function transform_cart_to_activetrail_format( $cart_data ) { $settings = $this->settings; // Get applied coupons from the cart, check if cart exists to avoid null error. $applied_coupons = array(); if ( WC()->cart ) { $applied_coupons = WC()->cart->get_applied_coupons(); } else { $this->log( 'Warning: WC()->cart is null in transform_cart_to_activetrail_format', 'warning' ); } $coupon_details = array(); // Get details of each applied coupon. foreach ( $applied_coupons as $coupon_code ) { $coupon = new WC_Coupon( $coupon_code ); $coupon_details[ $coupon_code ] = array( 'code' => $coupon_code, 'type' => $coupon->get_discount_type(), 'product_ids' => $coupon->get_product_ids(), 'excluded_product_ids' => $coupon->get_excluded_product_ids(), 'product_categories' => $coupon->get_product_categories(), 'excluded_product_categories' => $coupon->get_excluded_product_categories(), 'amount' => $coupon->get_amount(), ); } // Determine if there's an order-level coupon. $has_order_level_coupon = false; foreach ( $coupon_details as $coupon_data ) { if ( 'fixed_cart' === $coupon_data['type'] ) { $has_order_level_coupon = true; break; } } // Prepare products array according to ActiveTrail Cart API format. $products = array(); if ( ! empty( $cart_data['items'] ) ) { foreach ( $cart_data['items'] as $item ) { // Determine which product-specific coupons apply to this product. $product_coupon_codes = array(); if ( ! $has_order_level_coupon ) { // Only check for product coupons if there's no order-level coupon foreach ( $coupon_details as $coupon_code => $coupon_data ) { // Skip order-level coupons. if ( 'fixed_cart' === $coupon_data['type'] ) { continue; } $product_id = $item['product_id']; $product_cats = wp_get_post_terms( $product_id, 'product_cat', array( 'fields' => 'ids' ) ); // Check if coupon applies to this product. $applies_to_product = false; // Check product IDs. if ( ! empty( $coupon_data['product_ids'] ) ) { if ( in_array( $product_id, $coupon_data['product_ids'], true ) ) { $applies_to_product = true; } } // Check product categories. elseif ( ! empty( $coupon_data['product_categories'] ) && ! empty( $product_cats ) ) { foreach ( $product_cats as $cat_id ) { if ( in_array( $cat_id, $coupon_data['product_categories'], true ) ) { $applies_to_product = true; break; } } } // If no specific products or categories are set, coupon applies to all products. elseif ( empty( $coupon_data['product_ids'] ) && empty( $coupon_data['product_categories'] ) ) { $applies_to_product = true; } // Check excluded products. if ( $applies_to_product && ! empty( $coupon_data['excluded_product_ids'] ) ) { if ( in_array( $product_id, $coupon_data['excluded_product_ids'], true ) ) { $applies_to_product = false; } } // Check excluded categories. if ( $applies_to_product && ! empty( $coupon_data['excluded_product_categories'] ) && ! empty( $product_cats ) ) { foreach ( $product_cats as $cat_id ) { if ( in_array( $cat_id, $coupon_data['excluded_product_categories'], true ) ) { $applies_to_product = false; break; } } } // If coupon applies to this product, add it to the list. if ( $applies_to_product ) { $product_coupon_codes[] = $coupon_code; } } } // Create product data array. $product_data = array( 'id' => strval( $item['product_id'] ), 'name' => $item['name'], 'quantity' => floatval( $item['quantity'] ), 'totalamount' => floatval( $item['line_total'] ), 'description' => $item['name'], // Use name as description if no description available 'sku' => $item['sku'] ? $item['sku'] : strval( $item['product_id'] ), 'type' => 'product', 'category' => is_array( $item['categories'] ) ? implode( ', ', $item['categories'] ) : $item['categories'], 'unitprice' => floatval( $item['price'] ), 'totalprice' => floatval( $item['line_total'] ), ); // Only add image if it exists - ActiveTrail may reject carts with empty image strings if ( ! empty( $item['image_url'] ) && $item['image_url'] !== false ) { $product_data['image'] = $item['image_url']; } // Add product-specific coupon codes if any. if ( ! empty( $product_coupon_codes ) ) { $product_data['coupon'] = implode( ', ', $product_coupon_codes ); } $products[] = $product_data; } } // Get user ID if available (0 in cron/debounced context). $user_id = get_current_user_id(); $session_id = isset( $cart_data['session_id'] ) ? $cart_data['session_id'] : ''; $customer_email = $cart_data['customer_email']; $external_id = null; $existing_contact_data = null; $had_contacts_from_api = false; // True when API returned contacts (even if all Blocked). // One externalId per session: try stored transient first (debounced sync reuses same externalId). if ( ! empty( $session_id ) ) { $stored_external = get_transient( 'activetrail_session_external_' . $session_id ); if ( ! empty( $stored_external ) && is_string( $stored_external ) ) { $external_id = $stored_external; $this->log( 'Using session-stored external_id for cart: ' . $external_id, 'debug' ); } } if ( empty( $external_id ) && ! empty( $customer_email ) && ! empty( $this->api_client ) ) { try { $existing_check = $this->api_client->get_customer_by_email( $customer_email ); if ( ! is_wp_error( $existing_check ) && ! empty( $existing_check['exists'] ) ) { $existing_data = $existing_check['data']; $contacts_to_check = is_array( $existing_data ) && isset( $existing_data[0] ) ? $existing_data : array( $existing_data ); // Exclude Blocked contacts; ActiveTrail rejects Cart for Blocked. $allowed = array(); foreach ( $contacts_to_check as $c ) { $state = isset( $c['state'] ) ? $c['state'] : ''; if ( $state === 'Blocked' ) { $this->log( 'Skipping Blocked contact for cart: ' . ( isset( $c['externalId'] ) ? $c['externalId'] : 'unknown' ), 'debug' ); continue; } $allowed[] = $c; } $had_contacts_from_api = ! empty( $contacts_to_check ); $contacts_to_check = $allowed; $logged_in_user = $user_id ? wp_get_current_user() : null; $logged_in_email = $logged_in_user ? strtolower( trim( $logged_in_user->user_email ) ) : ''; $cart_email_lower = strtolower( trim( $customer_email ) ); $email_matches = ( $logged_in_email === $cart_email_lower ); // Debounce context: session_id is often the user ID for logged-in users. $session_id_numeric = is_numeric( $session_id ) ? (int) $session_id : 0; // Priority: (1) Registered WC-CONTACT-{user_id} or WC-CONTACT-{session_id}, (2) Subscribed, (3) Unsubscribed. $chosen = null; $by_priority = array( 'registered' => null, 'subscribed' => null, 'unsubscribed' => null ); foreach ( $contacts_to_check as $contact_data ) { $existing_external_id = isset( $contact_data['externalId'] ) ? $contact_data['externalId'] : null; if ( empty( $existing_external_id ) ) { continue; } $state = isset( $contact_data['state'] ) ? $contact_data['state'] : ''; // Registered: WC-CONTACT-{user_id} or WC-CONTACT-{session_id} (debounce). if ( ( $user_id && strpos( $existing_external_id, 'WC-CONTACT-' . $user_id ) === 0 ) || ( $session_id_numeric && strpos( $existing_external_id, 'WC-CONTACT-' . $session_id_numeric ) === 0 ) ) { $by_priority['registered'] = $contact_data; break; } if ( $state === 'Subscribed' && empty( $by_priority['subscribed'] ) ) { $by_priority['subscribed'] = $contact_data; } if ( ( $state === 'Unsubscribed' || $state === '' ) && empty( $by_priority['unsubscribed'] ) ) { $by_priority['unsubscribed'] = $contact_data; } } $chosen = $by_priority['registered'] ?: ( $by_priority['subscribed'] ?: $by_priority['unsubscribed'] ); if ( $chosen ) { $external_id = $chosen['externalId']; $existing_contact_data = $chosen; $this->log( 'Chosen contact for cart: ' . $external_id . ' (state: ' . ( isset( $chosen['state'] ) ? $chosen['state'] : 'n/a' ) . ')', 'debug' ); if ( ! empty( $session_id ) ) { set_transient( 'activetrail_session_external_' . $session_id, $external_id, HOUR_IN_SECONDS ); } } if ( $user_id && ! $email_matches && ! empty( $existing_contact_data ) ) { $this->log( 'Email mismatch: logged-in user email (' . $logged_in_email . ') differs from cart email (' . $cart_email_lower . ') - will use existing contact data', 'info' ); } elseif ( $user_id && ! $email_matches && ! empty( $contacts_to_check[0] ) ) { $existing_contact_data = $contacts_to_check[0]; $this->log( 'Email mismatch: using first available contact data', 'info' ); } } } catch ( Exception $check_e ) { $this->log( 'Could not check for existing contact in cart: ' . $check_e->getMessage(), 'debug' ); } } // Use standard GUEST only when no existing contact was found for this email. Do not create GUEST when we had contacts but all were Blocked. if ( empty( $external_id ) && ! $had_contacts_from_api ) { $external_id = $user_id ? activetrail_external_id_for_contact( $user_id ) : activetrail_external_id_for_contact( 0, $customer_email ); $this->log( 'Using standard external_id for cart: ' . $external_id, 'debug' ); if ( ! empty( $session_id ) ) { set_transient( 'activetrail_session_external_' . $session_id, $external_id, HOUR_IN_SECONDS ); } } // Check if user has given consent for marketing - DEFAULT TO FALSE. $consent = false; // If we are currently in a checkout submit, also look at the checkbox value from the form. // This is critical for guest checkouts (user_id = 0), where there is no user meta yet. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce checkout form handles nonce verification $checkout_post_consent = ActiveTrail_Consent_Helper::has_consent_from_data( $_POST ); if ( $user_id ) { // Logged-in user: start from stored meta consent. // Check user meta for consent from registration. // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for consent detection $user_consent = get_user_meta( $user_id, '_activetrail_registration_consent', true ); $consent = ( 'yes' === $user_consent ); // Also check for checkout consent saved on the user. // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for consent detection $checkout_consent_meta = get_user_meta( $user_id, '_activetrail_consent', true ); if ( 'yes' === $checkout_consent_meta ) { $consent = true; } // Finally, if in the current checkout request הלקוח סימן שוב צ'קבוקס – זה גובר ועדכן אותו כמאשר. if ( $checkout_post_consent ) { $consent = true; } } else { // Guest checkout (user_id = 0): הסתמך רק על מה שסומן עכשיו בטופס הצ'קאאוט. if ( $checkout_post_consent ) { $consent = true; } } // Log consent status for debugging. $this->log( 'Cart for user ' . $user_id . ' with email ' . $cart_data['customer_email'] . ' consent status: ' . ( $consent ? 'true' : 'false' ), 'debug' ); // Get order-level coupons for the cart-level coupon field. $order_level_coupon_codes = array(); foreach ( $coupon_details as $coupon_code => $coupon_data ) { if ( 'fixed_cart' === $coupon_data['type'] ) { $order_level_coupon_codes[] = $coupon_code; } } // Get customer name - prioritize existing contact data if available (to avoid mixing data from logged-in user) $first_name = ''; $last_name = ''; // If we have existing contact data (guest contact or email mismatch), use its data first if ( ! empty( $existing_contact_data ) ) { if ( ! empty( $existing_contact_data['first_name'] ) ) { $first_name = $existing_contact_data['first_name']; } if ( ! empty( $existing_contact_data['last_name'] ) ) { $last_name = $existing_contact_data['last_name']; } $this->log( 'Using name from existing contact data: ' . $first_name . ' ' . $last_name, 'debug' ); } // If no name from existing contact, get from WooCommerce Customer or fallback to email extraction. if ( empty( $first_name ) && empty( $last_name ) ) { $customer_name = $this->get_customer_name( $customer_email ); $first_name = $customer_name['first_name']; $last_name = $customer_name['last_name']; } // If still no name found, try to extract from email as fallback. if ( empty( $first_name ) && ! empty( $cart_data['customer_email'] ) ) { $extracted_name = $this->extract_name_from_email( $cart_data['customer_email'] ); $first_name = $extracted_name['first_name']; $last_name = $extracted_name['last_name']; } // Get customer phone, address, city, and zip code - prioritize existing contact data if available $customer_phone = ''; $customer_address = ''; $customer_city = ''; $customer_zip_code = ''; // If we have existing contact data, use its data first if ( ! empty( $existing_contact_data ) ) { if ( ! empty( $existing_contact_data['sms'] ) ) { $customer_phone = $existing_contact_data['sms']; } if ( ! empty( $existing_contact_data['street'] ) ) { $customer_address = $existing_contact_data['street']; } if ( ! empty( $existing_contact_data['city'] ) ) { $customer_city = $existing_contact_data['city']; } if ( ! empty( $existing_contact_data['zip_code'] ) ) { $customer_zip_code = $existing_contact_data['zip_code']; } $this->log( 'Using address data from existing contact', 'debug' ); } // If no data from existing contact, get from WooCommerce Customer if ( empty( $customer_phone ) ) { $customer_phone = $this->get_customer_phone( $customer_email ); } if ( empty( $customer_address ) ) { $customer_address = $this->get_customer_address( $customer_email ); } if ( empty( $customer_city ) ) { $customer_city = $this->get_customer_city( $customer_email ); } if ( empty( $customer_zip_code ) ) { $customer_zip_code = $this->get_customer_zip_code( $customer_email ); } // Format according to ActiveTrail Cart API structure. $activetrail_data = array( 'externalId' => $external_id, 'externalName' => ACTIVETRAIL_EXTERNAL_NAME, 'name' => 'Cart #' . substr( $cart_data['session_id'], -8 ), 'firstName' => $first_name, 'lastName' => $last_name, 'email' => $cart_data['customer_email'], 'mobile' => $customer_phone, 'netAmount' => floatval( $cart_data['cart_subtotal'] ), 'tax' => floatval( $cart_data['cart_tax'] ), 'coupon' => ! empty( $order_level_coupon_codes ) ? implode( ', ', $order_level_coupon_codes ) : '', // Set cart-level coupons 'quantity' => floatval( $cart_data['cart_count'] ), 'totalAmount' => floatval( $cart_data['cart_total'] ), 'address' => $customer_address, 'city' => $customer_city, 'zip_code' => $customer_zip_code, 'products' => $products, 'url' => wc_get_cart_url(), 'lastUpdate' => gmdate( 'c' ), // ISO 8601 format 'purchaseType' => 'online', 'group' => ! empty( $settings['group_id'] ) ? intval( $settings['group_id'] ) : null, // Added group from settings key 'group_id' ); // Log presence or absence of group. if ( ! empty( $settings['group_id'] ) ) { $this->log( 'Added group field to cart data: ' . intval( $settings['group_id'] ), 'debug' ); } else { $this->log( 'No group configured in settings; group field missing from cart data', 'warning' ); } // Add store ID if configured, otherwise fallback to 1. if ( ! empty( $settings['store_id'] ) ) { $activetrail_data['storeId'] = intval( $settings['store_id'] ); } else { $activetrail_data['storeId'] = 1; } return apply_filters( 'activetrail_cart_data_for_api', $activetrail_data, $cart_data ); } /** * Extract first name and last name from email (fallback when no name is available) * Tries to split the username by common separators (., _, -) or space * * @param string $email Email address. * @return array Array with 'first_name' and 'last_name' keys */ private function extract_name_from_email( $email ) { if ( empty( $email ) ) { return array( 'first_name' => '', 'last_name' => '', ); } $parts = explode( '@', $email ); $username = $parts[0]; // Try to split by common separators: dot, underscore, dash, or space. $name_parts = preg_split( '/[._\-\s]+/', $username ); // Remove numbers and special characters from each part. $cleaned_parts = array(); foreach ( $name_parts as $part ) { $cleaned = preg_replace( '/[^a-zA-Z]/', '', $part ); if ( ! empty( $cleaned ) ) { $cleaned_parts[] = ucfirst( strtolower( $cleaned ) ); } } // If we have multiple parts, use first as first_name and rest as last_name. if ( count( $cleaned_parts ) > 1 ) { $first_name = array_shift( $cleaned_parts ); $last_name = implode( ' ', $cleaned_parts ); } elseif ( count( $cleaned_parts ) === 1 ) { // Only one part - use as first name. $first_name = $cleaned_parts[0]; $last_name = ''; } else { // No valid name found. $first_name = ''; $last_name = ''; } return array( 'first_name' => $first_name, 'last_name' => $last_name, ); } /** * Mark cart as synced * * @param string $session_id Session ID. */ private function mark_cart_synced( $session_id ) { global $wpdb; $table_name = $wpdb->prefix . 'activetrail_cart_tracking'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for cart tracking $result = $wpdb->update( $table_name, array( 'synced' => 1, 'sync_attempts' => 0, // Reset attempts on successful sync ), array( 'session_id' => $session_id ), array( '%d', '%d' ), array( '%s' ) ); if ( $result === false ) { $this->log( 'Failed to mark cart as synced: ' . $session_id . ' - ' . $wpdb->last_error, 'error' ); } else { $this->log( 'Cart marked as synced: ' . $session_id, 'info' ); } } /** * Increment sync attempt counter for a cart * * @param string $session_id Session ID. */ private function increment_sync_attempt( $session_id ) { global $wpdb; $table_name = $wpdb->prefix . 'activetrail_cart_tracking'; $table = esc_sql( $table_name ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for cart tracking $result = $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is escaped with esc_sql() "UPDATE {$table} SET sync_attempts = sync_attempts + 1, last_sync_attempt = %s WHERE session_id = %s", current_time( 'mysql' ), $session_id ) ); if ( $result === false ) { $this->log( 'Failed to increment sync attempt for cart: ' . $session_id . ' - ' . $wpdb->last_error, 'error' ); } } /** * Sync abandoned carts */ public function sync_abandoned_carts() { // Check if cart tracking is enabled. if ( ! $this->is_cart_tracking_enabled() ) { $this->log( 'Abandoned cart sync skipped - cart tracking disabled', 'debug' ); return; } global $wpdb; $table_name = $wpdb->prefix . 'activetrail_cart_tracking'; $table = esc_sql( $table_name ); // Get abandoned carts (not synced, older than 60 minutes, with email). // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Direct query needed for cart tracking, table name is escaped // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for cart tracking // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for cart tracking $abandoned_carts = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is escaped with esc_sql() "SELECT * FROM {$table} WHERE synced = 0 AND email IS NOT NULL AND email != '' AND updated_at < %s LIMIT 50", gmdate( 'Y-m-d H:i:s', strtotime( '-60 minutes' ) ) ) ); $synced_count = 0; $error_count = 0; foreach ( $abandoned_carts as $cart_record ) { $cart_data = json_decode( $cart_record->cart_data, true ); // Sanitize decoded data. if ( is_array( $cart_data ) ) { $cart_data = activetrail_wc_sanitize_array( $cart_data ); } // Ensure session_id is set from database record (needed for mark_cart_synced). if ( ! isset( $cart_data['session_id'] ) || empty( $cart_data['session_id'] ) ) { $cart_data['session_id'] = $cart_record->session_id; } // Ensure email is set from database record. if ( ! isset( $cart_data['customer_email'] ) || empty( $cart_data['customer_email'] ) ) { $cart_data['customer_email'] = $cart_record->email; } // Check if cart has exceeded max retry attempts (default: 3). $max_retry_attempts = apply_filters( 'activetrail_max_cart_sync_attempts', 3 ); $current_attempts = isset( $cart_record->sync_attempts ) ? (int) $cart_record->sync_attempts : 0; if ( $current_attempts >= $max_retry_attempts ) { $this->log( 'Abandoned cart sync skipped: max retry attempts (' . $max_retry_attempts . ') reached for session ' . $cart_record->session_id, 'warning' ); // Mark as synced to prevent further attempts (even though it failed). $this->mark_cart_synced( $cart_record->session_id ); ++$error_count; continue; } if ( $cart_data && ! empty( $cart_data['items'] ) && ! empty( $cart_data['session_id'] ) ) { // Increment sync attempt counter before sync. $this->increment_sync_attempt( $cart_record->session_id ); $result = $this->sync_cart( $cart_data ); if ( is_array( $result ) ? ! empty( $result['success'] ) : (bool) $result ) { ++$synced_count; } else { ++$error_count; } } else { // Log if cart data is invalid. if ( empty( $cart_data['items'] ) ) { $this->log( 'Abandoned cart sync skipped: cart has no items for session ' . ( isset( $cart_record->session_id ) ? $cart_record->session_id : 'unknown' ), 'warning' ); } elseif ( empty( $cart_data['session_id'] ) ) { $this->log( 'Abandoned cart sync skipped: missing session_id for cart record ID ' . ( isset( $cart_record->id ) ? $cart_record->id : 'unknown' ), 'warning' ); } } } // Log results only if there were carts to process or errors occurred. if ( ! empty( $abandoned_carts ) && ( $synced_count > 0 || $error_count > 0 ) ) { $this->log( "Abandoned cart sync completed: {$synced_count} synced, {$error_count} errors", 'info' ); } } /** * Get product categories * * @param WC_Product $product Product object. * @return array */ private function get_product_categories( $product ) { $categories = array(); $terms = get_the_terms( $product->get_id(), 'product_cat' ); if ( $terms && ! is_wp_error( $terms ) ) { foreach ( $terms as $term ) { $categories[] = $term->name; } } return $categories; } /** * Get abandoned carts for admin display * * @param int $limit Limit. * @return array */ public function get_abandoned_carts( $limit = 20 ) { global $wpdb; $table_name = $wpdb->prefix . 'activetrail_cart_tracking'; $table = esc_sql( $table_name ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for cart tracking // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for cart tracking return $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is escaped with esc_sql() "SELECT * FROM {$table} WHERE email IS NOT NULL AND email != '' ORDER BY updated_at DESC LIMIT %d", $limit ) ); } /** * Clean old cart data * * @param int $days Days to keep. */ public function clean_old_cart_data( $days = 30 ) { global $wpdb; $table_name = $wpdb->prefix . 'activetrail_cart_tracking'; $table = esc_sql( $table_name ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for cart tracking cleanup $deleted = $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is escaped with esc_sql() "DELETE FROM {$table} WHERE created_at < %s", gmdate( 'Y-m-d H:i:s', strtotime( "-{$days} days" ) ) ) ); return $deleted; } /** * Log message * * @param string $message Log message. * @param string $level Log level. */ private function log( $message, $level = 'info' ) { if ( function_exists( 'activetrail_logger' ) ) { $level_map = array( 'info' => 'INFO', 'debug' => 'DEBUG', 'warning' => 'WARNING', 'error' => 'ERROR', ); $mapped_level = isset( $level_map[ $level ] ) ? $level_map[ $level ] : 'INFO'; activetrail_logger()->log( $message, $mapped_level, 'CART' ); } } }