get_parent_id(); if ($parent_id) { $parent_order = wc_get_order($parent_id); } } if ($parent_order && method_exists($parent_order, 'get_billing_email')) { $this->log( 'Using parent order for billing data', array( 'child_order_id' => method_exists($order, 'get_id') ? $order->get_id() : null, 'parent_order_id' => $parent_order->get_id(), 'child_type' => get_class($order), 'was_refund' => $is_refund_like ? 'yes' : 'no' ), 'debug' ); return $parent_order; } $this->log( 'Order missing billing information - skipping', array( 'order_id' => method_exists($order, 'get_id') ? $order->get_id() : null, 'order_type' => get_class($order) ), 'warning' ); return null; } /** * Constructor * * @param ActiveTrail_API_Client $api_client API client instance. * @param ActiveTrail_Settings $settings Settings instance. */ public function __construct($api_client, $settings) { $this->api_client = $api_client; $this->settings = $settings; // Instantiate order handler for order sync methods. require_once ACTIVETRAIL_WC_PLUGIN_DIR . 'includes/services/class-order-handler.php'; $this->order_handler = new ActiveTrail_Order_Handler($api_client, $settings); $this->init_hooks(); } /** * Initialize WordPress hooks */ private function init_hooks() { // AJAX handlers for historical sync. add_action('wp_ajax_activetrail_start_historical_sync', array($this, 'ajax_start_historical_sync')); add_action('wp_ajax_activetrail_get_sync_progress', array($this, 'ajax_get_sync_progress')); add_action('wp_ajax_activetrail_cancel_historical_sync', array($this, 'ajax_cancel_historical_sync')); // AJAX handler for batch processing when cron is disabled (removed for simplicity). // Note: If WordPress cron is disabled, user should set up system cron instead. // Cron hook for background processing. add_action('activetrail_process_historical_sync_batch', array($this, 'process_historical_sync_batch')); // Hook to trigger historical sync when API key changes - REMOVED: sync should only start manually via button click // add_action('activetrail_api_key_changed', array($this, 'offer_historical_sync')); } /** * Normalize a user supplied date to a full MySQL datetime. * * The date pickers submit Y-m-d. Downstream we compare these values against * user_registered / order dates as strings, so both ends must be full * datetimes or a record created on the final day would fall outside the range. * * @param string $value Date string (Y-m-d or Y-m-d H:i:s). * @param bool $is_end True to snap to the end of the day. * @return string Y-m-d H:i:s */ private function normalize_range_date($value, $is_end = false) { $value = trim((string) $value); if ('' === $value) { return $is_end ? current_time('mysql') : gmdate('Y-m-d H:i:s', strtotime('-1 year')); } // Already a full datetime - leave as is. if (preg_match('/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/', $value)) { return str_replace('T', ' ', substr($value, 0, 19)); } // Date only - snap to start or end of that day. if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { return $value . ($is_end ? ' 23:59:59' : ' 00:00:00'); } $timestamp = strtotime($value); if (false === $timestamp) { return $is_end ? current_time('mysql') : gmdate('Y-m-d H:i:s', strtotime('-1 year')); } return gmdate('Y-m-d H:i:s', $timestamp); } /** * Build the list of WooCommerce order statuses to sync, from settings. * * Shared by the counting, paging and syncing paths so they can never drift apart. * * @return array Status keys in 'wc-' prefixed form. */ private function get_order_status_list() { $settings = ActiveTrail_Settings::get_all(); $enabled_statuses = isset($settings['order_statuses']) ? $settings['order_statuses'] : array(); // Backward compatibility: if empty array, use all statuses. if (empty($enabled_statuses)) { return array_keys(wc_get_order_statuses()); } $all_statuses = wc_get_order_statuses(); $status_list = array(); foreach ($enabled_statuses as $status) { $status_key = strpos($status, 'wc-') === 0 ? $status : 'wc-' . $status; if (array_key_exists($status_key, $all_statuses)) { $status_list[] = $status_key; } } return $status_list; } /** * Count users registered in the given range. * * Uses a COUNT query - never loads the users themselves. * * @param string $date_from Start date. * @param string $date_to End date. * @return int */ private function count_users_in_range($date_from, $date_to) { $user_query = new WP_User_Query(array( 'date_query' => array( array( 'after' => $date_from, 'before' => $date_to, 'inclusive' => true, ), ), 'number' => 1, 'fields' => 'ID', 'count_total' => true, )); return (int) $user_query->get_total(); } /** * Count orders in the given range. * * Uses WooCommerce pagination metadata so only the count comes back, * not the order IDs. Works on both HPOS and legacy storage. * * @param string $date_from Start date. * @param string $date_to End date. * @return int */ private function count_orders_in_range($date_from, $date_to) { $status_list = $this->get_order_status_list(); if (empty($status_list)) { return 0; } $result = wc_get_orders(array( 'status' => $status_list, 'date_created' => $date_from . '...' . $date_to, 'limit' => 1, 'return' => 'ids', 'paginate' => true, )); if (is_object($result) && isset($result->total)) { return (int) $result->total; } // Very old WooCommerce without 'paginate' support - fall back to counting IDs. $order_ids = wc_get_orders(array( 'status' => $status_list, 'date_created' => $date_from . '...' . $date_to, 'limit' => -1, 'return' => 'ids', )); return is_array($order_ids) ? count($order_ids) : 0; } /** * Check whether an email belongs to a user registered inside the sync range. * * Reproduces the old "users take priority over orders" de-duplication without * needing the full contact list in memory: if the email belongs to a user that * the users pass already sent, the order-derived copy is skipped. * * @param string $email Email address. * @param string $date_from Start date. * @param string $date_to End date. * @return bool */ private function email_belongs_to_user_in_range($email, $date_from, $date_to) { $user = get_user_by('email', $email); if (!$user || empty($user->user_registered)) { return false; } return ($user->user_registered >= $date_from && $user->user_registered <= $date_to); } /** * Fetch one page of contacts derived from registered users. * * @param string $date_from Start date. * @param string $date_to End date. * @param int $limit Page size. * @param int $offset Offset into the user list. * @return array{contacts: array, scanned: int} Contacts kept, and how many users were read. */ private function get_user_contacts_page($date_from, $date_to, $limit, $offset) { $locale_switched = $this->switch_to_plugin_language(); try { $user_query = new WP_User_Query(array( 'date_query' => array( array( 'after' => $date_from, 'before' => $date_to, 'inclusive' => true, ), ), 'number' => $limit, 'offset' => $offset, 'orderby' => 'ID', 'order' => 'ASC', 'count_total' => false, 'fields' => 'all', )); $users = $user_query->get_results(); $contacts = array(); foreach ($users as $user) { $email = strtolower(trim($user->user_email)); if (empty($email)) { continue; } $contact_data = $this->prepare_user_contact_data($user); if (!$contact_data) { continue; } // Always generate externalId based on email (one-to-one) to ensure consistency. $contact_data['externalId'] = 'WC-CONTACT-' . md5($email); $contacts[] = $contact_data; } return array( 'contacts' => $contacts, 'scanned' => count($users), ); } finally { if ($locale_switched) { $this->restore_plugin_language(); } } } /** * Fetch one page of contacts derived from order billing details. * * Orders are read one at a time by ID so only a single WC_Order object is * held in memory at any moment. * * @param string $date_from Start date. * @param string $date_to End date. * @param int $limit Page size. * @param int $offset Offset into the order list. * @return array{contacts: array, scanned: int} Contacts kept, and how many orders were read. */ private function get_order_contacts_page($date_from, $date_to, $limit, $offset) { $locale_switched = $this->switch_to_plugin_language(); try { $status_list = $this->get_order_status_list(); if (empty($status_list)) { return array('contacts' => array(), 'scanned' => 0); } $order_ids = wc_get_orders(array( 'status' => $status_list, 'date_created' => $date_from . '...' . $date_to, 'limit' => $limit, 'offset' => $offset, 'orderby' => 'ID', 'order' => 'ASC', 'return' => 'ids', )); if (!is_array($order_ids)) { $order_ids = array(); } $contacts = array(); $seen_in_page = array(); foreach ($order_ids as $order_id) { $order = wc_get_order($order_id); if (!$order) { continue; } $order_with_billing = $this->resolve_order_with_billing_details($order); if (!$order_with_billing) { continue; } $email = strtolower(trim($order_with_billing->get_billing_email())); if (empty($email) || isset($seen_in_page[$email])) { continue; } // Users take priority: skip if the users pass already covered this email. if ($this->email_belongs_to_user_in_range($email, $date_from, $date_to)) { continue; } $contact_data = $this->prepare_order_contact_data($order_with_billing); if (!$contact_data) { continue; } // Always generate externalId based on email (one-to-one) to ensure consistency. $contact_data['externalId'] = 'WC-CONTACT-' . md5($email); $contacts[] = $contact_data; $seen_in_page[$email] = true; } return array( 'contacts' => $contacts, 'scanned' => count($order_ids), ); } finally { if ($locale_switched) { $this->restore_plugin_language(); } } } /** * How many source records to read per batch. * * Filterable so a store with plenty of memory can trade a larger page for fewer * batches (each batch is one cron run, 10s apart). Clamped to the ActiveTrail * batch-import ceiling of 1000 contacts per API request. * * @return int */ private function get_contact_page_size() { $size = (int) apply_filters('activetrail_historical_contact_page_size', $this->contact_page_size); return max(1, min(1000, $size)); } /** * Fetch one page of contacts from the given source. * * @param string $source 'users' or 'orders'. * @param string $date_from Start date. * @param string $date_to End date. * @param int $limit Page size. * @param int $offset Offset into that source. * @return array{contacts: array, scanned: int} */ private function get_contacts_page($source, $date_from, $date_to, $limit, $offset) { if ('orders' === $source) { return $this->get_order_contacts_page($date_from, $date_to, $limit, $offset); } return $this->get_user_contacts_page($date_from, $date_to, $limit, $offset); } /** * Get a slice of historical contacts (users first, then order billing contacts). * * Walks the paged sources instead of materialising the whole list, so peak * memory stays flat no matter how large the store is. * * @param string $date_from Start date (default: 1 year ago). * @param string $date_to End date (default: now). * @param int $limit Limit number of results. * @param int $offset Offset for pagination. * @return array */ public function get_historical_contacts($date_from = null, $date_to = null, $limit = 100, $offset = 0) { $date_from = $this->normalize_range_date($date_from, false); $date_to = $this->normalize_range_date($date_to, true); $limit = max(0, (int) $limit); $offset = max(0, (int) $offset); if (0 === $limit) { return array(); } $collected = array(); $skipped = 0; $page_size = $this->get_contact_page_size(); foreach (array('users', 'orders') as $source) { $source_offset = 0; while (count($collected) < $limit) { $page = $this->get_contacts_page($source, $date_from, $date_to, $page_size, $source_offset); if (empty($page['scanned'])) { break; } $source_offset += (int) $page['scanned']; foreach ($page['contacts'] as $contact_data) { if ($skipped < $offset) { $skipped++; continue; } $collected[] = $contact_data; if (count($collected) >= $limit) { break; } } // Short page means this source is exhausted. if ((int) $page['scanned'] < $page_size) { break; } } } return $collected; } /** * Get historical orders from WooCommerce by date range * * @param string|null $date_from Start date (Y-m-d H:i:s). * @param string|null $date_to End date (Y-m-d H:i:s). * @param int $limit Number of orders to retrieve. * @param int $offset Offset for pagination. * @return array Array of WC_Order objects */ public function get_historical_orders($date_from = null, $date_to = null, $limit = 100, $offset = 0) { // Switch to plugin's language setting for translations. $locale_switched = $this->switch_to_plugin_language(); try { $date_from = $this->normalize_range_date($date_from, false); $date_to = $this->normalize_range_date($date_to, true); $status_list = $this->get_order_status_list(); if (empty($status_list)) { return array(); } $order_args = array( 'status' => $status_list, 'date_created' => $date_from . '...' . $date_to, 'limit' => $limit, 'offset' => $offset, // Stable ordering so offset-based paging cannot skip or repeat orders. 'orderby' => 'ID', 'order' => 'ASC', 'return' => 'objects', ); $orders = wc_get_orders($order_args); return is_array($orders) ? $orders : array(); } finally { // Restore original locale. if ($locale_switched) { $this->restore_plugin_language(); } } } /** * Get the upper bound of historical contacts in a date range. * * This is users-in-range plus orders-in-range, using COUNT queries only - it * never loads the records themselves. It is an upper bound rather than an exact * figure because consent filtering and email de-duplication can only be resolved * while actually walking the records; the sync corrects the total to the real * number once the contacts phase completes. * * Replaces the previous implementation, which queried wp_posts for * post_type = 'shop_order' directly and therefore returned 0 on stores using * WooCommerce High-Performance Order Storage. * * @param string $date_from Start date. * @param string $date_to End date. * @return int */ public function get_historical_contacts_count($date_from = null, $date_to = null) { $date_from = $this->normalize_range_date($date_from, false); $date_to = $this->normalize_range_date($date_to, true); $user_count = $this->count_users_in_range($date_from, $date_to); $order_count = $this->count_orders_in_range($date_from, $date_to); $this->log('get_historical_contacts_count result', array( 'date_from' => $date_from, 'date_to' => $date_to, 'users' => $user_count, 'orders' => $order_count ), 'debug'); return $user_count + $order_count; } /** * Get total count of historical orders * * @param string|null $date_from Start date (Y-m-d H:i:s). * @param string|null $date_to End date (Y-m-d H:i:s). * @return int */ public function get_historical_orders_count($date_from = null, $date_to = null) { $date_from = $this->normalize_range_date($date_from, false); $date_to = $this->normalize_range_date($date_to, true); // Counted via WooCommerce pagination metadata - the order IDs are never // pulled into memory. Works on both HPOS and legacy post-based storage. $count = $this->count_orders_in_range($date_from, $date_to); $this->log('get_historical_orders_count result', array( 'date_from' => $date_from, 'date_to' => $date_to, 'total_orders' => $count ), 'info'); return $count; } /** * Detect opt-in from marketing meta keys * Checks user meta or post meta for opt-in consent from various email marketing plugins * Checks ALL keys before making a decision - prioritizes opt-in over opt-out * * @param int $object_id User ID or Post ID. * @param string $meta_type Type of meta: 'user' or 'post'. * @return array Array with 'optin' (bool|null), 'meta_key' (string), 'meta_value' (mixed) */ private function detect_optin_from_meta_keys($object_id, $meta_type = 'user') { // Define opt-in values (case-insensitive). $optin_values = array('1', '1', 'yes', 'true', 'on', 'active', 'subscribe', 'subscribed', 'accepted', 'consent'); // Define opt-out values (case-insensitive). $optout_values = array('0', '0', 'no', 'false', 'off', 'declined', 'unsubscribed', 'rejected'); $optin_found = null; $optout_found = null; // Single pass: Check ALL keys before making a decision. foreach ($this->marketing_meta_keys as $key) { // Get meta value based on type. if ( 'user' === $meta_type ) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection $value = get_user_meta($object_id, $key, true); } else { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection $value = get_post_meta($object_id, $key, true); } // Skip if empty or null. if ($value === '' || $value === null) { continue; } // Convert to string for comparison. $value_str = is_bool($value) ? ($value ? 'true' : 'false') : (string) $value; $value_lower = strtolower(trim($value_str)); // Check if it's an opt-in value. if (in_array($value_lower, array_map('strtolower', $optin_values), true) || $value === 1 || $value === true) { // Store first opt-in found (we'll use this if no other opt-in is found). if ($optin_found === null) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta queries necessary for opt-in detection // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta queries necessary for opt-in detection $optin_found = array( 'optin' => true, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta queries necessary for opt-in detection 'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta queries necessary for opt-in detection 'meta_value' => $value ); $this->log('Opt-in detected from marketing meta key', array( 'object_id' => $object_id, 'meta_type' => $meta_type, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection 'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta query necessary for opt-in detection 'meta_value' => $value ), 'debug'); } // Continue checking all keys - don't break. } // Check if it's an opt-out value (only if we haven't found opt-in yet, or continue checking). elseif (in_array($value_lower, array_map('strtolower', $optout_values), true) || $value === 0 || $value === false) { // Store first opt-out found (we'll use this only if no opt-in is found). if ($optout_found === null) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta queries necessary for opt-in detection $optout_found = array( 'optin' => false, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta queries necessary for opt-in detection 'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta queries necessary for opt-in detection 'meta_value' => $value ); $this->log('Opt-out detected from marketing meta key', array( 'object_id' => $object_id, 'meta_type' => $meta_type, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection 'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta query necessary for opt-in detection 'meta_value' => $value ), 'debug'); } // Continue checking all keys - don't break. } } // Decision: Prioritize opt-in over opt-out. // If we found ANY opt-in, return opt-in (even if there's also opt-out). if ($optin_found !== null) { return $optin_found; } // If no opt-in found but opt-out found, return opt-out. if ($optout_found !== null) { return $optout_found; } // No relevant meta found. return array('optin' => null); } /** * Detect opt-in from user's order meta * Checks all orders for the user to find opt-in consent * * @param int $user_id User ID. * @param string $email User email. * @return array Array with 'optin' (bool), 'source' (string), 'order_id' (int|null) */ private function detect_optin_from_order_meta($user_id, $email) { // Get orders for this user (by customer_id or email). $order_args = array( 'status' => array_keys(wc_get_order_statuses()), 'limit' => 50, // Limit to avoid performance issues 'return' => 'ids', ); // Try by customer_id first. if ($user_id > 0) { $order_args['customer_id'] = $user_id; $order_ids = wc_get_orders($order_args); } else { $order_ids = array(); } // If no orders found by customer_id, try by email. if (empty($order_ids) && !empty($email)) { $order_args = array( 'status' => array_keys(wc_get_order_statuses()), 'billing_email' => $email, 'limit' => 50, 'return' => 'ids', ); $order_ids = wc_get_orders($order_args); } if (empty($order_ids)) { return array('optin' => false); } // Check each order for opt-in. foreach ($order_ids as $order_id) { $detection = $this->detect_optin_from_meta_keys($order_id, 'post'); if ($detection['optin'] === true) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection $this->log('Opt-in found in order meta', array( 'user_id' => $user_id, 'email' => $email, 'order_id' => $order_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection 'meta_key' => $detection['meta_key'] ), 'debug'); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection return array( 'optin' => true, 'source' => 'order', 'order_id' => $order_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection 'meta_key' => $detection['meta_key'] ); } } return array('optin' => false); } /** * Check if user has consent in any of their orders * * @param int $user_id User ID. * @param string $email User email. * @return array */ private function check_user_orders_for_consent($user_id, $email) { $result = array( 'has_consent' => false, 'count' => 0, 'sample_order_ids' => array() ); // Get orders for this user (by customer_id or email). $order_args = array( 'status' => array_keys(wc_get_order_statuses()), 'limit' => 50, // Limit to avoid performance issues 'return' => 'ids', ); // Try by customer_id first. if ($user_id > 0) { $order_args['customer_id'] = $user_id; $order_ids = wc_get_orders($order_args); } else { $order_ids = array(); } // If no orders found by customer_id, try by email. if (empty($order_ids) && !empty($email)) { $order_args = array( 'status' => array_keys(wc_get_order_statuses()), 'billing_email' => $email, 'limit' => 50, 'return' => 'ids', ); $order_ids = wc_get_orders($order_args); } if (empty($order_ids)) { return $result; } // Check consent in orders. $orders_with_consent = array(); foreach ($order_ids as $order_id) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for consent detection $consent = get_post_meta($order_id, '_activetrail_consent', true); if ( 'yes' === $consent ) { $orders_with_consent[] = $order_id; } } $result['has_consent'] = !empty($orders_with_consent); $result['count'] = count($orders_with_consent); $result['sample_order_ids'] = array_slice($orders_with_consent, 0, 5); // First 5 for logging return $result; } /** * Prepare user contact data for ActiveTrail * * @param WP_User $user User object. * @return array|null */ private function prepare_user_contact_data($user) { if (!$user || empty($user->user_email)) { $this->log('prepare_user_contact_data: Invalid user or empty email', array('user_id' => $user ? $user->ID : 'null'), 'debug'); return null; } // Get user meta data. // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta queries necessary for user data retrieval $first_name = get_user_meta($user->ID, 'first_name', true); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta queries necessary for user data retrieval $last_name = get_user_meta($user->ID, 'last_name', true); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta queries necessary for user data retrieval $phone = get_user_meta($user->ID, 'billing_phone', true); // Get billing address data. // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta queries necessary for user data retrieval $city = get_user_meta($user->ID, 'billing_city', true); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta queries necessary for user data retrieval $address_1 = get_user_meta($user->ID, 'billing_address_1', true); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta queries necessary for user data retrieval $address_2 = get_user_meta($user->ID, 'billing_address_2', true); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta queries necessary for user data retrieval $postcode = get_user_meta($user->ID, 'billing_postcode', true); // Combine address lines. $street = trim($address_1 . ' ' . $address_2); // Check ActiveTrail consent status first. // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta queries necessary for consent detection $consent = get_user_meta($user->ID, '_activetrail_registration_consent', true); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta queries necessary for consent detection $checkout_consent = get_user_meta($user->ID, '_activetrail_consent', true); // Debug: Get all user meta to see what's actually stored. // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for debugging $all_user_meta = get_user_meta($user->ID); $activetrail_meta = array(); foreach ($all_user_meta as $key => $value) { if (strpos($key, 'activetrail-for-woocommerce') !== false) { $activetrail_meta[$key] = is_array($value) ? $value[0] : $value; } } // Determine subscription status. $subscription_status = false; $consent_source = 'none'; $detected_meta_key = null; // Priority 1: Check ActiveTrail consent. if ( 'yes' === $consent ) { $subscription_status = true; $consent_source = 'registration'; $this->log('User has ActiveTrail registration consent', array('user_id' => $user->ID, 'email' => $user->user_email), 'debug'); } elseif ( 'yes' === $checkout_consent ) { $subscription_status = true; $consent_source = 'checkout'; $this->log('User has ActiveTrail checkout consent', array('user_id' => $user->ID, 'email' => $user->user_email), 'debug'); } else { // Priority 2: Check marketing meta keys from other plugins. $detection = $this->detect_optin_from_meta_keys($user->ID, 'user'); if ($detection['optin'] === true) { $subscription_status = true; $consent_source = 'marketing_plugin'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta queries necessary for opt-in detection $detected_meta_key = $detection['meta_key']; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta queries necessary for opt-in detection $this->log('User has opt-in from marketing plugin', array( 'user_id' => $user->ID, 'email' => $user->user_email, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection 'meta_key' => $detected_meta_key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta query necessary for opt-in detection 'meta_value' => $detection['meta_value'] ), 'debug'); } elseif ($detection['optin'] === false) { // Explicit opt-out found. $subscription_status = false; $consent_source = 'marketing_plugin_optout'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection $detected_meta_key = $detection['meta_key']; $this->log('User has explicit opt-out from marketing plugin', array( 'user_id' => $user->ID, 'email' => $user->user_email, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection 'meta_key' => $detected_meta_key ), 'debug'); } else { // Priority 3: Check order meta. $order_detection = $this->detect_optin_from_order_meta($user->ID, $user->user_email); if ($order_detection['optin'] === true) { $subscription_status = true; $consent_source = 'order_marketing_plugin'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection $detected_meta_key = $order_detection['meta_key']; $this->log('User has opt-in from order marketing plugin', array( 'user_id' => $user->ID, 'email' => $user->user_email, 'order_id' => $order_detection['order_id'], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection 'meta_key' => $detected_meta_key ), 'debug'); } elseif ($order_detection['optin'] === false) { // Explicit opt-out found in order. $subscription_status = false; $consent_source = 'order_marketing_plugin_optout'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection $detected_meta_key = isset($order_detection['meta_key']) ? $order_detection['meta_key'] : null; $order_id_for_log = isset($order_detection['order_id']) ? $order_detection['order_id'] : null; $this->log('User has explicit opt-out from order marketing plugin', array( 'user_id' => $user->ID, 'email' => $user->user_email, 'order_id' => $order_id_for_log, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection 'meta_key' => $detected_meta_key ), 'debug'); } else { // No consent found anywhere. $subscription_status = false; $consent_source = 'none'; $this->log('User has no consent - syncing as unsubscribed', array('user_id' => $user->ID, 'email' => $user->user_email), 'debug'); } } } $this->log('Final user consent decision', array( 'user_id' => $user->ID, 'email' => $user->user_email, 'subscription_status' => $subscription_status, 'consent_source' => $consent_source, 'detected_meta_key' => $detected_meta_key, 'all_activetrail_meta' => $activetrail_meta ), 'debug'); // Generate external ID - always use email-based format (one-to-one) for consistency. $email = strtolower(trim($user->user_email)); $external_id = 'WC-CONTACT-' . md5($email); return array( 'email' => $user->user_email, 'first_name' => $first_name ? $first_name : '', 'last_name' => $last_name ? $last_name : '', 'phone' => $phone ? $phone : '', 'city' => $city ? $city : '', 'street' => $street ? $street : '', 'zip_code' => $postcode ? $postcode : '', 'subscriptionStatus' => $subscription_status, 'tracking_consent' => ( get_user_meta( $user->ID, '_activetrail_tracking_consent', true ) === 'yes' ), // CNIL, always boolean 'registration_date' => $user->user_registered, 'source' => 'woocommerce_historical_sync', 'externalId' => $external_id, 'externalName' => ACTIVETRAIL_EXTERNAL_NAME, 'user_id' => $user->ID, 'consent_source' => $consent_source, 'detected_meta_key' => $detected_meta_key ); } /** * Prepare order contact data for ActiveTrail * * @param WC_Order $order Order object. * @return array|null */ private function prepare_order_contact_data($order) { $order = $this->resolve_order_with_billing_details($order); if (!$order || empty($order->get_billing_email())) { $this->log('prepare_order_contact_data: Invalid order or empty email', array('order_id' => $order ? $order->get_id() : 'null'), 'debug'); return null; } // Check ActiveTrail consent status from order first. // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for consent detection $consent = get_post_meta($order->get_id(), '_activetrail_consent', true); // Debug: Get all order meta to see what's actually stored. $all_order_meta = $order->get_meta_data(); $activetrail_meta = array(); foreach ($all_order_meta as $meta) { $key = $meta->get_data()['key']; if (strpos($key, 'activetrail-for-woocommerce') !== false) { $activetrail_meta[$key] = $meta->get_data()['value']; } } // Determine subscription status. $subscription_status = false; $consent_source = 'none'; $detected_meta_key = null; // Priority 1: Check ActiveTrail consent. if ( 'yes' === $consent ) { $subscription_status = true; $consent_source = 'checkout'; $this->log('Order has ActiveTrail consent', array('order_id' => $order->get_id(), 'email' => $order->get_billing_email()), 'debug'); } else { // Priority 2: Check marketing meta keys from other plugins in order meta. $detection = $this->detect_optin_from_meta_keys($order->get_id(), 'post'); if ($detection['optin'] === true) { $subscription_status = true; $consent_source = 'order_marketing_plugin'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta queries necessary for opt-in detection $detected_meta_key = $detection['meta_key']; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta queries necessary for opt-in detection $this->log('Order has opt-in from marketing plugin', array( 'order_id' => $order->get_id(), 'email' => $order->get_billing_email(), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection 'meta_key' => $detected_meta_key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta query necessary for opt-in detection 'meta_value' => $detection['meta_value'] ), 'debug'); } elseif ($detection['optin'] === false) { // Explicit opt-out found. $subscription_status = false; $consent_source = 'order_marketing_plugin_optout'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection $detected_meta_key = $detection['meta_key']; $this->log('Order has explicit opt-out from marketing plugin', array( 'order_id' => $order->get_id(), 'email' => $order->get_billing_email(), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection 'meta_key' => $detected_meta_key ), 'debug'); } else { // No consent found. $subscription_status = false; $consent_source = 'none'; $this->log('Order has no consent - syncing as unsubscribed', array('order_id' => $order->get_id(), 'email' => $order->get_billing_email()), 'debug'); } } $this->log('Final order consent decision', array( 'order_id' => $order->get_id(), 'email' => $order->get_billing_email(), 'subscription_status' => $subscription_status, 'consent_source' => $consent_source, 'detected_meta_key' => $detected_meta_key, 'all_activetrail_meta' => $activetrail_meta ), 'debug'); // Generate external ID - always use email-based format (one-to-one) for consistency. $email = strtolower(trim($order->get_billing_email())); $external_id = 'WC-CONTACT-' . md5($email); $customer_id = $order->get_customer_id(); // Tracking consent (CNIL) - always sent as a boolean. Prefer the order's own // value; fall back to the customer's stored choice for registered users. $tracking_consent = ( get_post_meta( $order->get_id(), '_activetrail_tracking_consent', true ) === 'yes' ); if ( ! $tracking_consent && $customer_id ) { $tracking_consent = ( get_user_meta( $customer_id, '_activetrail_tracking_consent', true ) === 'yes' ); } return array( 'email' => $order->get_billing_email(), 'first_name' => $order->get_billing_first_name(), 'last_name' => $order->get_billing_last_name(), 'phone' => $order->get_billing_phone(), 'subscriptionStatus' => $subscription_status, 'tracking_consent' => $tracking_consent, // CNIL, always boolean 'registration_date' => $order->get_date_created()->format('Y-m-d H:i:s'), 'source' => 'woocommerce_historical_sync', 'externalId' => $external_id, 'externalName' => ACTIVETRAIL_EXTERNAL_NAME, 'user_id' => $customer_id, 'order_id' => $order->get_id(), 'consent_source' => $consent_source, 'detected_meta_key' => $detected_meta_key ); } /** * Start historical sync process * * @param array $options Sync options. * @return array */ public function start_historical_sync($options = array()) { $this->log('start_historical_sync() called with options: ' . wp_json_encode($options), array(), 'debug'); // Validate API configuration. if (!ActiveTrail_Settings::is_api_configured()) { $this->log('API key not configured, aborting historical sync', array(), 'error'); return array( 'success' => false, 'message' => esc_html__('API key not configured', 'activetrail-for-woocommerce') ); } // Set default options with sync_contacts and sync_orders flags. $options = wp_parse_args($options, array( 'date_from' => gmdate('Y-m-d H:i:s', strtotime('-1 year')), 'date_to' => current_time('mysql'), 'batch_size' => $this->batch_size, 'force_sync' => false, 'sync_contacts' => true, 'sync_orders' => true, )); // Normalize the range once, here, so every downstream query and every // string date comparison works against the same full datetimes. $options['date_from'] = $this->normalize_range_date($options['date_from'], false); $options['date_to'] = $this->normalize_range_date($options['date_to'], true); // Check if sync is already running. $sync_status = get_option('activetrail_historical_sync_status', array()); if (isset($sync_status['status']) && $sync_status['status'] === 'running') { // Cancel previous running sync to allow fresh start. $this->log('Historical sync already running. Cancelling previous sync to start a new one.', array('last_batch_at' => $sync_status['last_batch_at'] ?? null), 'warning'); // Cancel any scheduled batches and reset status. $sync_status['status'] = 'cancelled'; $sync_status['cancelled_at'] = current_time('mysql'); update_option('activetrail_historical_sync_status', $sync_status); wp_clear_scheduled_hook('activetrail_process_historical_sync_batch'); // Allow slight delay before restarting. sleep(1); } $total_contacts = 0; $total_orders = 0; $total_items = 0; // Counts only - COUNT queries, no records loaded. Previously this method // built the entire contact list (every user and every order as objects) // before replying, which exhausted PHP's memory/time limit on large stores // and killed the request, surfacing in the browser as "Network error:". $orders_in_range = $this->count_orders_in_range($options['date_from'], $options['date_to']); if ($options['sync_contacts']) { $total_contacts = $this->count_users_in_range($options['date_from'], $options['date_to']) + $orders_in_range; // Upper bound: consent filtering and email de-duplication happen while // walking the records, so the real figure can only be lower. The contacts // phase replaces this with the actual count when it finishes. $this->log("Estimated contacts to sync: {$total_contacts} (upper bound, before consent filtering)", array(), 'info'); } if ($options['sync_orders']) { $total_orders = $orders_in_range; $this->log("Total orders to sync: {$total_orders}", array(), 'debug'); } $total_items = $total_contacts + $total_orders; $this->log("Total items to sync (contacts + orders): {$total_items}", array(), 'info'); if ($total_items === 0) { // Build detailed message explaining why no items were found. $message_parts = array(); if ($options['sync_contacts'] && $options['sync_orders']) { $message_parts[] = esc_html__('No contacts or orders found', 'activetrail-for-woocommerce'); } elseif ($options['sync_contacts']) { $message_parts[] = esc_html__('No contacts found', 'activetrail-for-woocommerce'); } elseif ($options['sync_orders']) { $message_parts[] = esc_html__('No orders found', 'activetrail-for-woocommerce'); } $date_from_formatted = date_i18n(get_option('date_format'), strtotime($options['date_from'])); $date_to_formatted = date_i18n(get_option('date_format'), strtotime($options['date_to'])); $message = sprintf( /* translators: 1: sync type (contacts/orders), 2: start date, 3: end date */ esc_html__('%1$s for the date range %2$s to %3$s. Please check:', 'activetrail-for-woocommerce'), implode(' or ', $message_parts), $date_from_formatted, $date_to_formatted ); $message .= ''; $this->log('No items found for historical sync in given date range', array( 'date_from' => $options['date_from'], 'date_to' => $options['date_to'], 'sync_contacts' => $options['sync_contacts'], 'sync_orders' => $options['sync_orders'], 'total_contacts' => $total_contacts, 'total_orders' => $total_orders ), 'warning'); return array( 'success' => false, 'message' => $message, 'no_items' => true, 'details' => array( 'date_from' => $options['date_from'], 'date_to' => $options['date_to'], 'sync_contacts' => $options['sync_contacts'], 'sync_orders' => $options['sync_orders'], 'total_contacts' => $total_contacts, 'total_orders' => $total_orders ) ); } // Initialize sync status. $sync_status = array( 'status' => 'running', 'sync_phase' => 'contacts', // Start with contacts phase 'started_at' => current_time('mysql'), 'total_contacts' => $total_contacts, 'total_orders' => $total_orders, 'processed_contacts' => 0, 'processed_orders' => 0, 'successful_contacts' => 0, 'failed_contacts_count' => 0, 'failed_contacts' => 0, // Alias for failed_contacts_count for JavaScript compatibility 'successful_orders' => 0, 'failed_orders' => 0, 'successful_syncs' => 0, 'failed_syncs' => 0, 'current_batch' => 0, // Cursor for the paged contact walk: which source we are reading and how // far into it we are. Lets each batch resume without re-reading the rest. 'contacts_source' => 'users', 'contacts_source_offset' => 0, 'total_contacts_is_estimate' => true, 'options' => $options, 'errors' => array(), 'failed_contacts_list' => array() // Store individual failed contacts for CSV export ); update_option('activetrail_historical_sync_status', $sync_status); // Check if WordPress cron is disabled. $cron_disabled = defined('DISABLE_WP_CRON') && DISABLE_WP_CRON; if ($cron_disabled) { $this->log('WordPress cron is disabled - sync batches will run when WordPress cron is triggered manually or via system cron', array(), 'warning'); } // Try to schedule first batch. $this->log('Scheduling first batch for historical sync', array('batch' => 0), 'debug'); $scheduled = wp_schedule_single_event(time() + 5, 'activetrail_process_historical_sync_batch', array(0)); if ($scheduled === false) { $this->log('Failed to schedule cron event - sync may not run automatically', array(), 'error'); // Don't fail completely - just warn user. $sync_status['cron_warning'] = true; update_option('activetrail_historical_sync_status', $sync_status); } $this->log('Historical sync started', array( 'total_contacts' => $total_contacts, 'total_orders' => $total_orders, 'date_range' => $options['date_from'] . ' to ' . $options['date_to'] )); return array( 'success' => true, 'message' => sprintf( /* translators: %d: Total number of items to sync */ esc_html__('Historical sync started. Processing %d items (contacts + orders).', 'activetrail-for-woocommerce'), $total_items ), 'data' => $sync_status ); } /** * Process a batch of historical contacts and orders * * @param int $batch_number Batch number to process. */ public function process_historical_sync_batch($batch_number) { // Switch to plugin's language setting for translations. $locale_switched = $this->switch_to_plugin_language(); try { $this->log('process_historical_sync_batch() called for batch: ' . $batch_number, array(), 'debug'); $sync_status = get_option('activetrail_historical_sync_status', array()); // Check if sync was cancelled. if (!isset($sync_status['status']) || $sync_status['status'] !== 'running') { $this->log('Sync status is not running, exiting batch processing early', array('status' => $sync_status['status'] ?? 'unknown'), 'warning'); return; } $options = $sync_status['options']; $sync_phase = isset($sync_status['sync_phase']) ? $sync_status['sync_phase'] : 'contacts'; // Skip past any phase whose work is switched off. Without this a run with // sync_contacts disabled would sit in the contacts phase forever, because // the phase only advances from inside the (skipped) contacts block. if ('contacts' === $sync_phase && empty($options['sync_contacts'])) { $sync_phase = !empty($options['sync_orders']) ? 'orders' : 'completed'; $sync_status['sync_phase'] = $sync_phase; } if ('orders' === $sync_phase && empty($options['sync_orders'])) { $sync_phase = 'completed'; $sync_status['sync_phase'] = $sync_phase; } $batch_successful = 0; $batch_failed = 0; $batch_errors = array(); // PHASE 1: Process contacts. if ($sync_phase === 'contacts' && !empty($options['sync_contacts'])) { // Read one page from the current source (users first, then order billing // contacts). Only this page is ever held in memory. $contacts_source = isset($sync_status['contacts_source']) ? $sync_status['contacts_source'] : 'users'; $contacts_offset = isset($sync_status['contacts_source_offset']) ? (int) $sync_status['contacts_source_offset'] : 0; $contacts_page_size = $this->get_contact_page_size(); $page = $this->get_contacts_page( $contacts_source, $options['date_from'], $options['date_to'], $contacts_page_size, $contacts_offset ); $contacts = $page['contacts']; $scanned = (int) $page['scanned']; $source_exhausted = ($scanned < $contacts_page_size); // Advance the cursor by records read, not contacts kept - records filtered // out for missing consent must not be re-read on the next batch. $sync_status['contacts_source_offset'] = $contacts_offset + $scanned; $this->log('Processing contacts batch', array( 'source' => $contacts_source, 'scanned' => $scanned, 'kept' => count($contacts), 'offset' => $contacts_offset ), 'debug'); if (!empty($contacts)) { // Get group ID for batch import. $group_id = ActiveTrail_Settings::get('group_id'); if (empty($group_id)) { $this->log('No group_id configured, skipping contacts batch', array(), 'warning'); $batch_errors[] = array( 'type' => 'contacts', 'error' => esc_html__('Group ID is required for contact import', 'activetrail-for-woocommerce') ); } else { // Prepare contacts for batch import. $contacts_to_import = array(); foreach ($contacts as $contact_data) { // Ensure external ID is set. if (empty($contact_data['externalId'])) { if (function_exists('activetrail_external_id_for_contact')) { $user_id = isset($contact_data['user_id']) ? $contact_data['user_id'] : 0; $contact_data['externalId'] = activetrail_external_id_for_contact($user_id, $contact_data['email']); } } // Ensure external name is set. if (empty($contact_data['externalName'])) { $contact_data['externalName'] = ACTIVETRAIL_EXTERNAL_NAME; } $contacts_to_import[] = $contact_data; } // Import contacts in smaller chunks to avoid timeouts. $chunk_size = apply_filters('activetrail_historical_contacts_chunk_size', $this->contact_chunk_size); $contact_chunks = array_chunk($contacts_to_import, max(1, intval($chunk_size))); foreach ($contact_chunks as $chunk_index => $chunk_contacts) { try { $this->log('Importing contacts chunk', array( 'chunk_index' => $chunk_index, 'chunk_size' => count($chunk_contacts), 'total_contacts' => count($contacts_to_import) ), 'debug'); $result = $this->api_client->import_contacts_batch($chunk_contacts, intval($group_id)); if ($result['success']) { $success_count = isset($result['success_count']) ? intval($result['success_count']) : count($chunk_contacts); $failed_count = isset($result['failed_count']) ? intval($result['failed_count']) : 0; $batch_successful += $success_count; $batch_failed += $failed_count; // Mark users as synced (only for successful contacts). // Note: We mark all as synced because API doesn't tell us which specific contacts failed. // If failed_count > 0, we still mark all as processed to avoid infinite retries. // The failed_count is tracked separately for reporting. foreach ($chunk_contacts as $contact_data) { if (!empty($contact_data['user_id'])) { update_user_meta($contact_data['user_id'], '_activetrail_synced', 'yes'); update_user_meta($contact_data['user_id'], '_activetrail_sync_date', current_time('mysql')); } } if ($failed_count > 0) { // Extract detailed error information from API response if available. $api_data = isset($result['data']) ? $result['data'] : array(); $error_details = array(); // Check if API provides detailed failure information. if (isset($api_data['errors']) && is_array($api_data['errors'])) { $error_details['api_errors'] = $api_data['errors']; } if (isset($api_data['failed_contacts']) && is_array($api_data['failed_contacts'])) { $error_details['failed_contacts'] = $api_data['failed_contacts']; } // Extract error reason from API response. $error_reason = $this->extract_error_reason($api_data, esc_html__('Partial failure in batch import', 'activetrail-for-woocommerce')); // Add failed contacts to tracking (if API provides specific failed contacts). if (isset($api_data['failed_contacts']) && is_array($api_data['failed_contacts']) && !empty($api_data['failed_contacts'])) { // API provided specific failed contacts - use them. foreach ($api_data['failed_contacts'] as $failed_contact) { $email = ''; $user_id = ''; // Try to extract email from various possible structures. if (isset($failed_contact['email'])) { $email = $failed_contact['email']; } elseif (isset($failed_contact['contact']['email'])) { $email = $failed_contact['contact']['email']; } elseif (isset($failed_contact['contact']) && is_string($failed_contact['contact'])) { // Sometimes contact might be just the email string. $email = $failed_contact['contact']; } // Try to extract user_id. if (isset($failed_contact['user_id'])) { $user_id = $failed_contact['user_id']; } elseif (isset($failed_contact['contact']['user_id'])) { $user_id = $failed_contact['contact']['user_id']; } if (!empty($email)) { $contact_error_reason = isset($failed_contact['error']) ? $failed_contact['error'] : (isset($failed_contact['message']) ? $failed_contact['message'] : $error_reason); $this->add_failed_contact($email, $contact_error_reason, array( 'user_id' => $user_id, 'additional_info' => wp_json_encode($failed_contact) )); } } } else { // API doesn't provide specific failed contacts. // We can't know which specific ones failed, so we log a general error. // but don't mark individual contacts as failed (to avoid false positives). $this->log('Partial failure detected but API did not provide specific failed contacts', array( 'chunk_index' => $chunk_index, 'failed_count' => $failed_count, 'total_in_chunk' => count($chunk_contacts) ), 'warning'); } $this->log('Contacts chunk imported with partial failures', array( 'chunk_index' => $chunk_index, 'success_count' => $success_count, 'failed_count' => $failed_count, 'total_in_chunk' => count($chunk_contacts), 'api_response_data' => $api_data, 'error_details' => $error_details ), 'warning'); // Log partial failure as error for tracking. /* translators: 1: Number of succeeded items, 2: Number of failed items */ $error_message = sprintf(esc_html__('Partial failure: %1$d succeeded, %2$d failed', 'activetrail-for-woocommerce'), $success_count, $failed_count); if (!empty($error_details)) { $error_message .= ' - ' . esc_html__('Check logs for detailed error information', 'activetrail-for-woocommerce'); } $batch_errors[] = array( 'type' => 'contacts', 'error' => $error_message, 'chunk_index' => $chunk_index, 'success_count' => $success_count, 'failed_count' => $failed_count, 'error_details' => $error_details ); } else { $this->log('Contacts chunk imported successfully', array( 'chunk_index' => $chunk_index, 'success_count' => $success_count, 'failed_count' => $failed_count ), 'info'); } } else { $failed_contacts = count($chunk_contacts); $batch_failed += $failed_contacts; // Extract detailed error information from API response if available. $api_data = isset($result['data']) ? $result['data'] : array(); $error_message = isset($result['message']) ? $result['message'] : esc_html__('Unknown error', 'activetrail-for-woocommerce'); $error_reason = $this->extract_error_reason($api_data, $error_message); // Add all contacts in chunk as failed. foreach ($chunk_contacts as $contact_data) { $email = isset($contact_data['email']) ? $contact_data['email'] : ''; if (!empty($email)) { $this->add_failed_contact($email, $error_reason, array( 'user_id' => isset($contact_data['user_id']) ? $contact_data['user_id'] : '', 'additional_info' => esc_html__('Full chunk import failed', 'activetrail-for-woocommerce') )); } } $this->log('Contacts chunk import failed', array( 'chunk_index' => $chunk_index, 'error' => $error_message, 'api_response_data' => $api_data, 'chunk_size' => count($chunk_contacts), 'sample_emails' => array_slice(array_column($chunk_contacts, 'email'), 0, 5) // Log first 5 emails for debugging ), 'error'); $batch_errors[] = array( 'type' => 'contacts', 'error' => $error_message, 'chunk_index' => $chunk_index, 'api_response_data' => $api_data ); } } catch (Exception $e) { $failed_contacts = count($chunk_contacts); $batch_failed += $failed_contacts; // Extract sample emails for debugging. $sample_emails = array_slice(array_column($chunk_contacts, 'email'), 0, 5); /* translators: %s: Exception message */ $error_reason = sprintf(esc_html__('Exception: %s', 'activetrail-for-woocommerce'), $e->getMessage()); // Add all contacts in chunk as failed. foreach ($chunk_contacts as $contact_data) { $email = isset($contact_data['email']) ? $contact_data['email'] : ''; if (!empty($email)) { $this->add_failed_contact($email, $error_reason, array( 'user_id' => isset($contact_data['user_id']) ? $contact_data['user_id'] : '', /* translators: %s: Exception class name */ 'additional_info' => sprintf(esc_html__('Exception type: %s', 'activetrail-for-woocommerce'), get_class($e)) )); } } $this->log('Exception during contacts chunk import', array( 'chunk_index' => $chunk_index, 'exception' => $e->getMessage(), 'exception_type' => get_class($e), 'chunk_size' => count($chunk_contacts), 'sample_emails' => $sample_emails, 'trace' => $e->getTraceAsString() ), 'error'); $batch_errors[] = array( 'type' => 'contacts', 'error' => $e->getMessage(), 'chunk_index' => $chunk_index, 'exception_type' => get_class($e), 'chunk_size' => count($chunk_contacts) ); } } } $sync_status['processed_contacts'] += count($contacts); } // Source exhausted: move to the next source, or finish the phase. // Driven by the cursor, not by the estimated total - the estimate is an // upper bound and would otherwise cut the phase short. if ($source_exhausted && 'users' === $contacts_source) { $sync_status['contacts_source'] = 'orders'; $sync_status['contacts_source_offset'] = 0; $this->log('Users contact source exhausted, switching to order contacts', array( 'processed_contacts' => $sync_status['processed_contacts'] ), 'info'); } elseif ($source_exhausted) { // Both sources done - the running tally is now the real total. $sync_status['total_contacts'] = $sync_status['processed_contacts']; $sync_status['total_contacts_is_estimate'] = false; $this->log('Contacts phase completed, moving to orders phase', array( 'processed_contacts' => $sync_status['processed_contacts'], 'total_contacts' => $sync_status['total_contacts'] ), 'info'); // Move to orders phase if orders sync is enabled. if (!empty($options['sync_orders'])) { $sync_status['sync_phase'] = 'orders'; $sync_status['current_batch'] = 0; // Reset batch counter for orders phase } else { // Nothing left to do. Mark the phase finished and fall through to the // shared tail below, which records this batch's success/failure counts // and then calls finish_historical_sync(). Returning early here (as the // previous code did) discarded the final batch's counters, which is the // whole report in a "Sync Contacts Only" run. $sync_status['sync_phase'] = 'completed'; } } } // PHASE 2: Process orders (only after contacts phase is complete). if ($sync_phase === 'orders' && !empty($options['sync_orders'])) { $orders_batch_size = $options['batch_size']; // Use configured batch size for orders $orders_offset = $sync_status['processed_orders'] ?? 0; $orders = $this->get_historical_orders( $options['date_from'], $options['date_to'], $orders_batch_size, $orders_offset ); // A short page means the order source is exhausted. Used below so the // phase can end even if the total drifts (orders deleted or their status // changed mid-sync), which would otherwise reschedule batches forever. $orders_source_exhausted = (count($orders) < $orders_batch_size); $this->log('Processing orders batch', array('count' => count($orders), 'offset' => $orders_offset), 'debug'); foreach ($orders as $order) { try { $resolved_order = $this->resolve_order_with_billing_details($order); if (!$resolved_order) { $batch_failed++; $this->log('Skipping order sync - unable to resolve billing details', array('order_id' => method_exists($order, 'get_id') ? $order->get_id() : null, 'order_type' => is_object($order) ? get_class($order) : gettype($order)), 'warning'); continue; } // Check if order status should trigger events (respect status filter). $order_status = $resolved_order->get_status(); $settings = ActiveTrail_Settings::get_all(); $enabled_statuses = isset($settings['order_statuses']) ? $settings['order_statuses'] : array(); // Backward compatibility: if empty array, treat as all enabled. if (!empty($enabled_statuses)) { $normalized_status = str_replace('wc-', '', $order_status); if (!in_array($normalized_status, $enabled_statuses, true)) { $this->log('Skipping order sync - status not enabled: ' . $order_status, array('order_id' => $resolved_order->get_id()), 'debug'); continue; } } // Prepare order data using API client's transformer. $order_data = $this->api_client->transform_wc_order_to_activetrail($resolved_order); // Get external ID - use same format as contacts phase (email-based, one-to-one). $customer_email = strtolower(trim($resolved_order->get_billing_email())); $external_id = 'WC-CONTACT-' . md5($customer_email); $order_data['externalId'] = $external_id; $order_data['externalName'] = ACTIVETRAIL_EXTERNAL_NAME; // Add group ID if configured. $group_id = ActiveTrail_Settings::get('group_id'); if (!empty($group_id)) { $order_data['groupId'] = intval($group_id); } // Sync order using API client. $result = $this->api_client->submit_order($order_data); $order_id = $resolved_order->get_id(); $this->log('Order sync result', array('order_id' => $order_id, 'result' => $result), 'debug'); if ($result['success']) { $batch_successful++; // Mark order as synced. $order->update_meta_data('_activetrail_synced', 'yes'); $order->update_meta_data('_activetrail_sync_date', current_time('mysql')); $order->save(); } else { $batch_failed++; // Extract detailed error information from API response if available. $api_data = isset($result['data']) ? $result['data'] : array(); $error_message = isset($result['message']) ? $result['message'] : esc_html__('Unknown error', 'activetrail-for-woocommerce'); $this->log('Order sync failed', array( 'order_id' => $order_id, 'error' => $error_message, 'api_response_data' => $api_data, 'order_email' => $resolved_order->get_billing_email(), 'order_total' => $resolved_order->get_total(), 'order_date' => $resolved_order->get_date_created() ? $resolved_order->get_date_created()->format('Y-m-d H:i:s') : 'N/A' ), 'error'); $batch_errors[] = array( 'type' => 'order', 'order_id' => $order_id, 'error' => $error_message, 'api_response_data' => $api_data, 'order_email' => $resolved_order->get_billing_email() ); } usleep(100000); // 0.1 second between orders } catch (Exception $e) { $batch_failed++; $log_order_id = isset($resolved_order) && is_object($resolved_order) && method_exists($resolved_order, 'get_id') ? $resolved_order->get_id() : (is_object($order) && method_exists($order, 'get_id') ? $order->get_id() : null); $error_details = array( 'order_id' => $log_order_id, 'exception' => $e->getMessage(), 'exception_type' => get_class($e), 'trace' => $e->getTraceAsString() ); // Add order details if available. if (isset($resolved_order) && is_object($resolved_order)) { $error_details['order_email'] = method_exists($resolved_order, 'get_billing_email') ? $resolved_order->get_billing_email() : 'N/A'; $error_details['order_total'] = method_exists($resolved_order, 'get_total') ? $resolved_order->get_total() : 'N/A'; } $this->log('Exception during order sync', $error_details, 'error'); $batch_errors[] = array( 'type' => 'order', 'order_id' => $log_order_id, 'error' => $e->getMessage(), 'exception_type' => get_class($e) ); } } $sync_status['processed_orders'] += count($orders); } // Update sync status. $sync_status['successful_syncs'] += $batch_successful; $sync_status['failed_syncs'] += $batch_failed; // Track contacts and orders separately. if ( 'contacts' === $sync_phase ) { if (!isset($sync_status['successful_contacts'])) { $sync_status['successful_contacts'] = 0; } if (!isset($sync_status['failed_contacts_count'])) { $sync_status['failed_contacts_count'] = 0; } $sync_status['successful_contacts'] += $batch_successful; $sync_status['failed_contacts_count'] += $batch_failed; // Keep failed_contacts in sync with failed_contacts_count for JavaScript compatibility $sync_status['failed_contacts'] = $sync_status['failed_contacts_count']; } elseif ( 'orders' === $sync_phase ) { if (!isset($sync_status['successful_orders'])) { $sync_status['successful_orders'] = 0; } if (!isset($sync_status['failed_orders'])) { $sync_status['failed_orders'] = 0; } $sync_status['successful_orders'] += $batch_successful; $sync_status['failed_orders'] += $batch_failed; } $sync_status['current_batch'] = $batch_number + 1; $sync_status['last_batch_at'] = current_time('mysql'); // Add errors (keep only last 50 errors to avoid memory issues). $sync_status['errors'] = array_merge($sync_status['errors'], $batch_errors); if (count($sync_status['errors']) > 50) { $sync_status['errors'] = array_slice($sync_status['errors'], -50); } update_option('activetrail_historical_sync_status', $sync_status); $this->log('Historical sync batch processed', array( 'batch_number' => $batch_number, 'sync_phase' => $sync_status['sync_phase'], 'contacts_processed' => $sync_status['processed_contacts'], 'orders_processed' => $sync_status['processed_orders'], 'successful' => $batch_successful, 'failed' => $batch_failed )); // Determine if we need to continue. $should_continue = false; if ( 'contacts' === $sync_status['sync_phase'] ) { // Still in the contacts phase means at least one source has more records. // Driven by the cursor rather than the total, because total_contacts is an // upper bound until the phase completes - comparing against it would end // the phase early whenever consent filtering removed records. $should_continue = true; } elseif ( 'orders' === $sync_status['sync_phase'] ) { // Continue if there are more orders to process and the source still has rows. $orders_exhausted = isset($orders_source_exhausted) ? $orders_source_exhausted : false; $should_continue = (!$orders_exhausted && $sync_status['processed_orders'] < $sync_status['total_orders']); } if ($should_continue) { $this->log('Scheduling next batch', array('next_batch' => $batch_number + 1, 'phase' => $sync_status['sync_phase']), 'debug'); // Schedule next batch. $scheduled = wp_schedule_single_event(time() + 10, 'activetrail_process_historical_sync_batch', array($batch_number + 1)); if ($scheduled === false) { $this->log('Failed to schedule next batch via cron - sync may stop', array(), 'error'); // Mark sync as having issues but don't stop it completely. $sync_status['cron_warning'] = true; update_option('activetrail_historical_sync_status', $sync_status); } } else { $this->log('All batches processed, finishing sync', array('phase' => $sync_status['sync_phase']), 'debug'); // All items processed - finish sync. $this->finish_historical_sync(); } } finally { // Restore original locale. if ($locale_switched) { $this->restore_plugin_language(); } } } /** * Finish historical sync process */ private function finish_historical_sync() { $sync_status = get_option('activetrail_historical_sync_status', array()); $sync_status['status'] = 'completed'; $sync_status['completed_at'] = current_time('mysql'); // Generate CSV file with failed contacts if there are any. if (!empty($sync_status['failed_contacts_list']) && is_array($sync_status['failed_contacts_list'])) { $csv_path = $this->generate_failed_contacts_csv($sync_status['failed_contacts_list']); if ($csv_path) { $this->log('Generated failed contacts CSV file', array( 'filepath' => $csv_path, 'failed_count' => count($sync_status['failed_contacts_list']) ), 'info'); } } update_option('activetrail_historical_sync_status', $sync_status); $this->log('Historical sync completed', array( 'total_processed' => $sync_status['processed_contacts'], 'successful' => $sync_status['successful_syncs'], 'failed' => $sync_status['failed_syncs'], 'failed_contacts_count' => isset($sync_status['failed_contacts_count']) ? $sync_status['failed_contacts_count'] : 0 )); // Store sync history. $this->store_sync_history($sync_status); } /** * Store sync history for future reference * * @param array $sync_status Final sync status. */ private function store_sync_history($sync_status) { $history = get_option('activetrail_historical_sync_history', array()); $history[] = array( 'started_at' => $sync_status['started_at'], 'completed_at' => $sync_status['completed_at'], 'total_contacts' => $sync_status['total_contacts'], 'successful_syncs' => $sync_status['successful_syncs'], 'failed_syncs' => $sync_status['failed_syncs'], 'date_range' => $sync_status['options']['date_from'] . ' to ' . $sync_status['options']['date_to'] ); // Keep only last 10 sync records. if (count($history) > 10) { $history = array_slice($history, -10); } update_option('activetrail_historical_sync_history', $history); } /** * Cancel historical sync process * * @return array */ public function cancel_historical_sync() { $sync_status = get_option('activetrail_historical_sync_status', array()); if (!isset($sync_status['status']) || $sync_status['status'] !== 'running') { return array( 'success' => false, 'message' => esc_html__('No active sync to cancel', 'activetrail-for-woocommerce') ); } $sync_status['status'] = 'cancelled'; $sync_status['cancelled_at'] = current_time('mysql'); update_option('activetrail_historical_sync_status', $sync_status); // Clear any scheduled batches. wp_clear_scheduled_hook('activetrail_process_historical_sync_batch'); $this->log('Historical sync cancelled', array( 'processed_before_cancel' => $sync_status['processed_contacts'], 'successful_before_cancel' => $sync_status['successful_syncs'] )); return array( 'success' => true, 'message' => esc_html__('Historical sync cancelled successfully', 'activetrail-for-woocommerce') ); } /** * Get current sync progress * * @return array */ public function get_sync_progress() { $sync_status = get_option('activetrail_historical_sync_status', array()); if (empty($sync_status)) { return array( 'status' => 'none', 'message' => esc_html__('No sync has been started', 'activetrail-for-woocommerce') ); } $sync_phase = isset($sync_status['sync_phase']) ? $sync_status['sync_phase'] : 'contacts'; // Ensure all required fields are set with default values. $sync_status['total_contacts'] = isset($sync_status['total_contacts']) ? (int) $sync_status['total_contacts'] : 0; $sync_status['processed_contacts'] = isset($sync_status['processed_contacts']) ? (int) $sync_status['processed_contacts'] : 0; $sync_status['total_orders'] = isset($sync_status['total_orders']) ? (int) $sync_status['total_orders'] : 0; $sync_status['processed_orders'] = isset($sync_status['processed_orders']) ? (int) $sync_status['processed_orders'] : 0; $sync_status['successful_contacts'] = isset($sync_status['successful_contacts']) ? (int) $sync_status['successful_contacts'] : 0; $sync_status['failed_contacts'] = isset($sync_status['failed_contacts_count']) ? (int) $sync_status['failed_contacts_count'] : (isset($sync_status['failed_contacts']) ? (int) $sync_status['failed_contacts'] : 0); $sync_status['successful_orders'] = isset($sync_status['successful_orders']) ? (int) $sync_status['successful_orders'] : 0; $sync_status['failed_orders'] = isset($sync_status['failed_orders']) ? (int) $sync_status['failed_orders'] : 0; $sync_status['successful_syncs'] = isset($sync_status['successful_syncs']) ? (int) $sync_status['successful_syncs'] : 0; $sync_status['failed_syncs'] = isset($sync_status['failed_syncs']) ? (int) $sync_status['failed_syncs'] : 0; // Calculate progress percentage based on current phase. $progress_percentage = 0; $contacts_progress = 0; $orders_progress = 0; if ($sync_status['total_contacts'] > 0) { $contacts_progress = round(($sync_status['processed_contacts'] / $sync_status['total_contacts']) * 100, 1); } if ($sync_status['total_orders'] > 0) { $orders_progress = round(($sync_status['processed_orders'] / $sync_status['total_orders']) * 100, 1); } // Overall progress: weighted average (contacts + orders). $total_items = $sync_status['total_contacts'] + $sync_status['total_orders']; if ($total_items > 0) { $processed_items = $sync_status['processed_contacts'] + $sync_status['processed_orders']; $progress_percentage = round(($processed_items / $total_items) * 100, 1); } $sync_status['progress_percentage'] = $progress_percentage; $sync_status['contacts_progress'] = $contacts_progress; $sync_status['orders_progress'] = $orders_progress; $sync_status['sync_phase'] = $sync_phase; // Keep failed_contacts in sync with failed_contacts_count for JavaScript compatibility if (isset($sync_status['failed_contacts_count'])) { $sync_status['failed_contacts'] = $sync_status['failed_contacts_count']; } elseif (!isset($sync_status['failed_contacts'])) { $sync_status['failed_contacts'] = 0; } // Include failed contacts CSV info if available. if (isset($sync_status['failed_contacts_csv'])) { // CSV info is already in sync_status. } return $sync_status; } /** * Offer historical sync when API key changes */ public function offer_historical_sync() { // Set a flag to show the historical sync offer in admin. update_option('activetrail_show_historical_sync_offer', true); } /** * AJAX handler for starting historical sync */ public function ajax_start_historical_sync() { try { check_ajax_referer('activetrail_admin_nonce', 'nonce'); } catch (Exception $e) { wp_send_json_error(array( 'success' => false, 'message' => esc_html__('Security check failed', 'activetrail-for-woocommerce') )); return; } if (!current_user_can('manage_options')) { wp_send_json_error(array( 'success' => false, 'message' => esc_html__('Insufficient permissions', 'activetrail-for-woocommerce') )); return; } // Check if WordPress cron is disabled and warn user. $cron_disabled = defined('DISABLE_WP_CRON') && DISABLE_WP_CRON; if ($cron_disabled) { $this->log('Historical sync started with WordPress cron disabled', array(), 'warning'); } $date_from = isset($_POST['date_from']) ? sanitize_text_field(wp_unslash($_POST['date_from'])) : ''; $date_to = isset($_POST['date_to']) ? sanitize_text_field(wp_unslash($_POST['date_to'])) : ''; $force_sync = isset($_POST['force_sync']) && sanitize_text_field(wp_unslash($_POST['force_sync'])); $sync_contacts = isset($_POST['sync_contacts']) ? filter_var(wp_unslash($_POST['sync_contacts']), FILTER_VALIDATE_BOOLEAN) : true; $sync_orders = isset($_POST['sync_orders']) ? filter_var(wp_unslash($_POST['sync_orders']), FILTER_VALIDATE_BOOLEAN) : false; $options = array(); if (!empty($date_from)) { $options['date_from'] = $date_from; } if (!empty($date_to)) { $options['date_to'] = $date_to; } $options['force_sync'] = $force_sync; $options['sync_contacts'] = $sync_contacts; $options['sync_orders'] = $sync_orders; try { $result = $this->start_historical_sync($options); if ($result && isset($result['success']) && $result['success']) { // Add cron status to response. if ($cron_disabled) { $result['cron_disabled'] = true; $result['message'] .= ' ' . esc_html__('Note: WordPress cron is disabled, using alternative processing method.', 'activetrail-for-woocommerce'); } wp_send_json_success($result); } else { $error_message = isset($result['message']) ? $result['message'] : esc_html__('Failed to start historical sync', 'activetrail-for-woocommerce'); wp_send_json_error(array( 'success' => false, 'message' => $error_message )); } } catch (Exception $e) { $this->log('Exception in ajax_start_historical_sync', array('error' => $e->getMessage(), 'trace' => $e->getTraceAsString()), 'error'); wp_send_json_error(array( 'success' => false, 'message' => esc_html__('Error starting historical sync: ', 'activetrail-for-woocommerce') . $e->getMessage() )); } catch (Error $e) { // PHP 7+ Error handling. $this->log('Fatal error in ajax_start_historical_sync', array('error' => $e->getMessage(), 'trace' => $e->getTraceAsString()), 'error'); wp_send_json_error(array( 'success' => false, 'message' => esc_html__('Fatal error starting historical sync: ', 'activetrail-for-woocommerce') . $e->getMessage() )); } } /** * AJAX handler for getting sync progress */ public function ajax_get_sync_progress() { try { check_ajax_referer('activetrail_admin_nonce', 'nonce'); } catch (Exception $e) { wp_send_json_error(array( 'success' => false, 'message' => esc_html__('Security check failed', 'activetrail-for-woocommerce') )); return; } if (!current_user_can('manage_options')) { wp_send_json_error(array( 'success' => false, 'message' => esc_html__('Insufficient permissions', 'activetrail-for-woocommerce') )); return; } try { $progress = $this->get_sync_progress(); wp_send_json_success($progress); } catch (Exception $e) { $this->log('Exception in ajax_get_sync_progress', array('error' => $e->getMessage(), 'trace' => $e->getTraceAsString()), 'error'); wp_send_json_error(array( 'success' => false, 'message' => esc_html__('Error getting sync progress: ', 'activetrail-for-woocommerce') . $e->getMessage() )); } catch (Error $e) { // PHP 7+ Error handling. $this->log('Fatal error in ajax_get_sync_progress', array('error' => $e->getMessage(), 'trace' => $e->getTraceAsString()), 'error'); wp_send_json_error(array( 'success' => false, 'message' => esc_html__('Fatal error getting sync progress: ', 'activetrail-for-woocommerce') . $e->getMessage() )); } } /** * AJAX handler for cancelling historical sync */ public function ajax_cancel_historical_sync() { check_ajax_referer('activetrail_admin_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_die(esc_html__('Insufficient permissions', 'activetrail-for-woocommerce')); } $result = $this->cancel_historical_sync(); if ($result['success']) { wp_send_json_success($result); } else { wp_send_json_error($result); } } /** * Add failed contact to tracking array * * @param string $email Contact email. * @param string $error_reason Error reason/message. * @param array $additional_data Additional data about the failure. */ private function add_failed_contact($email, $error_reason, $additional_data = array()) { $sync_status = get_option('activetrail_historical_sync_status', array()); if (!isset($sync_status['failed_contacts_list'])) { $sync_status['failed_contacts_list'] = array(); } // Avoid duplicates - check if email already exists. $email_lower = strtolower(trim($email)); $exists = false; foreach ($sync_status['failed_contacts_list'] as $index => $failed) { if (strtolower(trim($failed['email'])) === $email_lower) { // Update existing entry if we have more details. if (!empty($error_reason) && empty($failed['error_reason'])) { $sync_status['failed_contacts_list'][$index]['error_reason'] = $error_reason; } if (!empty($additional_data)) { $sync_status['failed_contacts_list'][$index] = array_merge($sync_status['failed_contacts_list'][$index], $additional_data); } $exists = true; break; } } if (!$exists) { $sync_status['failed_contacts_list'][] = array_merge(array( 'email' => $email, 'error_reason' => $error_reason, 'failed_at' => current_time('mysql') ), $additional_data); } update_option('activetrail_historical_sync_status', $sync_status); } /** * Generate CSV file with failed contacts * * @param array $failed_contacts Array of failed contacts. * @return string|false File path on success, false on failure */ private function generate_failed_contacts_csv($failed_contacts) { if (empty($failed_contacts)) { return false; } $upload_dir = wp_upload_dir(); if ($upload_dir['error']) { $this->log('Failed to get upload directory', array('error' => $upload_dir['error']), 'error'); return false; } // Create activetrail directory if it doesn't exist. $activetrail_dir = $upload_dir['basedir'] . '/activetrail'; if (!file_exists($activetrail_dir)) { wp_mkdir_p($activetrail_dir); } // Generate filename with timestamp. $timestamp = gmdate('Y-m-d_H-i-s'); $filename = 'failed_contacts_' . $timestamp . '.csv'; $filepath = $activetrail_dir . '/' . $filename; // Open file for writing. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- fputcsv requires native fopen $file = fopen($filepath, 'w'); if (!$file) { $this->log('Failed to create CSV file', array('filepath' => $filepath), 'error'); return false; } // Add BOM for UTF-8 (helps Excel display Hebrew correctly). fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF)); // Write CSV header. $headers = array( esc_html__('Email', 'activetrail-for-woocommerce'), esc_html__('Error Reason', 'activetrail-for-woocommerce'), esc_html__('Failed At', 'activetrail-for-woocommerce'), esc_html__('User ID', 'activetrail-for-woocommerce'), esc_html__('Additional Info', 'activetrail-for-woocommerce') ); fputcsv($file, $headers); // Write failed contacts data. foreach ($failed_contacts as $failed) { $row = array( isset($failed['email']) ? $failed['email'] : '', isset($failed['error_reason']) ? $failed['error_reason'] : '', isset($failed['failed_at']) ? $failed['failed_at'] : '', isset($failed['user_id']) ? $failed['user_id'] : '', isset($failed['additional_info']) ? $failed['additional_info'] : '' ); fputcsv($file, $row); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- fputcsv requires native fclose fclose($file); // Store file path in sync status. $sync_status = get_option('activetrail_historical_sync_status', array()); $sync_status['failed_contacts_csv'] = array( 'filename' => $filename, 'filepath' => $filepath, 'url' => $upload_dir['baseurl'] . '/activetrail/' . $filename, 'count' => count($failed_contacts) ); update_option('activetrail_historical_sync_status', $sync_status); $this->log('Generated failed contacts CSV', array( 'filepath' => $filepath, 'count' => count($failed_contacts) ), 'info'); return $filepath; } /** * Extract error reason from API response * * @param array $api_data API response data. * @param string $default_message Default error message. * @return string Error reason */ private function extract_error_reason($api_data, $default_message = '') { $error_reason = $default_message; // Check for common error patterns in API response. // ActiveTrail API may return errors in different formats. if (isset($api_data['Message'])) { // ActiveTrail sometimes uses capital M. $error_reason = $api_data['Message']; } elseif (isset($api_data['message'])) { $error_reason = $api_data['message']; } elseif (isset($api_data['Error'])) { // ActiveTrail sometimes uses capital E. $error_reason = is_array($api_data['Error']) ? wp_json_encode($api_data['Error']) : $api_data['Error']; } elseif (isset($api_data['error'])) { $error_reason = is_array($api_data['error']) ? wp_json_encode($api_data['error']) : $api_data['error']; } elseif (isset($api_data['errors']) && is_array($api_data['errors'])) { $error_messages = array(); foreach ($api_data['errors'] as $error) { if (is_array($error)) { // Check for nested error structures. if (isset($error['message'])) { $error_messages[] = $error['message']; } elseif (isset($error['Message'])) { $error_messages[] = $error['Message']; } elseif (isset($error['error'])) { $error_messages[] = is_array($error['error']) ? wp_json_encode($error['error']) : $error['error']; } else { $error_messages[] = wp_json_encode($error); } } else { $error_messages[] = $error; } } $error_reason = implode('; ', $error_messages); } elseif (isset($api_data['ErrorMessage'])) { $error_reason = $api_data['ErrorMessage']; } elseif (isset($api_data['errorMessage'])) { $error_reason = $api_data['errorMessage']; } // Normalize error reason to lowercase for pattern matching. $error_reason_lower = strtolower($error_reason); // Check for specific error types and provide user-friendly messages. // 1. Contact already exists / Duplicate. if (stripos($error_reason, 'already exists') !== false || stripos($error_reason, 'duplicate') !== false || stripos($error_reason, 'כבר קיים') !== false || stripos($error_reason, 'exists') !== false || stripos($error_reason, 'already a member') !== false) { $error_reason = esc_html__('Contact already exists in ActiveTrail', 'activetrail-for-woocommerce'); } // 2. Email validation errors. elseif (stripos($error_reason, 'invalid email') !== false || stripos($error_reason, 'email is required') !== false || stripos($error_reason, 'email format') !== false || stripos($error_reason, 'email address') !== false || (stripos($error_reason, 'email') !== false && stripos($error_reason, 'invalid') !== false)) { $error_reason = esc_html__('Invalid email address', 'activetrail-for-woocommerce'); } // 3. Validation errors. elseif (stripos($error_reason, 'validation') !== false || stripos($error_reason, 'validate') !== false || stripos($error_reason, 'invalid') !== false) { $error_reason = esc_html__('Validation error', 'activetrail-for-woocommerce'); } // 4. API authentication errors. elseif (stripos($error_reason, 'unauthorized') !== false || stripos($error_reason, 'authentication') !== false || stripos($error_reason, 'api key') !== false || stripos($error_reason, 'forbidden') !== false || stripos($error_reason, '401') !== false || stripos($error_reason, '403') !== false) { $error_reason = esc_html__('API authentication failed', 'activetrail-for-woocommerce'); } // 5. Rate limiting / Too many requests. elseif (stripos($error_reason, 'rate limit') !== false || stripos($error_reason, 'too many requests') !== false || stripos($error_reason, '429') !== false || stripos($error_reason, 'limit') !== false) { $error_reason = esc_html__('API rate limit exceeded', 'activetrail-for-woocommerce'); } // 6. Server errors. elseif (stripos($error_reason, '500') !== false || stripos($error_reason, '502') !== false || stripos($error_reason, '503') !== false || stripos($error_reason, '504') !== false || stripos($error_reason, 'server error') !== false || stripos($error_reason, 'internal server') !== false) { $error_reason = esc_html__('ActiveTrail server error', 'activetrail-for-woocommerce'); } // 7. Network/Connection errors. elseif (stripos($error_reason, 'connection') !== false || stripos($error_reason, 'timeout') !== false || stripos($error_reason, 'network') !== false || stripos($error_reason, 'curl') !== false) { $error_reason = esc_html__('Network connection error', 'activetrail-for-woocommerce'); } // 8. Missing required fields. elseif (stripos($error_reason, 'required') !== false || stripos($error_reason, 'missing') !== false || stripos($error_reason, 'mandatory') !== false) { $error_reason = esc_html__('Missing required field', 'activetrail-for-woocommerce'); } // 9. Group/List errors. elseif (stripos($error_reason, 'group') !== false || stripos($error_reason, 'list') !== false) { $error_reason = esc_html__('Group/List error', 'activetrail-for-woocommerce'); } // 10. Import limit errors. elseif (stripos($error_reason, 'import limit') !== false || stripos($error_reason, 'limit reached') !== false || stripos($error_reason, 'limited to') !== false) { $error_reason = esc_html__('ActiveTrail import limit reached', 'activetrail-for-woocommerce'); } // If we still have the default message or empty, return unknown error. if (empty($error_reason) || $error_reason === $default_message) { // If we have some API data, try to extract more info. if (!empty($api_data)) { // Try to get any error-related field. foreach (array('error', 'Error', 'errors', 'Errors', 'message', 'Message', 'errorMessage', 'ErrorMessage') as $field) { if (isset($api_data[$field]) && !empty($api_data[$field])) { $extracted = is_array($api_data[$field]) ? wp_json_encode($api_data[$field]) : $api_data[$field]; if ($extracted !== $error_reason) { $error_reason = $extracted; break; } } } } // If still empty or default, return unknown. if (empty($error_reason) || $error_reason === $default_message) { $error_reason = esc_html__('Unknown error', 'activetrail-for-woocommerce'); } } return $error_reason; } /** * Switch to plugin's configured language * * @return bool True if locale was switched, false otherwise */ private function switch_to_plugin_language() { $current_language = ActiveTrail_Settings::get_current_language(); if (empty($current_language)) { return false; } // Support WPML if installed. if (function_exists('icl_object_id') && function_exists('do_action')) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- WPML core global variable global $sitepress; if ($sitepress) { // Convert language code format if needed (e.g., en_US -> en). $wpml_lang = strstr($current_language, '_', true) ?: $current_language; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML hook do_action('wpml_switch_language', $wpml_lang); } } // Support Polylang if installed. if (function_exists('pll_current_language') && function_exists('PLL')) { $polylang = PLL(); if ($polylang) { // Convert language code format if needed. $pll_lang = strstr($current_language, '_', true) ?: $current_language; $polylang->curlang = $polylang->model->get_language($pll_lang); if (!$polylang->curlang) { // Try full code. $polylang->curlang = $polylang->model->get_language($current_language); } } } // Switch WordPress locale for text domain translations. if (function_exists('switch_to_locale')) { return switch_to_locale($current_language); } return false; } /** * Restore original locale */ private function restore_plugin_language() { // Restore WordPress locale. Pop a single level where possible: the paged // sync reads run inside an outer switch, and restore_current_locale() would // discard the whole stack and drop the outer switch too. if (function_exists('restore_previous_locale')) { restore_previous_locale(); } elseif (function_exists('restore_current_locale')) { restore_current_locale(); } // WPML and Polylang don't need explicit restoration as they handle it automatically. } /** * Log message * * @param string $message Message to log. * @param array $data Additional data. * @param string $level Log level. */ private function log($message, $data = array(), $level = 'info') { // Always log to WordPress error log for debugging. $log_message = '[ActiveTrail Historical Sync] ' . $message; if (!empty($data)) { $log_message .= ' - ' . wp_json_encode($data); } // Use the ActiveTrail logger if available. if (class_exists('ActiveTrail_Logger')) { $logger = ActiveTrail_Logger::get_instance(); switch (strtolower($level)) { case 'error': $logger->error($message, 'HISTORICAL_SYNC', $data); break; case 'warning': $logger->warning($message, 'HISTORICAL_SYNC', $data); break; case 'debug': $logger->debug($message, 'HISTORICAL_SYNC', $data); break; default: $logger->info($message, 'HISTORICAL_SYNC', $data); break; } } } }