*/ private $orders_currently_syncing = array(); /** * Constructor * * @param ActiveTrail_API_Client $api_client API client instance. * @param array $settings Plugin settings. */ public function __construct($api_client, $settings) { $this->api_client = $api_client; $this->settings = $settings; $this->init_hooks(); } /** * Initialize WordPress hooks */ private function init_hooks() { // Order status change hooks. add_action('woocommerce_order_status_changed', array($this, 'handle_order_status_change'), 10, 4); // New order hooks - multiple hooks to ensure we catch all orders. add_action('woocommerce_new_order', array($this, 'handle_new_order'), 10, 2); add_action('woocommerce_checkout_order_processed', array($this, 'handle_new_order'), 10, 3); add_action('woocommerce_payment_complete', array($this, 'handle_payment_complete'), 10, 1); // Checkout completed hook. add_action('woocommerce_checkout_order_processed', array($this, 'handle_checkout_completed'), 10, 3); // Customer registration hook. add_action('woocommerce_created_customer', array($this, 'handle_customer_registration'), 10, 3); // Additional hooks for order creation from admin. add_action('woocommerce_admin_order_data_after_billing_address', array($this, 'handle_admin_order_created'), 10, 1); add_action('woocommerce_order_created', array($this, 'handle_order_created'), 10, 1); add_action('woocommerce_order_status_processing', array($this, 'handle_order_status_processing'), 10, 1); } /** * Check if order status should trigger events * * @param string $status Order status (with or without 'wc-' prefix). * @return bool True if status should trigger events. */ private function should_trigger_status_event($status) { // Get settings from ActiveTrail_Settings class. $settings = ActiveTrail_Settings::get_all(); // Get enabled statuses. $enabled_statuses = isset($settings['order_statuses']) ? $settings['order_statuses'] : array(); // Backward compatibility: if empty array, treat as all enabled. if (empty($enabled_statuses)) { return true; } // Ensure status comparison works correctly by removing 'wc-' prefix if present. $normalized_status = str_replace('wc-', '', $status); return in_array($normalized_status, $enabled_statuses, true); } /** * Check if order status event was already sent (deduplication) * Uses transient with 5-second time bucket to prevent duplicate events from multiple hooks * * @param int $order_id Order ID. * @param string $status Order status (normalized, without 'wc-' prefix). * @return bool True if event was already sent (duplicate), false if new. */ private function is_order_status_event_duplicate($order_id, $status) { // Normalize status. $normalized_status = str_replace('wc-', '', $status); // Create time bucket: round to nearest 5 seconds to catch rapid duplicate calls. $time_bucket = floor(time() / 5) * 5; // Create transient key. $transient_key = 'activetrail_order_dedup_' . $order_id . '_' . $normalized_status . '_' . $time_bucket; // Check if transient exists (event already sent). $existing = get_transient($transient_key); if ($existing !== false) { // Event already sent in this time bucket. return true; } // Mark as sent by setting transient (expires in 10 seconds). set_transient($transient_key, time(), 10); return false; } /** * Handle order status change * * @param int $order_id Order ID. * @param string $old_status Old status. * @param string $new_status New status. * @param WC_Order $order Order object. */ public function handle_order_status_change($order_id, $old_status, $new_status, $order) { // Check if this status should trigger events. if (!$this->should_trigger_status_event($new_status)) { $this->log('Order status change skipped - status not enabled: ' . $new_status, 'debug'); return; } // Deduplication: Check if this event was already sent (prevents duplicate events from multiple hooks). $normalized_status = str_replace('wc-', '', $new_status); if ($this->is_order_status_event_duplicate($order_id, $normalized_status)) { $this->log('Order status change skipped - duplicate event detected for order ' . $order_id . ' status ' . $new_status, 'debug'); return; } // Get settings from ActiveTrail_Settings class. $settings = ActiveTrail_Settings::get_all(); // Debug log to help diagnose status comparison issues. $this->log('Checking order status change: ' . $new_status . ' against sync statuses: ' . implode(', ', $settings['order_statuses'] ?? array()), 'debug'); // Check consent if required. if (!$this->should_sync_order($order)) { return; } // Check if order was already synced to ActiveTrail. $synced = $order->get_meta('_activetrail_synced', true); if ( 'yes' === $synced ) { // Order exists in ActiveTrail - update its status. $this->log('Order ' . $order_id . ' already synced, updating status from ' . $old_status . ' to ' . $new_status); $this->update_order_status_in_activetrail($order, $old_status, $new_status); } else { // Order doesn't exist in ActiveTrail yet - create it (customer data is included in the order). $this->log('Order ' . $order_id . ' not yet synced, creating new order with status ' . $new_status); $this->sync_order($order); } } /** * Update order status in ActiveTrail * * @param WC_Order $order Order object. * @param string $old_status Old status. * @param string $new_status New status. * @return bool Success or failure */ private function update_order_status_in_activetrail($order, $old_status, $new_status) { $order_id = $order->get_id(); $this->log('========== START UPDATE ORDER STATUS IN ACTIVETRAIL ==========', 'debug'); $this->log('Order ID: ' . $order_id . ', Old Status: ' . $old_status . ', New Status: ' . $new_status, 'debug'); // Get order data in ActiveTrail format. $this->log('Transforming order to ActiveTrail format...', 'debug'); $order_data = $this->api_client->transform_wc_order_to_activetrail($order); $this->log('Order data transformed successfully', 'debug'); $override_external_id = $order->get_meta('_activetrail_external_id', true); $this->log('Override external_id from order meta: ' . ($override_external_id ? $override_external_id : 'EMPTY'), 'debug'); if (!empty($override_external_id)) { $order_data['externalId'] = $override_external_id; $this->log('Applied stored externalId for status update on order ' . $order_id . ': ' . $override_external_id, 'debug'); } // Update contact subscription in ActiveTrail when consent is given (independent of order status sync). $this->update_contact_subscription_in_activetrail($order); // Log the update operation. $this->log('Order Status Update', [ 'order_id' => $order_id, 'old_status' => $old_status, 'new_status' => $new_status, ], 'info'); // Use PUT method to update existing order. $response = $this->api_client->request('PUT', '/commerce/order/' . $order_id, $order_data); // Log the API response. $this->log('Order Status Update API Response', [ 'order_id' => $order_id, 'new_status' => $new_status, 'api_response' => $response, ], 'debug'); if (isset($response['success']) && $response['success']) { $this->log('Successfully updated order ' . $order_id . ' status in ActiveTrail from ' . $old_status . ' to ' . $new_status); return true; } else { $error_message = isset($response['message']) ? $response['message'] : 'Unknown error'; $this->log('Failed to update order ' . $order_id . ' status in ActiveTrail: ' . $error_message, 'error'); return false; } } /** * Update contact subscription status in ActiveTrail when the order has consent. * Used both on order status change and on checkout completed, so that a customer * who was previously Unsubscribed gets updated to Subscribed when they give consent on a later order. * * @param WC_Order $order Order object. */ private function update_contact_subscription_in_activetrail($order) { if (!$order || !is_object($order)) { return; } if (!$this->order_has_consent($order)) { $this->log('Consent not given (order_has_consent=false), skipping contact subscription update', 'debug'); return; } $order_id = $order->get_id(); $this->log('========== STARTING CONTACT SUBSCRIPTION UPDATE ==========', 'info'); $this->log('Order ' . $order_id . ' has consent=yes, proceeding with contact subscription update', 'info'); $external_id = ''; $customer_id = $order->get_customer_id(); $billing_email = $order->get_billing_email(); $this->log('Customer ID: ' . ($customer_id ? $customer_id : 'GUEST (0)'), 'debug'); $this->log('Billing Email: ' . $billing_email, 'debug'); $this->log('STEP 1: Checking for existing contact...', 'debug'); $existing_contact_external_id = null; if (!empty($billing_email) && !empty($this->api_client)) { try { $existing_check = $this->api_client->get_customer_by_email($billing_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); $preferred_registered_contact = null; $preferred_guest_contact = null; $any_existing_contact = null; foreach ($contacts_to_check as $contact_data) { $contact_external_id = isset($contact_data['externalId']) ? $contact_data['externalId'] : null; if (empty($contact_external_id)) { continue; } if (empty($any_existing_contact)) { $any_existing_contact = $contact_external_id; } if ($customer_id && strpos($contact_external_id, 'WC-CONTACT-' . $customer_id) === 0) { $preferred_registered_contact = $contact_external_id; $this->log('✓ Found registered contact for customer ID ' . $customer_id . ': ' . $contact_external_id, 'info'); break; } if (strpos($contact_external_id, 'WC-CONTACT-GUEST-') === 0 && empty($preferred_guest_contact)) { $preferred_guest_contact = $contact_external_id; } } if (!empty($preferred_registered_contact)) { $existing_contact_external_id = $preferred_registered_contact; $this->log('✓ Using registered contact: ' . $existing_contact_external_id, 'info'); } elseif (!empty($preferred_guest_contact)) { $existing_contact_external_id = $preferred_guest_contact; $this->log('✓ Using guest contact: ' . $existing_contact_external_id, 'info'); } elseif (!empty($any_existing_contact)) { $existing_contact_external_id = $any_existing_contact; $this->log('✓ Using existing contact (fallback): ' . $existing_contact_external_id, 'info'); } if (!empty($existing_contact_external_id)) { $order->update_meta_data('_activetrail_external_id', $existing_contact_external_id); $order->save(); $this->log('✓ Updated order meta with correct external_id: ' . $existing_contact_external_id, 'info'); } } } catch (Exception $check_e) { $this->log('Could not check for existing contact: ' . $check_e->getMessage(), 'debug'); } } if (!empty($existing_contact_external_id)) { $external_id = $existing_contact_external_id; $this->log('✓ Using existing contact from API: ' . $external_id, 'info'); } else { $this->log('STEP 2: Getting external_id from order meta...', 'debug'); $stored_external_id = $order->get_meta('_activetrail_external_id', true); if (!empty($stored_external_id)) { $external_id = $stored_external_id; $this->log('✓ Using external_id from order meta: ' . $external_id, 'info'); } else { $this->log('✗ WARNING: No external_id stored in order meta, will attempt fallback', 'warning'); } } $this->log('STEP 3: Checking if fallback needed...', 'debug'); if (empty($external_id)) { if ($customer_id && function_exists('activetrail_external_id_for_contact')) { $external_id = activetrail_external_id_for_contact($customer_id, $billing_email); $this->log('✓ Generated external_id from customer ID: ' . $external_id, 'info'); } else { $external_id = $this->generate_guest_external_id($order); $this->log('✓ Generated guest external_id: ' . $external_id, 'info'); } } $this->log('FINAL EXTERNAL ID: ' . ($external_id ? $external_id : 'STILL EMPTY!!!'), 'info'); if (empty($external_id)) { $this->log('✗✗✗ CRITICAL ERROR: Cannot update contact - no external ID available for order ' . $order_id, 'error'); $this->log('========== END CONTACT SUBSCRIPTION UPDATE ==========', 'debug'); return; } $this->log('========== PREPARING CONTACT UPDATE DATA ==========', 'debug'); $customer_data = array( 'externalId' => $external_id, 'externalName' => 'WooCommerce', 'email' => $billing_email, 'first_name' => $order->get_billing_first_name(), 'last_name' => $order->get_billing_last_name(), 'sms' => $order->get_billing_phone(), 'city' => $order->get_billing_city(), 'street' => trim($order->get_billing_address_1() . ' ' . $order->get_billing_address_2()), 'zip_code' => $order->get_billing_postcode(), 'subscriptionStatus' => true ); $this->log('========== CALLING API CLIENT UPDATE_CUSTOMER ==========', 'info'); $contact_result = $this->api_client->update_customer($external_id, $customer_data); $this->log('Response success: ' . (!empty($contact_result['success']) ? 'TRUE' : 'FALSE'), 'debug'); if (!empty($contact_result['success'])) { $this->log('✓✓✓ SUCCESS: Contact subscription status updated successfully for order ' . $order_id, 'info'); $this->log('========== END CONTACT SUBSCRIPTION UPDATE ==========', 'debug'); return; } $error_msg = isset($contact_result['message']) ? $contact_result['message'] : 'Unknown error'; $response_code = isset($contact_result['data']['Message']) ? json_decode($contact_result['data']['Message'], true) : null; $is_404 = !empty($response_code['status']) && $response_code['status'] === '404'; if ($is_404) { $this->log('✗✗✗ FAILED: Contact not found (404) - attempting to find correct contact...', 'error'); } else { $this->log('✗✗✗ FAILED: Contact subscription status update failed for order ' . $order_id . ': ' . $error_msg, 'error'); } if ($is_404 && !empty($billing_email) && !empty($this->api_client)) { try { $retry_check = $this->api_client->get_customer_by_email($billing_email); if (!is_wp_error($retry_check) && !empty($retry_check['exists'])) { $retry_data = $retry_check['data']; $retry_contacts = is_array($retry_data) && isset($retry_data[0]) ? $retry_data : array($retry_data); $correct_external_id = null; foreach ($retry_contacts as $retry_contact) { $retry_external_id = isset($retry_contact['externalId']) ? $retry_contact['externalId'] : null; if (empty($retry_external_id)) { continue; } if ($customer_id && strpos($retry_external_id, 'WC-CONTACT-' . $customer_id) === 0) { $correct_external_id = $retry_external_id; break; } if (empty($correct_external_id)) { $correct_external_id = $retry_external_id; } } if (!empty($correct_external_id) && $correct_external_id !== $external_id) { $this->log('✓ Found correct contact: ' . $correct_external_id . ', retrying update...', 'info'); $order->update_meta_data('_activetrail_external_id', $correct_external_id); $order->save(); $customer_data['externalId'] = $correct_external_id; $retry_result = $this->api_client->update_customer($correct_external_id, $customer_data); if (!empty($retry_result['success'])) { $this->log('✓✓✓ SUCCESS: Contact subscription status updated successfully after retry for order ' . $order_id, 'info'); } else { $this->log('✗✗✗ FAILED: Contact update still failed after retry', 'error'); } } } } catch (Exception $retry_e) { $this->log('Could not retry contact update: ' . $retry_e->getMessage(), 'error'); } } $this->log('========== END CONTACT SUBSCRIPTION UPDATE ==========', 'debug'); } /** * Handle new order * * @param int $order_id Order ID. * @param WC_Order|array $order_or_data Order object or posted data. * @param WC_Order $order Order object (optional, for checkout_order_processed hook). */ public function handle_new_order($order_id, $order_or_data, $order = null) { // Handle different parameter combinations from different hooks. if ($order === null) { // From woocommerce_new_order hook. $order = $order_or_data; } else { // From woocommerce_checkout_order_processed hook. $order = $order; } if (!$order || !is_object($order)) { $this->log('Invalid order object for order: ' . $order_id, 'error'); return; } // Check if order was already synced - HPOS compatible. $order = wc_get_order($order_id); if (!$order) { $this->log('Invalid order ID: ' . $order_id, 'error'); return; } $synced = $order->get_meta('_activetrail_synced', true); if ( 'yes' === $synced ) { $this->log('Order already synced: ' . $order_id); return; } // Get settings from ActiveTrail_Settings class. $settings = ActiveTrail_Settings::get_all(); // Log new order. $this->log('New order created: ' . $order_id . ' (Status: ' . $order->get_status() . ')'); // Check if this status should trigger events. $current_status = $order->get_status(); if (!$this->should_trigger_status_event($current_status)) { $this->log('New order skipped - status not enabled: ' . $current_status . ' for order: ' . $order_id, 'debug'); return; } // Debug log to help diagnose status comparison issues. $this->log('Checking order status: ' . $current_status . ' against sync statuses: ' . implode(', ', $settings['order_statuses'] ?? array()), 'debug'); // Check consent if required. if ($this->should_sync_order($order)) { // Send order data to ActiveTrail (customer data is included in the order). $this->sync_order($order); } else { $this->log('Order sync skipped due to consent check: ' . $order_id); } } /** * Handle checkout completed * * @param int $order_id Order ID. * @param array $posted_data Posted data. * @param WC_Order $order Order object. */ public function handle_checkout_completed($order_id, $posted_data, $order) { // Store consent information - HPOS compatible. $order = wc_get_order($order_id); if ($order) { $customer_id = $order->get_customer_id(); $has_consent = ActiveTrail_Consent_Helper::has_consent_from_data( $posted_data ); // Save to order meta. if ($has_consent) { $order->update_meta_data('_activetrail_consent', 'yes'); // Add standard meta keys for compatibility with other plugins. $order->update_meta_data('activetrail_optin', 'yes'); $order->update_meta_data('activetrail_subscribe', '1'); } else { // Always store a value for _activetrail_consent, even when not checked. $order->update_meta_data('_activetrail_consent', 'no'); // Add standard meta keys for compatibility with other plugins. $order->update_meta_data('activetrail_optin', 'no'); $order->update_meta_data('activetrail_subscribe', '0'); } $order->save(); // Also save to user meta if customer is logged in. if ($customer_id > 0) { if ($has_consent) { update_user_meta($customer_id, '_activetrail_consent', 'yes'); update_user_meta($customer_id, '_activetrail_consent_date', current_time('mysql')); // Add standard meta keys for compatibility with other plugins. update_user_meta($customer_id, 'activetrail_optin', 'yes'); update_user_meta($customer_id, 'activetrail_subscribe', '1'); $this->log('Consent saved to user meta during checkout', 'debug', array( 'user_id' => $customer_id, 'order_id' => $order_id )); } else { update_user_meta($customer_id, '_activetrail_consent', 'no'); // Add standard meta keys for compatibility with other plugins. update_user_meta($customer_id, 'activetrail_optin', 'no'); update_user_meta($customer_id, 'activetrail_subscribe', '0'); } } // When consent was given, update the contact's subscription status in ActiveTrail immediately. // This ensures a customer who was previously Unsubscribed becomes Subscribed on a later order with consent. if ($has_consent) { $this->update_contact_subscription_in_activetrail($order); } } $this->log('Checkout completed for order: ' . $order_id); } /** * Handle payment complete * * @param int $order_id Order ID. */ public function handle_payment_complete($order_id) { $order = wc_get_order($order_id); if (!$order) { return; } $this->log('Payment completed for order: ' . $order_id); // Check if order was already synced - HPOS compatible. $synced = $order->get_meta('_activetrail_synced', true); if ( 'yes' === $synced ) { return; } // Check if this status should trigger events. $current_status = $order->get_status(); if (!$this->should_trigger_status_event($current_status)) { $this->log('Payment complete skipped - status not enabled: ' . $current_status . ' for order: ' . $order_id, 'debug'); return; } // Get settings from ActiveTrail_Settings class. $settings = ActiveTrail_Settings::get_all(); // Debug log to help diagnose status comparison issues. $this->log('Checking order status: ' . $current_status . ' against sync statuses: ' . implode(', ', $settings['order_statuses'] ?? array()), 'debug'); // Check consent if required. if ($this->should_sync_order($order)) { // Send order data to ActiveTrail (customer data is included in the order). $this->sync_order($order); } } /** * Handle admin order created * * @param WC_Order $order Order object. */ public function handle_admin_order_created($order) { if (!$order) { return; } $order_id = $order->get_id(); $this->log('Admin order created: ' . $order_id); // Check if order was already synced - HPOS compatible. $synced = $order->get_meta('_activetrail_synced', true); if ( 'yes' === $synced ) { return; } // Check if this status should trigger events. $current_status = $order->get_status(); if (!$this->should_trigger_status_event($current_status)) { $this->log('Payment complete skipped - status not enabled: ' . $current_status . ' for order: ' . $order_id, 'debug'); return; } // Get settings from ActiveTrail_Settings class. $settings = ActiveTrail_Settings::get_all(); // Debug log to help diagnose status comparison issues. $this->log('Checking order status: ' . $current_status . ' against sync statuses: ' . implode(', ', $settings['order_statuses'] ?? array()), 'debug'); // Check consent if required. if ($this->should_sync_order($order)) { // Send order data to ActiveTrail (customer data is included in the order). $this->sync_order($order); } } /** * Handle order created (general hook) * * @param WC_Order $order Order object. */ public function handle_order_created($order) { if (!$order) { return; } $order_id = $order->get_id(); $this->log('Order created hook triggered: ' . $order_id); // Check if order was already synced - HPOS compatible. $synced = $order->get_meta('_activetrail_synced', true); if ( 'yes' === $synced ) { return; } // Check if this status should trigger events. $current_status = $order->get_status(); if (!$this->should_trigger_status_event($current_status)) { $this->log('Payment complete skipped - status not enabled: ' . $current_status . ' for order: ' . $order_id, 'debug'); return; } // Get settings from ActiveTrail_Settings class. $settings = ActiveTrail_Settings::get_all(); // Debug log to help diagnose status comparison issues. $this->log('Checking order status: ' . $current_status . ' against sync statuses: ' . implode(', ', $settings['order_statuses'] ?? array()), 'debug'); // Check consent if required. if ($this->should_sync_order($order)) { // Send order data to ActiveTrail (customer data is included in the order). $this->sync_order($order); } } /** * Handle order status processing * * @param int $order_id Order ID. */ public function handle_order_status_processing($order_id) { $order = wc_get_order($order_id); if (!$order) { return; } $this->log('Order status changed to processing: ' . $order_id); // Check if order was already synced - HPOS compatible. $synced = $order->get_meta('_activetrail_synced', true); if ( 'yes' === $synced ) { return; } // Check if this status should trigger events. $current_status = $order->get_status(); if (!$this->should_trigger_status_event($current_status)) { $this->log('Payment complete skipped - status not enabled: ' . $current_status . ' for order: ' . $order_id, 'debug'); return; } // Get settings from ActiveTrail_Settings class. $settings = ActiveTrail_Settings::get_all(); // Debug log to help diagnose status comparison issues. $this->log('Checking order status: ' . $current_status . ' against sync statuses: ' . implode(', ', $settings['order_statuses'] ?? array()), 'debug'); // Check consent if required. if ($this->should_sync_order($order)) { // Send order data to ActiveTrail (customer data is included in the order). $this->sync_order($order); } } /** * Handle customer registration * * @param int $customer_id Customer ID. * @param array $new_customer_data Customer data. * @param string $password_generated Whether password was generated. */ public function handle_customer_registration($customer_id, $new_customer_data, $password_generated) { $this->log('New customer registered: ' . $customer_id); // Customer registration is now handled by User_Registration_Handler. // We'll sync the customer when they place their first order. } /** * Check if order should be synced * * @param WC_Order $order Order object. * @return bool */ private function should_sync_order($order) { // Check if order status should trigger events (status filtering is handled by should_trigger_status_event). $current_status = $order->get_status(); if (!$this->should_trigger_status_event($current_status)) { $this->log('Order status not in sync list: ' . $current_status . ' for order ' . $order->get_id()); return false; } // Always return true - we'll sync all orders but set subscriberStatus based on consent. return true; } /** * Whether the order/customer has consent for marketing (checkout consent OR registration consent). * * @param WC_Order $order Order object. * @return bool */ private function order_has_consent($order) { $consent = $order->get_meta('_activetrail_consent', true); if ($consent === 'yes') { return true; } $customer_id = $order->get_customer_id(); if ($customer_id > 0) { $reg_consent = get_user_meta($customer_id, '_activetrail_registration_consent', true); $checkout_meta = get_user_meta($customer_id, '_activetrail_consent', true); if ($reg_consent === 'yes' || $checkout_meta === 'yes') { return true; } } return false; } /** * Sync order to ActiveTrail * * @param WC_Order $order Order object. * @return bool */ public function sync_order($order) { $order_id = $order->get_id(); if ($this->is_order_currently_syncing($order_id)) { $this->log('Order ' . $order_id . ' sync already running in this request, skipping duplicate trigger', 'debug'); return false; } $this->lock_order_sync($order_id); try { // Get settings from ActiveTrail_Settings class. $settings = ActiveTrail_Settings::get_all(); if (empty($settings['api_key'])) { $this->log('API key not configured', 'error'); return false; } $this->log('Starting order sync for order: ' . $order_id); $customer_id = $order->get_customer_id(); $stored_external_id = $order->get_meta('_activetrail_external_id', true); if (empty($stored_external_id)) { $this->log('Order ' . $order_id . ' missing externalId meta, ensuring contact exists before commerce sync', 'info'); $this->ensure_guest_contact_synced($order, $settings); $stored_external_id = $order->get_meta('_activetrail_external_id', true); } // Check if order has items. if ($order->get_item_count() == 0) { $this->log('Order ' . $order_id . ' has no items, skipping sync', 'warning'); return false; } // Check if order has valid customer email. $customer_email = $order->get_billing_email(); if (empty($customer_email) || !is_email($customer_email)) { $this->log('Order ' . $order_id . ' has invalid customer email: ' . $customer_email, 'error'); return false; } // Check if order has valid total (allow 0 for 100% discount coupons). $order_total = $order->get_total(); if (empty($order_total) && $order_total !== 0) { $this->log('Order ' . $order_id . ' has invalid total: ' . $order_total, 'error'); return false; } // Log order details for debugging. $this->log('Order ' . $order_id . ' total: ' . $order_total . ', subtotal: ' . $order->get_subtotal() . ', discount: ' . $order->get_discount_total(), 'debug'); // Check consent status to set subscriberStatus (checkout OR registration consent). $subscriber_status = $this->order_has_consent($order); // Log consent status for debugging. $this->log('Order ' . $order_id . ' consent (order_has_consent): ' . ($subscriber_status ? 'true' : 'false'), 'debug'); // Use the API client's transform method instead of prepare_order_data. $order_data = $this->api_client->transform_wc_order_to_activetrail($order); // CRITICAL FIX: Always use the external ID stored in the order meta! // This preserves the original identity (guest or registered) from when the order was created. $this->log('Checking for stored external_id in order meta...', 'debug'); $stored_external_id = $order->get_meta('_activetrail_external_id', true); if (!empty($stored_external_id)) { // Use the stored external ID - this is the source of truth. $order_data['externalId'] = $stored_external_id; $this->log('Using stored external_id from order meta: ' . $stored_external_id, 'info'); $this->log('This preserves the original contact identity from order creation', 'debug'); } else { // Fallback: Generate external ID if not stored (shouldn't happen after ensure_guest_contact_synced). $this->log('WARNING: No stored external_id found, generating fallback...', 'warning'); if ($customer_id && function_exists('activetrail_external_id_for_contact')) { $fallback_external_id = activetrail_external_id_for_contact($customer_id, $order->get_billing_email()); $order_data['externalId'] = $fallback_external_id; $this->log('Generated fallback external_id from customer ID: ' . $fallback_external_id, 'info'); } else { $fallback_external_id = $stored_external_id; // Should already be set by ensure_guest_contact_synced if (!empty($fallback_external_id)) { $order_data['externalId'] = $fallback_external_id; $this->log('Using fallback external_id: ' . $fallback_external_id, 'info'); } } } // Always set subscriptionStatus explicitly based on consent. // This ensures it's always present in the payload with the correct value. $order_data['subscriptionStatus'] = $subscriber_status; // Add group ID - ensure it's always set if configured, regardless of consent status. $customer_id = $order->get_customer_id(); $configured_group_id = !empty($settings['group_id']) ? (int) $settings['group_id'] : 0; if ($configured_group_id > 0) { $order_data['groupId'] = $configured_group_id; } // Log the order data being sent (for debugging). $this->log('Order data prepared for order ' . $order_id . ': ' . wp_json_encode($order_data), 'debug'); $result = $this->api_client->submit_order($order_data); // Log the API response (for debugging). $this->log('API response for order ' . $order_id . ': ' . wp_json_encode($result), 'debug'); if ($result['success']) { // Mark order as synced - HPOS compatible. $order->update_meta_data('_activetrail_synced', 'yes'); $order->update_meta_data('_activetrail_sync_date', current_time('mysql')); $order->save(); // Ensure contact is in the configured group regardless of consent (group membership controls automations). if ($configured_group_id > 0) { $contact_external_id = $order->get_meta('_activetrail_external_id', true); if (empty($contact_external_id)) { if (function_exists('activetrail_external_id_for_contact')) { $contact_external_id = activetrail_external_id_for_contact($customer_id, $order->get_billing_email()); } elseif (!empty($customer_id)) { $contact_external_id = 'WC-CONTACT-' . $customer_id; } else { $contact_external_id = $this->generate_guest_external_id($order); } } if (!empty($contact_external_id)) { try { $group_result = $this->api_client->add_contact_to_group_via_external( $contact_external_id, $configured_group_id, $order->get_billing_email() ); if (is_array($group_result) && !empty($group_result['success'])) { $this->log('Contact added to group successfully: ' . $contact_external_id . ' -> ' . $configured_group_id, 'info'); } elseif (is_array($group_result)) { $this->log('Add to group returned error for ' . $contact_external_id . ': ' . ($group_result['message'] ?? 'unknown'), 'error'); } } catch (\Throwable $e) { $this->log('Add to group failed: ' . $e->getMessage(), 'error'); } } } else { $this->log('Skipping group assignment because no valid group_id is configured', 'debug'); } $this->log('Order synced successfully: ' . $order_id); return true; } else { $this->log('Failed to sync order ' . $order_id . ': ' . $result['message'], 'error'); // Log additional error details if available. if (isset($result['data'])) { $this->log('Error details for order ' . $order_id . ': ' . wp_json_encode($result['data']), 'error'); } return false; } } finally { $this->unlock_order_sync($order_id); } } /** * Sync customer to ActiveTrail * * @param WC_Order $order Order object. * @return bool */ public function sync_customer($order) { // Get settings from ActiveTrail_Settings class. $settings = ActiveTrail_Settings::get_all(); if (empty($settings['api_key'])) { $this->log('API key not configured', 'error'); return false; } // Check if customer already synced. $customer_id = $order->get_customer_id(); if ( 'yes' === $customer_id && get_user_meta($customer_id, '_activetrail_synced', true) ) { $this->log('Customer already synced: ' . $order->get_billing_email()); return true; } $this->log('Starting customer sync for: ' . $order->get_billing_email()); // Prepare customer data. $customer_data = $this->prepare_customer_data($order); // Check consent status to set subscriberStatus (checkout OR registration consent). $subscriber_status = $this->order_has_consent($order); // Log consent status for debugging. $this->log('Customer ' . $order->get_billing_email() . ' consent (order_has_consent): ' . ($subscriber_status ? 'true' : 'false'), 'debug'); // Always set subscriptionStatus explicitly based on consent. $customer_data['subscriptionStatus'] = $subscriber_status; // Ensure group ID is always set in the payload to fix mandatory field error. if (!empty($settings['group_id'])) { $customer_data['groupId'] = intval($settings['group_id']); $this->log('Set groupId in customer sync data to: ' . $settings['group_id'], 'debug'); } else { $this->log('No group_id configured in settings for customer sync', 'warning'); } // Log group ID for debugging. $this->log('Sync Customer - Group ID: ' . 'group_setting=' . ($settings['group_id'] ?? 'none') . ', ' . 'payload_group=' . ($customer_data['groupId'] ?? 'none'), 'debug'); // Log the customer data being sent (for debugging). $this->log('Customer data prepared for order ' . $order->get_id() . ': ' . wp_json_encode($customer_data), 'debug'); // Send to ActiveTrail using the prepared customer data. $result = $this->api_client->import_customer($customer_data); if ($result['success']) { // Mark customer as synced. if ($customer_id) { update_user_meta($customer_id, '_activetrail_synced', 'yes'); update_user_meta($customer_id, '_activetrail_sync_date', current_time('mysql')); } $this->log('Customer synced successfully: ' . $order->get_billing_email()); return true; } else { $this->log('Failed to sync customer ' . $order->get_billing_email() . ': ' . $result['message'], 'error'); return false; } } /** * Prepare order data for ActiveTrail API * * @param WC_Order $order Order object. * @return array */ private function prepare_order_data($order) { // Get items. $items = array(); foreach ($order->get_items() as $item) { $product = $item->get_product(); if ($product) { $items[] = array( 'product_id' => $product->get_id(), 'name' => $product->get_name(), 'quantity' => $item->get_quantity(), 'price' => $product->get_price(), 'total' => $item->get_total(), 'categories' => $this->get_product_categories($product), ); } } $data = array( 'order_id' => $order->get_id(), 'order_number' => $order->get_order_number(), 'status' => $order->get_status(), 'total' => $order->get_total(), 'subtotal' => $order->get_subtotal(), 'tax_total' => $order->get_total_tax(), 'shipping_total' => $order->get_shipping_total(), 'currency' => $order->get_currency(), 'date_created' => $order->get_date_created()->format('Y-m-d H:i:s'), 'customer' => 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(), ), 'billing_address' => array( 'first_name' => $order->get_billing_first_name(), 'last_name' => $order->get_billing_last_name(), 'company' => $order->get_billing_company(), 'address_1' => $order->get_billing_address_1(), 'address_2' => $order->get_billing_address_2(), 'city' => $order->get_billing_city(), 'state' => $order->get_billing_state(), 'postcode' => $order->get_billing_postcode(), 'country' => $order->get_billing_country(), 'email' => $order->get_billing_email(), 'phone' => $order->get_billing_phone(), ), 'shipping_address' => array( 'first_name' => $order->get_shipping_first_name(), 'last_name' => $order->get_shipping_last_name(), 'company' => $order->get_shipping_company(), 'address_1' => $order->get_shipping_address_1(), 'address_2' => $order->get_shipping_address_2(), 'city' => $order->get_shipping_city(), 'state' => $order->get_shipping_state(), 'postcode' => $order->get_shipping_postcode(), 'country' => $order->get_shipping_country(), ), 'items' => $items, 'coupons' => $this->get_order_coupons($order), ); return apply_filters('activetrail_order_data', $data, $order); } /** * Prepare customer data for ActiveTrail API * * @param WC_Order $order Order object. * @return array */ private function prepare_customer_data($order) { // Get settings from ActiveTrail_Settings class. $settings = ActiveTrail_Settings::get_all(); // Check consent status to set subscriptionStatus (checkout OR registration consent). $subscriber_status = $this->order_has_consent($order); $data = array( 'email' => $order->get_billing_email(), 'sms' => $order->get_billing_phone(), 'first_name' => $order->get_billing_first_name(), 'last_name' => $order->get_billing_last_name(), 'city' => $order->get_billing_city(), 'street' => trim($order->get_billing_address_1() . ' ' . $order->get_billing_address_2()), 'zip_code' => $order->get_billing_postcode(), 'externalId' => activetrail_external_id_for_contact($order->get_customer_id(), $order->get_billing_email()), 'externalName' => ACTIVETRAIL_EXTERNAL_NAME, 'subscriptionStatus' => $subscriber_status ); // Add group ID - use 'groupId' key to match what import_customer expects. if (!empty($settings['group_id'])) { $data['groupId'] = intval($settings['group_id']); $this->log('Set groupId in prepare_customer_data to: ' . $settings['group_id'], 'debug'); } else { $this->log('No group_id configured in settings for prepare_customer_data', 'warning'); } // Add store ID if configured. if (!empty($settings['store_id'])) { $data['storeId'] = intval($settings['store_id']); } return apply_filters('activetrail_customer_data', $data, $order); } /** * Get product categories * * @param WC_Product|null $product Product object. * @return array */ private function get_product_categories($product) { if (!$product) { return array(); } $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 order coupons * * @param WC_Order $order Order object. * @return array */ private function get_order_coupons($order) { $coupons = array(); foreach ($order->get_coupon_codes() as $coupon_code) { $coupon = new WC_Coupon($coupon_code); $coupons[] = array( 'code' => $coupon_code, 'discount' => $order->get_discount_total(), 'type' => $coupon->get_discount_type(), ); } return $coupons; } /** * Manually retry failed order sync * * @param int $order_id Order ID. * @return array Result */ public function retry_order_sync($order_id) { $order = wc_get_order($order_id); if (!$order) { return array( 'success' => false, 'message' => 'Order not found' ); } // Remove sync flag to force retry - HPOS compatible. $order->delete_meta_data('_activetrail_synced'); $order->delete_meta_data('_activetrail_sync_date'); $order->save(); $this->log('Manually retrying order sync for order: ' . $order_id); $result = $this->sync_order($order); return array( 'success' => $result, 'message' => $result ? 'Order sync retried successfully' : 'Order sync retry failed' ); } /** * Retry all failed orders * * @param int $limit Maximum number of orders to retry. * @return array Results */ public function retry_all_failed_orders($limit = 50) { $results = array(); $retried_count = 0; // Get orders that should be synced but haven't been. $orders = wc_get_orders(array( 'limit' => $limit, 'orderby' => 'date', 'order' => 'DESC', 'status' => array('processing', 'completed'), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query necessary for filtering synced orders 'meta_query' => array( array( 'key' => '_activetrail_synced', 'compare' => 'NOT EXISTS' ) ) )); foreach ($orders as $order) { $order_id = $order->get_id(); // Check if order should be synced. $current_status = $order->get_status(); if (!$this->should_trigger_status_event($current_status)) { continue; } $settings = ActiveTrail_Settings::get_all(); // Check consent if required (checkout OR registration consent). if ($settings['enable_checkout_consent'] ?? false) { if ( ! $this->order_has_consent($order) ) { $results[] = array( 'order_id' => $order_id, 'success' => false, 'message' => 'No consent given' ); continue; } } $result = $this->retry_order_sync($order_id); $results[] = array_merge(array('order_id' => $order_id), $result); if ($result['success']) { $retried_count++; } } $this->log("Retried $retried_count orders out of " . count($orders) . " total"); return array( 'total_orders' => count($orders), 'retried_count' => $retried_count, 'results' => $results ); } /** * Ensure a guest customer's contact exists in ActiveTrail before syncing commerce data. * * @param WC_Order $order Order object. * @param array $settings Plugin settings. * @return bool */ private function ensure_guest_contact_synced($order, $settings) { if (!$order) { return false; } $needs_save = false; $customer_id = $order->get_customer_id(); $stored_external_id = $order->get_meta('_activetrail_external_id', true); $billing_email = $order->get_billing_email(); // CRITICAL: ALWAYS check if there's already a guest contact from cart/checkout. // Even if order meta already has an external ID, we should check for guest contact first. // If user registered during checkout, we MUST use the existing guest contact. // to avoid creating duplicate contacts and ensure order links to the correct contact. $existing_guest_contact = null; if (!empty($billing_email) && !empty($this->api_client)) { try { $existing_check = $this->api_client->get_customer_by_email($billing_email); if (!is_wp_error($existing_check) && !empty($existing_check['exists'])) { // Check all contacts with this email - might be multiple. $existing_contacts = is_array($existing_check['data']) && isset($existing_check['data'][0]) ? $existing_check['data'] : array($existing_check['data']); foreach ($existing_contacts as $existing_contact) { $existing_external_id = isset($existing_contact['externalId']) ? $existing_contact['externalId'] : null; // If existing contact is a guest contact (from cart/checkout), ALWAYS use it. // Even if user registered during checkout, we want to keep using the guest contact. if (!empty($existing_external_id) && strpos($existing_external_id, 'WC-CONTACT-GUEST-') === 0) { $existing_guest_contact = $existing_external_id; $this->log('Found existing guest contact from checkout: ' . $existing_guest_contact . ' - will use it to avoid duplicate (even if user registered)', 'info'); break; // Use first guest contact found } } } } catch (Exception $check_e) { // If check fails, continue with normal logic. $this->log('Could not check for existing guest contact: ' . $check_e->getMessage(), 'debug'); } } // PRIORITY: If we found an existing guest contact, ALWAYS use it (override any stored ID). if (!empty($existing_guest_contact)) { if ($stored_external_id !== $existing_guest_contact) { $stored_external_id = $existing_guest_contact; $order->update_meta_data('_activetrail_external_id', $stored_external_id); $needs_save = true; $this->log('Overriding stored external_id with existing guest contact (user registered during checkout): ' . $stored_external_id, 'info'); } } elseif (empty($stored_external_id)) { // No existing guest contact found, generate new external ID. if ($customer_id > 0) { // Customer is registered - use their registered external ID. if (function_exists('activetrail_external_id_for_contact')) { $stored_external_id = activetrail_external_id_for_contact($customer_id, $billing_email); } else { $stored_external_id = 'WC-CONTACT-' . $customer_id; } $this->log('Registered customer checkout - using registered external_id: ' . $stored_external_id . ' (customer_id=' . $customer_id . ')', 'info'); } else { // True guest checkout - use guest external ID. $stored_external_id = $this->generate_guest_external_id($order); $this->log('Guest checkout - using guest external_id: ' . $stored_external_id, 'info'); } if (!empty($stored_external_id)) { $order->update_meta_data('_activetrail_external_id', $stored_external_id); $needs_save = true; } } if ($needs_save) { $order->save(); $needs_save = false; } // Skip if contact already marked as imported (but ensure meta persisted if created now). $guest_meta = $order->get_meta('_activetrail_guest_imported'); if ( 'yes' === $guest_meta ) { return true; } // If we're using an existing guest contact (even if user registered), contact already exists. // No need to import them again - just mark as synced and return. if (!empty($existing_guest_contact)) { $this->log('Using existing guest contact (ID: ' . $existing_guest_contact . ') - already exists in ActiveTrail, skipping import', 'info'); $order->update_meta_data('_activetrail_guest_imported', 'yes'); $order->save(); return true; } // If customer is registered (customer_id > 0) and no guest contact exists,. // they already exist in ActiveTrail from registration - no need to import again. if ($customer_id > 0) { $this->log('Registered customer (ID: ' . $customer_id . ') - already exists in ActiveTrail from registration, skipping import', 'info'); $order->update_meta_data('_activetrail_guest_imported', 'yes'); $order->save(); return true; } // Ensure we have a group to assign guests to. if (empty($settings['group_id'])) { $this->log('Guest contact sync skipped: no group_id configured', 'warning'); return false; } // CRITICAL FIX: Build customer data manually WITHOUT using prepare_customer_data. // to avoid recalculating the external ID from customer_id. $subscriber_status = $this->order_has_consent($order); $customer_data = array( 'email' => $order->get_billing_email(), 'sms' => $order->get_billing_phone(), 'first_name' => $order->get_billing_first_name(), 'last_name' => $order->get_billing_last_name(), 'city' => $order->get_billing_city(), 'street' => trim($order->get_billing_address_1() . ' ' . $order->get_billing_address_2()), 'zip_code' => $order->get_billing_postcode(), 'externalId' => $stored_external_id, // CRITICAL: Use the stored external ID, not recalculated! 'externalName' => ACTIVETRAIL_EXTERNAL_NAME, 'subscriptionStatus' => $subscriber_status, 'groupId' => intval($settings['group_id']) ); // Add store ID if configured. if (!empty($settings['store_id'])) { $customer_data['storeId'] = intval($settings['store_id']); } $this->log('Importing guest contact to ActiveTrail before order sync: ' . $order->get_id(), 'info'); $this->log('Using external_id: ' . $stored_external_id . ' (keeping guest identity, customer_id=' . $customer_id . ')', 'debug'); $result = $this->api_client->import_customer($customer_data); if (!empty($result['success'])) { $order->update_meta_data('_activetrail_external_id', $stored_external_id); $order->update_meta_data('_activetrail_guest_imported', 'yes'); $order->save(); // Allow ActiveTrail a brief moment to process the import before the commerce call. usleep(300000); // 0.3 seconds $this->log('Guest contact imported successfully for order: ' . $order->get_id(), 'info'); return true; } $message = isset($result['message']) ? $result['message'] : 'Unknown error'; $this->log('Failed to import guest contact before order sync: ' . $order->get_id() . ' - ' . $message, 'warning'); return false; } /** * Check if an order is already syncing in the current request. * * @param int $order_id * @return bool */ private function is_order_currently_syncing($order_id) { return isset($this->orders_currently_syncing[$order_id]); } /** * Lock an order sync to avoid duplicate submissions. * * @param int $order_id */ private function lock_order_sync($order_id) { $this->orders_currently_syncing[$order_id] = microtime(true); } /** * Release the sync lock for an order. * * @param int $order_id */ private function unlock_order_sync($order_id) { unset($this->orders_currently_syncing[$order_id]); } /** * Generate a stable external ID for guest orders based on their email. * * @param WC_Order $order * @return string */ private function generate_guest_external_id($order) { if (!$order) { return ''; } $email = $order->get_billing_email(); if (function_exists('activetrail_external_id_for_contact')) { return activetrail_external_id_for_contact(0, $email); } if (!empty($email)) { return 'WC-CONTACT-GUEST-' . md5(strtolower(trim($email))); } return 'WC-CONTACT-GUEST-' . $order->get_id(); } /** * Log message * * @param string $message Log message. * @param string $level Log level. * @param array $data Optional data to log. */ private function log($message, $level = 'info', $data = array()) { // Use enhanced logger if available. if (class_exists('ActiveTrail_Logger')) { $logger = ActiveTrail_Logger::get_instance(); $logger->log($message, $level, 'ORDER', $data); } else { // Fallback - no logging if logger unavailable. } } }