api_key = $api_key; // Load settings if available. if ( class_exists( 'ActiveTrail_Settings' ) ) { $this->settings = new ActiveTrail_Settings(); } } /** * Test API connection * * @return array */ public function test_connection() { if ( empty( $this->api_key ) ) { return array( 'success' => false, 'message' => esc_html__( 'API key is required', 'activetrail-for-woocommerce' ), ); } // Check for test mode. if ( 'test_key_123' === $this->api_key ) { return array( 'success' => true, 'message' => esc_html__( 'Test mode: API connection simulated successfully', 'activetrail-for-woocommerce' ), ); } $response = $this->make_request( 'GET', '/groups' ); if ( is_wp_error( $response ) ) { $error_message = $response->get_error_message(); $error_code = $response->get_error_code(); $this->log_request_error( 'Connection test failed', $error_message ); // Provide more detailed error messages for common connection issues. if ( 'http_request_failed' === $error_code ) { if ( strpos( $error_message, 'cURL error 28' ) !== false ) { return array( 'success' => false, 'message' => esc_html__( 'Connection failed: Connection timeout. Please check your internet connection or try again later.', 'activetrail-for-woocommerce' ), ); } elseif ( strpos( $error_message, 'cURL error 6' ) !== false || strpos( $error_message, 'cURL error 7' ) !== false ) { return array( 'success' => false, 'message' => esc_html__( 'Connection failed: Could not resolve host. Please check your internet connection.', 'activetrail-for-woocommerce' ), ); } } // If no specific error was identified, return a generic message if the error message is empty. if ( empty( $error_message ) ) { return array( 'success' => false, 'message' => esc_html__( 'Connection failed: Unable to connect to ActiveTrail API. Please check your internet connection and try again.', 'activetrail-for-woocommerce' ), ); } return array( 'success' => false, /* translators: %s: Error message */ 'message' => sprintf( esc_html__( 'Connection failed: %s', 'activetrail-for-woocommerce' ), $error_message ), ); } $response_code = wp_remote_retrieve_response_code( $response ); $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); // Log detailed response for debugging. $this->log_request_debug( 'API Test Response', array( 'response_code' => $response_code, 'body' => $body, 'parsed_data' => $data, 'headers' => wp_remote_retrieve_headers( $response ), ) ); if ( $response_code === 200 ) { return array( 'success' => true, 'message' => esc_html__( 'API connection successful', 'activetrail-for-woocommerce' ), ); } else { // ActiveTrail uses "Message" (capital M) for error responses. $error_message = isset( $data['Message'] ) ? $data['Message'] : ( isset( $data['message'] ) ? $data['message'] : /* translators: %d: HTTP response code */ sprintf( esc_html__( 'HTTP %d: API connection failed', 'activetrail-for-woocommerce' ), $response_code ) ); return array( 'success' => false, 'message' => $error_message, 'debug_info' => array( 'response_code' => $response_code, 'response_body' => $body, 'request_url' => self::API_BASE_URL . '/groups', ), ); } } /** * Get groups from ActiveTrail * * @param array $params Query parameters (SearchTerm, Page, Limit). * @return array */ public function get_groups( $params = array() ) { if ( empty( $this->api_key ) ) { return array( 'success' => false, 'message' => esc_html__( 'API key is required', 'activetrail-for-woocommerce' ), 'data' => array(), ); } // Check for test mode. if ( 'test_key_123' === $this->api_key ) { return array( 'success' => true, 'message' => esc_html__( 'Test mode: Groups retrieved successfully', 'activetrail-for-woocommerce' ), 'data' => array( array( 'Id' => 1, 'Name' => 'Test Group 1', ), array( 'Id' => 2, 'Name' => 'Test Group 2', ), array( 'Id' => 3, 'Name' => 'Test Group 3', ), ), ); } // Build query parameters. $query_params = array(); if ( ! empty( $params['SearchTerm'] ) ) { $query_params['SearchTerm'] = $params['SearchTerm']; } if ( ! empty( $params['Page'] ) ) { $query_params['Page'] = $params['Page']; } if ( ! empty( $params['Limit'] ) ) { $query_params['Limit'] = $params['Limit']; } $endpoint = '/groups'; if ( ! empty( $query_params ) ) { $endpoint .= '?' . http_build_query( $query_params ); } $response = $this->make_request( 'GET', $endpoint ); if ( is_wp_error( $response ) ) { return array( 'success' => false, 'message' => $response->get_error_message(), 'data' => array(), ); } $response_code = wp_remote_retrieve_response_code( $response ); $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); // Log detailed response for debugging. $this->log_request_debug( 'Groups API Response', array( 'response_code' => $response_code, 'body' => $body, 'parsed_data' => $data, ) ); if ( $response_code === 200 ) { // ActiveTrail API returns groups directly in an array, not wrapped in 'data'. $groups = is_array( $data ) ? $data : array(); return array( 'success' => true, 'message' => esc_html__( 'Groups retrieved successfully', 'activetrail-for-woocommerce' ), 'data' => $groups, ); } else { $error_message = isset( $data['Message'] ) ? $data['Message'] : ( isset( $data['message'] ) ? $data['message'] : esc_html__( 'Failed to retrieve groups', 'activetrail-for-woocommerce' ) ); return array( 'success' => false, 'message' => $error_message, 'data' => array(), ); } } /** * Import customer to ActiveTrail (with smart upsert logic) * * @param array|WC_Customer $customer_data Customer data or WC_Customer object. * @return array */ public function import_customer( $customer_data ) { if ( empty( $this->api_key ) ) { return array( 'success' => false, 'message' => esc_html__( 'API key is required', 'activetrail-for-woocommerce' ), ); } // Transform WooCommerce customer to ActiveTrail format if needed. if ( is_object( $customer_data ) && ( method_exists( $customer_data, 'get_email' ) || method_exists( $customer_data, 'get_billing_email' ) ) ) { $customer_data = $this->transform_wc_customer_to_activetrail( $customer_data ); } // Validate required fields. $validation = $this->validate_customer_data( $customer_data ); if ( ! $validation['valid'] ) { return array( 'success' => false, 'message' => $validation['message'], ); } // Rename subscriberStatus to subscriptionStatus if present. if ( isset( $customer_data['subscriberStatus'] ) ) { $customer_data['subscriptionStatus'] = $customer_data['subscriberStatus']; unset( $customer_data['subscriberStatus'] ); } // Try smart upsert approach to avoid import limits. return $this->upsert_customer( $customer_data ); } /** * Import customer from user data (for registration) * * @param array $user_data User data array. * @return array */ public function import_customer_from_user( $user_data ) { if ( empty( $this->api_key ) ) { return array( 'success' => false, 'message' => esc_html__( 'API key is required', 'activetrail-for-woocommerce' ), ); } // Validate required fields. if ( empty( $user_data['email'] ) ) { return array( 'success' => false, 'message' => esc_html__( 'Email is required', 'activetrail-for-woocommerce' ), ); } // Generate proper external ID using the helper function. $external_id = ''; if ( ! empty( $user_data['externalId'] ) ) { // Use provided external ID if available. $external_id = $user_data['externalId']; } elseif ( function_exists( 'activetrail_external_id_for_contact' ) ) { // Use the helper function if available. $user_id = isset( $user_data['user_id'] ) ? $user_data['user_id'] : 0; $external_id = activetrail_external_id_for_contact( $user_id, $user_data['email'] ); } else { // Fallback to a safe format that doesn't use email directly. $user_id = isset( $user_data['user_id'] ) ? $user_data['user_id'] : 0; if ( $user_id ) { $external_id = 'WC-CONTACT-' . $user_id; } else { $external_id = 'WC-CONTACT-GUEST-' . md5( $user_data['email'] ); } } // Transform user data to customer format. $customer_data = array( 'email' => $user_data['email'], 'sms' => isset( $user_data['phone'] ) ? $user_data['phone'] : '', 'first_name' => isset( $user_data['first_name'] ) ? $user_data['first_name'] : '', 'last_name' => isset( $user_data['last_name'] ) ? $user_data['last_name'] : '', 'externalId' => $external_id, 'externalName' => 'WooCommerce', ); // Add address fields if available (always include, even for unsubscribed contacts). if ( ! empty( $user_data['street'] ) ) { $customer_data['street'] = $user_data['street']; } elseif ( ! empty( $user_data['address'] ) ) { $customer_data['street'] = $user_data['address']; } if ( ! empty( $user_data['city'] ) ) { $customer_data['city'] = $user_data['city']; } if ( ! empty( $user_data['zip_code'] ) ) { $customer_data['zip_code'] = $user_data['zip_code']; } elseif ( ! empty( $user_data['postcode'] ) ) { $customer_data['zip_code'] = $user_data['postcode']; } // Add subscriptionStatus if provided in user_data. if ( isset( $user_data['subscriptionStatus'] ) ) { $customer_data['subscriptionStatus'] = $user_data['subscriptionStatus']; } // Add tracking_consent (CNIL) if provided in user_data - always a boolean. if ( isset( $user_data['tracking_consent'] ) ) { $customer_data['tracking_consent'] = (bool) $user_data['tracking_consent']; } // Add group ID if provided - check user_data first, then fallback to settings. $group_id = null; if ( ! empty( $user_data['group_id'] ) && is_numeric( $user_data['group_id'] ) && intval( $user_data['group_id'] ) > 0 ) { $group_id = intval( $user_data['group_id'] ); } else { // Fallback to settings if not in user_data. if ( class_exists( 'ActiveTrail_Settings' ) ) { $settings = ActiveTrail_Settings::get_all(); if ( ! empty( $settings['group_id'] ) && is_numeric( $settings['group_id'] ) && intval( $settings['group_id'] ) > 0 ) { $group_id = intval( $settings['group_id'] ); $this->log_api_debug( 'Import Customer From User - Using group_id from settings', array( 'group_id' => $group_id, 'email' => $user_data['email'], ) ); } } } if ( ! empty( $group_id ) ) { $customer_data['groupId'] = $group_id; $this->log_api_debug( 'Import Customer From User - Group ID set', array( 'groupId' => $group_id, 'email' => $user_data['email'], ) ); } else { $this->log_api_debug( 'Import Customer From User - No group_id available', array( 'user_data_group_id' => isset( $user_data['group_id'] ) ? $user_data['group_id'] : 'not_set', 'email' => $user_data['email'], ) ); } // Validate customer data. $validation = $this->validate_customer_data( $customer_data ); if ( ! $validation['valid'] ) { return array( 'success' => false, 'message' => $validation['message'], ); } // Try smart upsert approach. return $this->upsert_customer( $customer_data ); } /** * Smart customer upsert - check if exists, then create or update * * @param array $customer_data Customer data. * @return array */ private function upsert_customer( $customer_data ) { $email = $customer_data['email']; // Step 1: Check if customer exists by email using the correct endpoint. $existing_customer = $this->get_customer_by_email( $email ); if ( is_wp_error( $existing_customer ) ) { return array( 'success' => false, 'message' => $existing_customer->get_error_message(), ); } if ( $existing_customer['exists'] ) { // Customer exists - update them using external ID. data may be array of contacts; use first for update. $data = $existing_customer['data']; $contact = ( is_array( $data ) && isset( $data[0] ) ) ? $data[0] : $data; return $this->update_existing_customer_by_external_id( $contact, $customer_data ); } else { // Customer doesn't exist - try to import them. return $this->import_single_customer( $customer_data ); } } /** * Get customer by email from ActiveTrail using correct API endpoint * * @param string $email Customer email. * @return array|WP_Error */ public function get_customer_by_email( $email ) { // Use the correct endpoint from the API docs: GET /external?Email={Email}. $endpoint = '/external?Email=' . urlencode( $email ); $response = $this->make_request( 'GET', $endpoint ); if ( is_wp_error( $response ) ) { return $response; } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); $response_code = wp_remote_retrieve_response_code( $response ); if ( $response_code === 200 ) { // Check if we got any results. Return full array so callers can choose by state (e.g. exclude Blocked). if ( ! empty( $data ) && is_array( $data ) && count( $data ) > 0 ) { // Customer found - return all contacts for this email. return array( 'exists' => true, 'data' => $data, 'message' => esc_html__( 'Customer found', 'activetrail-for-woocommerce' ), ); } else { // No customer found with this email. return array( 'exists' => false, 'data' => null, 'message' => esc_html__( 'Customer not found', 'activetrail-for-woocommerce' ), ); } } else { // Some other error. $error_message = $this->extract_error_message( $data ); return new WP_Error( 'customer_lookup_failed', $error_message ); } } /** * Locate an ActiveTrail contact by email or external ID * * @param string $external_id External identifier. * @param string $email Email address. * @return array|WP_Error */ private function get_contact_by_identifier( $external_id, $email = '' ) { if ( ! empty( $email ) && is_email( $email ) ) { $by_email = $this->get_customer_by_email( $email ); if ( is_wp_error( $by_email ) ) { return $by_email; } if ( ! empty( $by_email['exists'] ) ) { return $by_email; } } if ( ! empty( $external_id ) ) { return $this->get_contact_by_external_id( $external_id ); } return array( 'exists' => false, 'data' => null, 'message' => esc_html__( 'Contact not found', 'activetrail-for-woocommerce' ), ); } /** * Locate contact by external ID * * @param string $external_id External identifier. * @return array|WP_Error */ private function get_contact_by_external_id( $external_id ) { if ( empty( $external_id ) ) { return array( 'exists' => false, 'data' => null, 'message' => esc_html__( 'External ID is required', 'activetrail-for-woocommerce' ), ); } $endpoints = array( '/external?ExternalId=', '/external?externalId=', ); foreach ( $endpoints as $endpoint_prefix ) { $endpoint = $endpoint_prefix . urlencode( $external_id ); $response = $this->make_request( 'GET', $endpoint ); if ( is_wp_error( $response ) ) { // If first request fails, try next format. $error = $response; continue; } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); $response_code = wp_remote_retrieve_response_code( $response ); if ( $response_code === 200 ) { if ( ! empty( $data ) && is_array( $data ) && count( $data ) > 0 ) { return array( 'exists' => true, 'data' => $data[0], 'message' => esc_html__( 'Contact found', 'activetrail-for-woocommerce' ), ); } return array( 'exists' => false, 'data' => null, 'message' => esc_html__( 'Contact not found', 'activetrail-for-woocommerce' ), ); } $error_message = $this->extract_error_message( $data ); $error = new WP_Error( 'contact_lookup_failed', $error_message ); } return isset( $error ) ? $error : array( 'exists' => false, 'data' => null, 'message' => esc_html__( 'Contact not found', 'activetrail-for-woocommerce' ), ); } /** * Submit cart data to ActiveTrail * * @param array $cart_data Cart data. * @return array */ public function submit_cart( $cart_data ) { if ( empty( $this->api_key ) ) { return array( 'success' => false, 'message' => esc_html__( 'API key is required', 'activetrail-for-woocommerce' ), ); } // Cart events are anonymous & read-only. Contacts are created or updated only on explicit user actions (registration / checkout). $endpoint = '/commerce/Cart'; $response = $this->make_request( 'POST', $endpoint, $cart_data ); if ( is_wp_error( $response ) ) { $this->log_api_error( 'Cart Submit', $response->get_error_message(), $cart_data ); return array( 'success' => false, 'message' => $response->get_error_message(), ); } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); $response_code = wp_remote_retrieve_response_code( $response ); if ( in_array( $response_code, array( 200, 201 ), true ) ) { // Check if ActiveTrail actually accepted the cart (success field) if ( isset( $data['success'] ) && $data['success'] === 0 ) { // HTTP 200 but ActiveTrail rejected the cart $error_details = isset( $data['errors'] ) && ! empty( $data['errors'] ) ? wp_json_encode( $data['errors'] ) : 'No specific error details provided by ActiveTrail'; $this->log_api_error( 'Cart Submit - Rejected by ActiveTrail', 'Cart was rejected with success=0', array( 'response_code' => $response_code, 'response_data' => $data, 'request_data' => $cart_data, 'error_details' => $error_details, 'validation_hints' => array( 'check_externalId' => isset( $cart_data['externalId'] ) ? 'present' : 'MISSING', 'check_email' => isset( $cart_data['email'] ) && ! empty( $cart_data['email'] ) ? 'present' : 'MISSING', 'check_products' => isset( $cart_data['products'] ) && ! empty( $cart_data['products'] ) ? count( $cart_data['products'] ) . ' products' : 'MISSING', 'check_totalAmount' => isset( $cart_data['totalAmount'] ) ? $cart_data['totalAmount'] : 'MISSING', 'check_storeId' => isset( $cart_data['storeId'] ) ? $cart_data['storeId'] : 'MISSING', ), ) ); return array( 'success' => false, 'message' => 'ActiveTrail rejected cart: ' . $error_details, 'data' => $data, ); } $this->log_api_success( 'Cart Submit', 'Cart submitted successfully', $data ); return array( 'success' => true, 'message' => esc_html__( 'Cart submitted successfully', 'activetrail-for-woocommerce' ), 'data' => $data, ); } else { $error_message = $this->extract_error_message( $data ); $this->log_api_error( 'Cart Submit', $error_message, array( 'response_code' => $response_code, 'response_data' => $data, 'request_data' => $cart_data, ) ); return array( 'success' => false, 'message' => $error_message, 'data' => $data, ); } } /** * Ensure customer exists in ActiveTrail before submitting cart * * @param array $cart_data Cart data with customer info. * @return array */ private function ensure_customer_exists( $cart_data, $group_id = null ) { if ( empty( $cart_data['email'] ) ) { return array( 'success' => false, 'message' => esc_html__( 'Customer email is required', 'activetrail-for-woocommerce' ), ); } // Use passed $group_id or fallback to the one in cart_data. if ( empty( $group_id ) && ! empty( $cart_data['group_id'] ) && is_numeric( $cart_data['group_id'] ) && intval( $cart_data['group_id'] ) > 0 ) { $group_id = intval( $cart_data['group_id'] ); } // Generate proper external ID. $external_id = ''; if ( function_exists( 'activetrail_external_id_for_contact' ) ) { $external_id = activetrail_external_id_for_contact( 0, $cart_data['email'] ); } else { $external_id = 'WC-CONTACT-GUEST-' . md5( $cart_data['email'] ); } // Prepare customer data. $customer_data = array( 'email' => $cart_data['email'], 'first_name' => isset( $cart_data['firstName'] ) ? $cart_data['firstName'] : ( isset( $cart_data['first_name'] ) ? $cart_data['first_name'] : '' ), 'last_name' => isset( $cart_data['lastName'] ) ? $cart_data['lastName'] : ( isset( $cart_data['last_name'] ) ? $cart_data['last_name'] : '' ), 'externalId' => $external_id, 'externalName' => 'WooCommerce', ); // Add phone/mobile (map mobile to sms for API). if ( ! empty( $cart_data['mobile'] ) ) { $customer_data['sms'] = $cart_data['mobile']; } elseif ( ! empty( $cart_data['sms'] ) ) { $customer_data['sms'] = $cart_data['sms']; } elseif ( ! empty( $cart_data['phone'] ) ) { $customer_data['sms'] = $cart_data['phone']; } // Add address (map address to street for API). if ( ! empty( $cart_data['address'] ) ) { $customer_data['street'] = $cart_data['address']; } elseif ( ! empty( $cart_data['street'] ) ) { $customer_data['street'] = $cart_data['street']; } // Add city. if ( ! empty( $cart_data['city'] ) ) { $customer_data['city'] = $cart_data['city']; } // Add zip code if available. if ( ! empty( $cart_data['zip_code'] ) ) { $customer_data['zip_code'] = $cart_data['zip_code']; $this->log_api_debug( 'Added zip_code to customer_data in ensure_customer_exists', array( 'zip_code' => $cart_data['zip_code'], 'email' => $cart_data['email'], ) ); } elseif ( ! empty( $cart_data['postcode'] ) ) { $customer_data['zip_code'] = $cart_data['postcode']; $this->log_api_debug( 'Added postcode as zip_code to customer_data in ensure_customer_exists', array( 'postcode' => $cart_data['postcode'], 'email' => $cart_data['email'], ) ); } else { $this->log_api_debug( 'No zip_code or postcode found in cart_data', array( 'cart_data_keys' => array_keys( $cart_data ), 'email' => $cart_data['email'], ) ); } // Add tracking_consent (CNIL) from cart_data if available - always a boolean. if ( isset( $cart_data['tracking_consent'] ) ) { $customer_data['tracking_consent'] = (bool) $cart_data['tracking_consent']; } // Add subscriptionStatus from cart_data if available. if ( isset( $cart_data['subscriptionStatus'] ) ) { $customer_data['subscriptionStatus'] = $cart_data['subscriptionStatus']; $this->log_api_debug( 'Added subscriptionStatus to customer_data in ensure_customer_exists', array( 'subscriptionStatus' => $cart_data['subscriptionStatus'], 'email' => $cart_data['email'], ) ); } else { $this->log_api_debug( 'subscriptionStatus NOT found in cart_data', array( 'cart_data_keys' => array_keys( $cart_data ), 'email' => $cart_data['email'], ) ); } // Inject groupId if available. if ( ! empty( $group_id ) ) { $customer_data['groupId'] = $group_id; $this->log_api_debug( 'Injected groupId in ensure_customer_exists', array( 'groupId' => $group_id ) ); } else { $this->log_api_debug( 'No valid group_id passed to ensure_customer_exists - will import without group', array( 'group_id' => $group_id ) ); } // Try to import customer (will create if doesn't exist). // Note: import_customer can work without group_id - it will just import the customer without assigning to a group. return $this->import_customer( $customer_data ); } /** * Submit products to ActiveTrail * * @param array $products_data Products data. * @return array */ public function submit_products( $products_data ) { if ( empty( $this->api_key ) ) { return array( 'success' => false, 'message' => esc_html__( 'API key is required', 'activetrail-for-woocommerce' ), ); } // Base URL already includes /api. $endpoint = '/commerce/product'; $response = $this->make_request( 'POST', $endpoint, $products_data ); if ( is_wp_error( $response ) ) { $this->log_api_error( 'Products Submit', $response->get_error_message(), $products_data ); return array( 'success' => false, 'message' => $response->get_error_message(), ); } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); $response_code = wp_remote_retrieve_response_code( $response ); if ( in_array( $response_code, array( 200, 201 ) ) ) { $this->log_api_success( 'Products Submit', 'Products submitted successfully', $data ); return array( 'success' => true, 'message' => esc_html__( 'Products submitted successfully', 'activetrail-for-woocommerce' ), 'data' => $data, ); } else { $error_message = $this->extract_error_message( $data ); $this->log_api_error( 'Products Submit', $error_message, array( 'response_code' => $response_code, 'response_data' => $data, 'request_data' => $products_data, ) ); return array( 'success' => false, 'message' => $error_message, 'data' => $data, ); } } /** * Update existing customer by external ID * * @param array $existing_data Existing customer data. * @param array $new_data New customer data. * @return array */ private function update_existing_customer_by_external_id( $existing_data, $new_data ) { // Customer exists - update them with new data using PUT request. // Prefer the externalId returned from ActiveTrail (existing_data) over the incoming one to avoid 404s. $existing_external_id = isset( $existing_data['externalId'] ) ? $existing_data['externalId'] : ''; $incoming_external_id = isset( $new_data['externalId'] ) ? $new_data['externalId'] : ''; $external_id = ''; if ( ! empty( $existing_external_id ) ) { $external_id = $existing_external_id; } elseif ( ! empty( $incoming_external_id ) ) { $external_id = $incoming_external_id; } if ( empty( $external_id ) ) { $this->log_api_error( 'Customer Update', 'Cannot update customer: externalId is missing', array( 'email' => isset( $new_data['email'] ) ? $new_data['email'] : 'unknown', 'existing_customer_id' => isset( $existing_data['id'] ) ? $existing_data['id'] : 'unknown', ) ); return array( 'success' => false, 'message' => esc_html__( 'Cannot update customer: externalId is missing', 'activetrail-for-woocommerce' ), 'data' => $existing_data, ); } // If incoming externalId differs from existing, log and override to the existing one to prevent 404. if ( ! empty( $incoming_external_id ) && $incoming_external_id !== $external_id ) { $this->log_api_debug( 'Incoming externalId differs from existing - overriding to existing', array( 'incoming_externalId' => $incoming_external_id, 'existing_externalId' => $external_id, 'email' => isset( $new_data['email'] ) ? $new_data['email'] : 'unknown', ) ); } // Keep new_data externalId in sync with the selected external_id. $new_data['externalId'] = $external_id; // Use update_customer to perform PUT request with all customer data. // This will update address, phone, city, zip_code regardless of subscription status. $update_result = $this->update_customer( $external_id, $new_data ); if ( $update_result['success'] ) { $this->log_api_success( 'Customer Update', 'Customer updated successfully with all contact details', array( 'email' => isset( $new_data['email'] ) ? $new_data['email'] : 'unknown', 'external_id' => $external_id, 'has_address' => ! empty( $new_data['street'] ), 'has_city' => ! empty( $new_data['city'] ), 'has_zip_code' => ! empty( $new_data['zip_code'] ), 'has_phone' => ! empty( $new_data['sms'] ), ) ); } return $update_result; } /** * Smart group addition - handles both regular and external groups * Enhanced to gracefully handle group assignment failures * * @param string|int $group_id Group ID. * @param array $customer_data Customer data. * @param array $existing_data Existing customer data (optional) * @return array */ private function add_customer_to_group_smart( $group_id, $customer_data, $existing_data = null ) { // Directly use regular group endpoint as external group endpoint is deprecated. return $this->add_customer_to_regular_group( $group_id, $customer_data ); } /** * Add customer to regular group using groups API * * @param string|int $group_id Group ID. * @param array $customer_data Customer data. * @return array */ private function add_customer_to_regular_group( $group_id, $customer_data ) { $endpoint = '/groups/' . urlencode( $group_id ) . '/members'; // Prepare data for regular group addition (different format). $member_data = array( 'email' => $customer_data['email'], 'first_name' => isset( $customer_data['first_name'] ) ? $customer_data['first_name'] : '', 'last_name' => isset( $customer_data['last_name'] ) ? $customer_data['last_name'] : '', 'sms' => isset( $customer_data['sms'] ) ? $customer_data['sms'] : '', 'city' => isset( $customer_data['city'] ) ? $customer_data['city'] : '', 'street' => isset( $customer_data['street'] ) ? $customer_data['street'] : '', 'zip_code' => isset( $customer_data['zip_code'] ) ? $customer_data['zip_code'] : '', 'status' => 'Subscribed', // Default status for regular groups 'sms_status' => 'None', ); $response = $this->make_request_with_retry( 'POST', $endpoint, $member_data ); if ( is_wp_error( $response ) ) { return array( 'success' => false, 'message' => $response->get_error_message(), 'response_code' => 0, ); } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); $response_code = wp_remote_retrieve_response_code( $response ); if ( in_array( $response_code, array( 200, 201 ) ) ) { $this->log_api_success( 'Regular Group Update', 'Customer added to regular group successfully', $data ); return array( 'success' => true, 'message' => esc_html__( 'Customer added to regular group successfully', 'activetrail-for-woocommerce' ), 'data' => $data, ); } else { $error_message = $this->extract_error_message( $data ); // Check if customer is already in group or if endpoint is forbidden (405). // 405 Forbidden usually means the customer is already in the group or endpoint not available. if ( $response_code === 405 || strpos( $error_message, 'already exists' ) !== false || strpos( $error_message, 'duplicate' ) !== false || strpos( $error_message, 'already a member' ) !== false || strpos( $error_message, 'Forbidden' ) !== false || strpos( $error_message, 'not permitted' ) !== false ) { // Treat as success - customer is likely already in the group or endpoint not accessible. $this->log_api_success( 'Regular Group Update', 'Customer already in group or endpoint not accessible (treating as success)', array( 'response_code' => $response_code, 'error_message' => $error_message, ) ); return array( 'success' => true, 'message' => esc_html__( 'Customer already in group or group assignment not needed', 'activetrail-for-woocommerce' ), 'data' => $data, ); } return array( 'success' => false, 'message' => $error_message, 'response_code' => $response_code, 'data' => $data, ); } } /** * Import single customer using the import endpoint * * @param array $customer_data Customer data. * @return array */ private function import_single_customer( $customer_data ) { $group_id = isset( $customer_data['groupId'] ) ? $customer_data['groupId'] : ( isset( $customer_data['group_id'] ) ? $customer_data['group_id'] : null ); // Remove groupId from customer data as it's not part of the contact structure. $clean_customer_data = $customer_data; unset( $clean_customer_data['groupId'] ); unset( $clean_customer_data['group_id'] ); // Ensure externalId in contact matches the root externalId. $clean_customer_data['externalId'] = $customer_data['externalId']; $clean_customer_data['externalName'] = $customer_data['externalName']; // Rename subscriberStatus to subscriptionStatus if present. if ( isset( $clean_customer_data['subscriberStatus'] ) ) { $clean_customer_data['subscriptionStatus'] = $clean_customer_data['subscriberStatus']; unset( $clean_customer_data['subscriberStatus'] ); } // Prepare the import data according to the API documentation. $import_payload = array( 'contacts' => array( array( 'externalId' => $customer_data['externalId'], 'externalName' => $customer_data['externalName'], 'contact' => $clean_customer_data, ), ), ); // Add group ID if provided and ensure it's an integer greater than 0. if ( ! empty( $group_id ) && is_numeric( $group_id ) && intval( $group_id ) > 0 ) { $import_payload['group'] = intval( $group_id ); $this->log_api_debug( 'Import Customer - Group ID added to import payload', array( 'group_id' => intval( $group_id ), 'external_id' => $customer_data['externalId'], ) ); } else { // Don't add group to import payload if not valid. $this->log_api_debug( 'Import Customer - No valid group ID provided', array( 'group_id_raw' => $group_id, 'external_id' => $customer_data['externalId'], ) ); } $response = $this->make_request_with_retry( 'POST', '/external/import', $import_payload ); if ( is_wp_error( $response ) ) { $this->log_api_error( 'Customer Import', $response->get_error_message(), $customer_data ); return array( 'success' => false, 'message' => $response->get_error_message(), ); } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); $response_code = wp_remote_retrieve_response_code( $response ); if ( in_array( $response_code, array( 200, 201 ) ) ) { $this->log_api_success( 'Customer Import', 'Customer imported successfully', $data ); // If import was successful but we still need to handle group assignment. if ( ! empty( $group_id ) && isset( $data['success_count'] ) && $data['success_count'] > 0 ) { // Customer was imported successfully, group should be assigned automatically. return array( 'success' => true, 'message' => esc_html__( 'Customer imported and added to group successfully', 'activetrail-for-woocommerce' ), 'data' => $data, ); } return array( 'success' => true, 'message' => esc_html__( 'Customer imported successfully', 'activetrail-for-woocommerce' ), 'data' => $data, ); } else { $error_message = $this->extract_error_message( $data ); // Check if it's the import limit error. if ( strpos( $error_message, 'Imports contacts is limited to 1000' ) !== false ) { // Log the limit issue but don't fail completely. $this->log_api_error( 'Customer Import - Limit Reached', 'Import limit reached, customer sync skipped', array( 'response_code' => $response_code, 'response_data' => $data, 'request_data' => $import_payload, ) ); // Return a "success" but with a warning message. return array( 'success' => true, // Don't fail the order sync because of this 'message' => esc_html__( 'Customer sync skipped due to ActiveTrail import limits', 'activetrail-for-woocommerce' ), 'data' => array( 'warning' => 'import_limit_reached' ), ); } // If customer already exists, try to update instead. if ( strpos( $error_message, 'already exists' ) !== false || strpos( $error_message, 'duplicate' ) !== false || strpos( $error_message, 'exists' ) !== false ) { $this->log_api_error( 'Customer Import - Duplicate Found', 'Customer exists, attempting to get and update', array( 'response_code' => $response_code, 'response_data' => $data, 'request_data' => $import_payload, ) ); // Try to get the existing customer and update. data may be array of contacts; use first. $existing_customer = $this->get_customer_by_email( $customer_data['email'] ); if ( ! is_wp_error( $existing_customer ) && $existing_customer['exists'] ) { $data = $existing_customer['data']; $contact = ( is_array( $data ) && isset( $data[0] ) ) ? $data[0] : $data; return $this->update_existing_customer_by_external_id( $contact, $customer_data ); } } $this->log_api_error( 'Customer Import', $error_message, array( 'response_code' => $response_code, 'response_data' => $data, 'request_data' => $import_payload, ) ); return array( 'success' => false, 'message' => $error_message, 'data' => $data, ); } } /** * Import multiple contacts in batch (up to 1000 contacts per request) * * @param array $contacts_array Array of contact data arrays. * @param int|null $group_id Group ID to assign contacts to. * @return array */ public function import_contacts_batch( $contacts_array, $group_id = null ) { if ( empty( $this->api_key ) ) { return array( 'success' => false, 'message' => esc_html__( 'API key is required', 'activetrail-for-woocommerce' ), ); } // Validate contacts array. if ( empty( $contacts_array ) || ! is_array( $contacts_array ) ) { return array( 'success' => false, 'message' => esc_html__( 'Contacts array is required', 'activetrail-for-woocommerce' ), ); } // Limit to 1000 contacts per API request. if ( count( $contacts_array ) > 1000 ) { return array( 'success' => false, 'message' => esc_html__( 'Batch import is limited to 1000 contacts per request', 'activetrail-for-woocommerce' ), ); } // Prepare contacts array in the format required by API. $formatted_contacts = array(); foreach ( $contacts_array as $contact_data ) { // Validate required fields. if ( empty( $contact_data['email'] ) ) { continue; // Skip invalid contacts } // Generate external ID if not provided. $external_id = ''; if ( ! empty( $contact_data['externalId'] ) ) { $external_id = $contact_data['externalId']; } elseif ( function_exists( 'activetrail_external_id_for_contact' ) ) { $user_id = isset( $contact_data['user_id'] ) ? $contact_data['user_id'] : 0; $external_id = activetrail_external_id_for_contact( $user_id, $contact_data['email'] ); } else { $user_id = isset( $contact_data['user_id'] ) ? $contact_data['user_id'] : 0; if ( $user_id ) { $external_id = 'WC-CONTACT-' . $user_id; } else { $external_id = 'WC-CONTACT-GUEST-' . md5( $contact_data['email'] ); } } // Clean contact data - remove fields that are not part of contact structure. $clean_contact = $contact_data; unset( $clean_contact['groupId'] ); unset( $clean_contact['group_id'] ); unset( $clean_contact['externalId'] ); unset( $clean_contact['externalName'] ); unset( $clean_contact['user_id'] ); unset( $clean_contact['order_id'] ); unset( $clean_contact['consent_source'] ); unset( $clean_contact['registration_date'] ); unset( $clean_contact['source'] ); // Rename subscriberStatus to subscriptionStatus if present. if ( isset( $clean_contact['subscriberStatus'] ) ) { $clean_contact['subscriptionStatus'] = $clean_contact['subscriberStatus']; unset( $clean_contact['subscriberStatus'] ); } // Map phone to sms if needed. if ( empty( $clean_contact['sms'] ) && ! empty( $clean_contact['phone'] ) ) { $clean_contact['sms'] = $clean_contact['phone']; unset( $clean_contact['phone'] ); } // Get external name. $external_name = ! empty( $contact_data['externalName'] ) ? $contact_data['externalName'] : ACTIVETRAIL_EXTERNAL_NAME; $formatted_contacts[] = array( 'externalId' => $external_id, 'externalName' => $external_name, 'contact' => $clean_contact, ); } if ( empty( $formatted_contacts ) ) { return array( 'success' => false, 'message' => esc_html__( 'No valid contacts to import', 'activetrail-for-woocommerce' ), ); } // Prepare the import payload. $import_payload = array( 'contacts' => $formatted_contacts, ); // Add group ID if provided. if ( ! empty( $group_id ) && is_numeric( $group_id ) && intval( $group_id ) > 0 ) { $import_payload['group'] = intval( $group_id ); } $this->log_api_debug( 'Batch Import Contacts', 'Sending batch import request', array( 'contacts_count' => count( $formatted_contacts ), 'group_id' => $group_id, ) ); $response = $this->make_request_with_retry( 'POST', '/external/import', $import_payload ); if ( is_wp_error( $response ) ) { $this->log_api_error( 'Batch Import Contacts', $response->get_error_message(), array( 'contacts_count' => count( $formatted_contacts ), ) ); return array( 'success' => false, 'message' => $response->get_error_message(), ); } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); $response_code = wp_remote_retrieve_response_code( $response ); if ( in_array( $response_code, array( 200, 201 ) ) ) { $success_count = isset( $data['success_count'] ) ? intval( $data['success_count'] ) : count( $formatted_contacts ); $failed_count = isset( $data['failed_count'] ) ? intval( $data['failed_count'] ) : 0; $this->log_api_success( 'Batch Import Contacts', sprintf( 'Imported %d contacts successfully', $success_count ), array( 'success_count' => $success_count, 'failed_count' => $failed_count, 'total' => count( $formatted_contacts ), ) ); return array( 'success' => true, /* translators: %d: Number of successfully imported contacts */ 'message' => sprintf( esc_html__( 'Successfully imported %d contacts', 'activetrail-for-woocommerce' ), $success_count ), 'data' => $data, 'success_count' => $success_count, 'failed_count' => $failed_count, ); } else { $error_message = $this->extract_error_message( $data ); $this->log_api_error( 'Batch Import Contacts', $error_message, array( 'response_code' => $response_code, 'response_data' => $data, 'contacts_count' => count( $formatted_contacts ), ) ); return array( 'success' => false, 'message' => $error_message, 'data' => $data, ); } } /** * Update customer in ActiveTrail * * @param string $external_id External customer ID. * @param array|WC_Customer $customer_data Customer data or WC_Customer object. * @return array */ public function update_customer( $external_id, $customer_data ) { $this->log( '========== API CLIENT: UPDATE_CUSTOMER START ==========', array(), 'info' ); $this->log( 'Received external_id: ' . ( $external_id ? $external_id : 'EMPTY!!!' ), array(), 'debug' ); $this->log( 'Received customer_data type: ' . gettype( $customer_data ), array(), 'debug' ); if ( empty( $this->api_key ) ) { $this->log( '✗✗✗ FAILED: API key is empty', array(), 'error' ); return array( 'success' => false, 'message' => esc_html__( 'API key is required', 'activetrail-for-woocommerce' ), ); } $this->log( '✓ API key exists', array(), 'debug' ); if ( empty( $external_id ) ) { $this->log( '✗✗✗ CRITICAL: External ID is EMPTY - cannot proceed with update!', array(), 'error' ); return array( 'success' => false, 'message' => esc_html__( 'External ID is required for customer update', 'activetrail-for-woocommerce' ), ); } $this->log( '✓ External ID validated: ' . $external_id, array(), 'debug' ); // Transform WooCommerce customer to ActiveTrail format if needed. if ( is_object( $customer_data ) && method_exists( $customer_data, 'get_email' ) ) { $this->log( 'Customer data is WC object, transforming...', array(), 'debug' ); $customer_data = $this->transform_wc_customer_to_activetrail( $customer_data ); $this->log( '✓ Transformed to array', array(), 'debug' ); } else { $this->log( 'Customer data is already array', array(), 'debug' ); } // Validate required fields. $this->log( 'Validating customer data...', array(), 'debug' ); $validation = $this->validate_customer_data( $customer_data ); if ( ! $validation['valid'] ) { $this->log( '✗✗✗ VALIDATION FAILED: ' . $validation['message'], array(), 'error' ); return array( 'success' => false, 'message' => $validation['message'], ); } $this->log( '✓ Customer data validated successfully', array(), 'debug' ); // Get externalName from customer_data or use default. $external_name = isset( $customer_data['externalName'] ) ? $customer_data['externalName'] : 'WooCommerce'; $this->log( 'External name: ' . $external_name, array(), 'debug' ); // Prepare contact data in the correct API format. $this->log( '========== PREPARING API PAYLOAD ==========', array(), 'debug' ); $contact_update = array(); // Add email (required). if ( ! empty( $customer_data['email'] ) ) { $contact_update['email'] = $customer_data['email']; $this->log( '✓ Added email: ' . $customer_data['email'], array(), 'debug' ); } else { $this->log( '✗ Email is empty!', array(), 'warning' ); } // Add SMS/phone - check multiple fields for phone number. if ( ! empty( $customer_data['sms'] ) ) { $contact_update['sms'] = $customer_data['sms']; $this->log( '✓ Added sms: ' . $customer_data['sms'], array(), 'debug' ); } elseif ( ! empty( $customer_data['phone'] ) ) { $contact_update['sms'] = $customer_data['phone']; $this->log( '✓ Added phone as sms: ' . $customer_data['phone'], array(), 'debug' ); } elseif ( ! empty( $customer_data['mobile'] ) ) { $contact_update['sms'] = $customer_data['mobile']; $this->log( '✓ Added mobile as sms: ' . $customer_data['mobile'], array(), 'debug' ); } else { $this->log( '- SMS/phone is empty (optional)', array(), 'debug' ); } // Add names - API expects first_name and last_name (snake_case) to match import format. if ( isset( $customer_data['first_name'] ) && ! empty( $customer_data['first_name'] ) ) { $contact_update['first_name'] = $customer_data['first_name']; $this->log( '✓ Added first_name: ' . $customer_data['first_name'], array(), 'debug' ); } elseif ( isset( $customer_data['firstName'] ) && ! empty( $customer_data['firstName'] ) ) { $contact_update['first_name'] = $customer_data['firstName']; $this->log( '✓ Added first_name (from firstName): ' . $customer_data['firstName'], array(), 'debug' ); } if ( isset( $customer_data['last_name'] ) && ! empty( $customer_data['last_name'] ) ) { $contact_update['last_name'] = $customer_data['last_name']; $this->log( '✓ Added last_name: ' . $customer_data['last_name'], array(), 'debug' ); } elseif ( isset( $customer_data['lastName'] ) && ! empty( $customer_data['lastName'] ) ) { $contact_update['last_name'] = $customer_data['lastName']; $this->log( '✓ Added last_name (from lastName): ' . $customer_data['lastName'], array(), 'debug' ); } // Add address fields - check multiple field names for compatibility. if ( ! empty( $customer_data['city'] ) ) { $contact_update['city'] = $customer_data['city']; $this->log( '✓ Added city: ' . $customer_data['city'], array(), 'debug' ); } // Street/address - check both field names. if ( ! empty( $customer_data['street'] ) ) { $contact_update['street'] = $customer_data['street']; $this->log( '✓ Added street: ' . $customer_data['street'], array(), 'debug' ); } elseif ( ! empty( $customer_data['address'] ) ) { $contact_update['street'] = $customer_data['address']; $this->log( '✓ Added address as street: ' . $customer_data['address'], array(), 'debug' ); } // Zip code/postcode - check both field names. if ( ! empty( $customer_data['zip_code'] ) ) { $contact_update['zip_code'] = $customer_data['zip_code']; $this->log( '✓ Added zip_code: ' . $customer_data['zip_code'], array(), 'debug' ); } elseif ( ! empty( $customer_data['postcode'] ) ) { $contact_update['zip_code'] = $customer_data['postcode']; $this->log( '✓ Added postcode as zip_code: ' . $customer_data['postcode'], array(), 'debug' ); } // Convert subscriptionStatus to status enum. // status: None=0, Subscribed=1, Unsubscribed=2, Pending=3. $this->log( 'Processing subscriptionStatus...', array(), 'debug' ); if ( isset( $customer_data['subscriptionStatus'] ) ) { $status_value = $customer_data['subscriptionStatus'] ? 1 : 2; // 1=Subscribed, 2=Unsubscribed $contact_update['status'] = $status_value; $this->log( '✓ Set status to: ' . $status_value . ' (' . ( $status_value === 1 ? 'Subscribed' : 'Unsubscribed' ) . ')', array(), 'info' ); } else { $this->log( '✗ subscriptionStatus not found in customer_data!', array(), 'warning' ); } // Wrap in contact object as required by API. $update_payload = array( 'contact' => $contact_update, ); $this->log( 'Final payload prepared:', array(), 'debug' ); $this->log( ' - contact fields count: ' . count( $contact_update ), array(), 'debug' ); $this->log( ' - Full payload: ' . wp_json_encode( $update_payload ), array(), 'debug' ); // Add externalName as query parameter. $endpoint = '/external/contact/' . urlencode( $external_id ) . '?externalName=' . urlencode( $external_name ); $this->log( '========== MAKING API REQUEST ==========', array(), 'info' ); $this->log( 'Endpoint: PUT ' . $endpoint, array(), 'info' ); $this->log( 'Full URL: ' . self::API_BASE_URL . $endpoint, array(), 'debug' ); $this->log_api_debug( 'Customer Update Request Details', array( 'external_id' => $external_id, 'external_name' => $external_name, 'endpoint' => $endpoint, 'payload' => $update_payload, ) ); $response = $this->make_request_with_retry( 'PUT', $endpoint, $update_payload ); $this->log( '========== API RESPONSE RECEIVED ==========', array(), 'debug' ); if ( is_wp_error( $response ) ) { $error_msg = $response->get_error_message(); $this->log( '✗✗✗ WP_Error received: ' . $error_msg, array(), 'error' ); $this->log_api_error( 'Customer Update', $error_msg, $update_payload ); return array( 'success' => false, 'message' => $error_msg, ); } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); $response_code = wp_remote_retrieve_response_code( $response ); $this->log( 'Response code: ' . $response_code, array(), 'info' ); $this->log( 'Response body: ' . $body, array(), 'debug' ); $this->log( 'Decoded data: ' . wp_json_encode( $data ), array(), 'debug' ); if ( in_array( $response_code, array( 200, 201, 204 ) ) ) { $this->log( '✓✓✓ SUCCESS: Customer updated successfully (HTTP ' . $response_code . ')', array(), 'info' ); $this->log_api_success( 'Customer Update', 'Customer updated successfully', $data ); $this->log( '========== API CLIENT: UPDATE_CUSTOMER END (SUCCESS) ==========', array(), 'info' ); return array( 'success' => true, 'message' => esc_html__( 'Customer updated successfully', 'activetrail-for-woocommerce' ), 'data' => $data, ); } else { $error_message = $this->extract_error_message( $data ); $this->log( '✗✗✗ FAILED: HTTP ' . $response_code . ' - ' . $error_message, array(), 'error' ); $this->log_api_error( 'Customer Update', $error_message, array( 'external_id' => $external_id, 'response_code' => $response_code, 'response_data' => $data, 'request_data' => $update_payload, ) ); $this->log( '========== API CLIENT: UPDATE_CUSTOMER END (FAILED) ==========', array(), 'error' ); return array( 'success' => false, 'message' => $error_message, 'data' => $data, ); } } /** * Make a generic request to the ActiveTrail API * * @param string $method HTTP method (GET, POST, PUT, DELETE). * @param string $endpoint API endpoint. * @param array $data Request data. * @return array Response data */ public function request( $method, $endpoint, $data = array() ) { if ( empty( $this->api_key ) ) { return array( 'success' => false, 'message' => esc_html__( 'API key is required', 'activetrail-for-woocommerce' ), ); } // Log the request for debugging. $this->log_api_debug( "{$method} {$endpoint}", $data ); // Make the request. $response = $this->make_request_with_retry( $method, $endpoint, $data ); if ( is_wp_error( $response ) ) { $this->log_api_error( "{$method} Request", $response->get_error_message(), $data ); return array( 'success' => false, 'message' => $response->get_error_message(), ); } $body = wp_remote_retrieve_body( $response ); $response_data = json_decode( $body, true ); $response_code = wp_remote_retrieve_response_code( $response ); if ( in_array( $response_code, array( 200, 201, 204 ) ) ) { $this->log_api_success( "{$method} Request", "Request to {$endpoint} successful", $response_data ); return array( 'success' => true, 'message' => esc_html__( 'Request successful', 'activetrail-for-woocommerce' ), 'data' => $response_data, ); } else { $error_message = $this->extract_error_message( $response_data ); $this->log_api_error( "{$method} Request", $error_message, array( 'response_code' => $response_code, 'response_data' => $response_data, 'request_data' => $data, ) ); return array( 'success' => false, 'message' => $error_message, 'data' => $response_data, ); } } /** * Submit order to ActiveTrail * * @param array|WC_Order $order_data Order data or WC_Order object. * @return array */ public function submit_order( $order_data ) { if ( empty( $this->api_key ) ) { return array( 'success' => false, 'message' => esc_html__( 'API key is required', 'activetrail-for-woocommerce' ), ); } // Transform WooCommerce order to ActiveTrail format if needed. if ( is_object( $order_data ) && method_exists( $order_data, 'get_id' ) ) { $order_data = $this->transform_wc_order_to_activetrail( $order_data ); } // Validate required fields. $validation = $this->validate_order_data( $order_data ); if ( ! $validation['valid'] ) { return array( 'success' => false, 'message' => $validation['message'], ); } // ActiveTrail expects an array of orders, not a single order. $order_array = array( $order_data ); $response = $this->make_request_with_retry( 'POST', '/commerce/Order', $order_array ); if ( is_wp_error( $response ) ) { $this->log_api_error( 'Order Submit', $response->get_error_message(), $order_data ); return array( 'success' => false, 'message' => $response->get_error_message(), ); } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); $response_code = wp_remote_retrieve_response_code( $response ); if ( in_array( $response_code, array( 200, 201 ) ) ) { // Check if ActiveTrail API returned success. if ( isset( $data['success'] ) && $data['success'] > 0 ) { $this->log_api_success( 'Order Submit', 'Order submitted successfully', $data ); return array( 'success' => true, 'message' => esc_html__( 'Order sent successfully', 'activetrail-for-woocommerce' ), 'data' => $data, ); } else { // ActiveTrail returned HTTP 200 but with errors. $error_message = 'Order submission failed'; if ( isset( $data['errors'] ) && is_array( $data['errors'] ) && ! empty( $data['errors'] ) ) { $errors = array(); foreach ( $data['errors'] as $field => $error ) { $errors[] = $field . ': ' . $error; } $error_message = implode( ', ', $errors ); } elseif ( isset( $data['success'] ) && $data['success'] == 0 ) { $error_message = 'ActiveTrail API returned success=0 - order was rejected'; } $this->log_api_error( 'Order Submit', $error_message, array( 'response_code' => $response_code, 'response_data' => $data, 'request_data' => $order_array, ) ); return array( 'success' => false, 'message' => $error_message, 'data' => $data, ); } } else { $error_message = $this->extract_error_message( $data ); $this->log_api_error( 'Order Submit', $error_message, array( 'response_code' => $response_code, 'response_data' => $data, 'request_data' => $order_array, ) ); return array( 'success' => false, 'message' => $error_message, 'data' => $data, ); } } // Legacy method aliases for backward compatibility. public function send_order( $order_data ) { return $this->submit_order( $order_data ); } public function send_cart( $cart_data ) { return $this->submit_cart( $cart_data ); } /** * Create coupon via ActiveTrail API * * @param array $coupon_data Coupon data. * @return array */ public function create_coupon( $coupon_data ) { if ( empty( $this->api_key ) ) { return array( 'success' => false, 'message' => esc_html__( 'API key is required', 'activetrail-for-woocommerce' ), ); } $response = $this->make_request( 'POST', '/commerce/Coupon', $coupon_data ); if ( is_wp_error( $response ) ) { $this->log_api_error( 'Coupon Create', $response->get_error_message(), $coupon_data ); return array( 'success' => false, 'message' => $response->get_error_message(), ); } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); $response_code = wp_remote_retrieve_response_code( $response ); if ( in_array( $response_code, array( 200, 201 ) ) ) { $this->log_api_success( 'Coupon Create', 'Coupon created successfully', $data ); return array( 'success' => true, 'message' => esc_html__( 'Coupon created successfully', 'activetrail-for-woocommerce' ), 'data' => $data, ); } else { $error_message = $this->extract_error_message( $data ); $this->log_api_error( 'Coupon Create', $error_message, array( 'response_code' => $response_code, 'response_data' => $data, 'request_data' => $coupon_data, ) ); return array( 'success' => false, 'message' => $error_message, 'data' => $data, ); } } /** * Make HTTP request to ActiveTrail API with improved error handling and timeouts * * @param string $method HTTP method (GET, POST, PUT, DELETE). * @param string $endpoint API endpoint. * @param array $data Request data. * @return array|WP_Error */ private function make_request( $method, $endpoint, $data = array() ) { $url = self::API_BASE_URL . $endpoint; // ActiveTrail uses simple token authentication, not Bearer token. $headers = array( 'Authorization' => $this->api_key, 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'User-Agent' => 'ActiveTrail-WooCommerce/' . ACTIVETRAIL_WC_VERSION, ); $args = array( 'method' => $method, 'headers' => $headers, 'timeout' => 40, // Increased overall timeout 'connect_timeout' => 10, // Specific connect timeout 'sslverify' => true, ); if ( ! empty( $data ) && in_array( $method, array( 'POST', 'PUT', 'PATCH' ) ) ) { $args['body'] = wp_json_encode( $data ); } // Log the request for debugging (with PII masking). $this->log_request( $method, $url, $this->mask_pii_in_data( $data ) ); $response = wp_remote_request( $url, $args ); // Log the response for debugging (with PII masking). $this->log_response( $response ); return $response; } /** * Log API request * * @param string $method HTTP method. * @param string $url Request URL. * @param array $data Request data. */ private function log_request( $method, $url, $data ) { if ( function_exists( 'activetrail_logger' ) ) { $log_data = array( 'method' => $method, 'url' => $url, 'data' => $data, ); activetrail_logger()->api( 'API Request: ' . $method . ' ' . $url, 'DEBUG', $log_data ); } } /** * Log API response * * @param array|WP_Error $response API response. */ private function log_response( $response ) { if ( function_exists( 'activetrail_logger' ) ) { if ( is_wp_error( $response ) ) { activetrail_logger()->api( 'API Response Error: ' . $response->get_error_message(), 'ERROR' ); } else { $log_data = array( 'code' => wp_remote_retrieve_response_code( $response ), 'body' => wp_remote_retrieve_body( $response ), ); activetrail_logger()->api( 'API Response', 'INFO', $log_data ); } } } /** * Set API key * * @param string $api_key API key. */ public function set_api_key( $api_key ) { $this->api_key = $api_key; } /** * Get API key * * @return string */ public function get_api_key() { return $this->api_key; } /** * Log request error * * @param string $context Error context. * @param string $error_message Error message. */ private function log_request_error( $context, $error_message ) { if ( function_exists( 'activetrail_logger' ) ) { activetrail_logger()->api( $context . ': ' . $error_message, 'ERROR' ); } } /** * Log request debug information * * @param string $context Debug context. * @param array $debug_data Debug data. */ private function log_request_debug( $context, $debug_data ) { if ( function_exists( 'activetrail_logger' ) ) { activetrail_logger()->api( $context, 'DEBUG', $debug_data ); } } // ========================================. // ENHANCED METHODS - ENTERPRISE FEATURES. // ========================================. /** * Make HTTP request with retry logic * * @param string $method HTTP method. * @param string $endpoint API endpoint. * @param array $data Request data. * @param int $retry_count Current retry attempt. * @return array|WP_Error */ private function make_request_with_retry( $method, $endpoint, $data = array(), $retry_count = 0 ) { $response = $this->make_request( $method, $endpoint, $data ); if ( is_wp_error( $response ) ) { return $response; } $response_code = wp_remote_retrieve_response_code( $response ); // Retry logic for specific error codes. if ( $retry_count < 1 && $this->should_retry( $response_code ) ) { $this->log_request_debug( 'Retrying Request', array( 'method' => $method, 'endpoint' => $endpoint, 'response_code' => $response_code, 'retry_count' => $retry_count + 1, ) ); // Wait before retry. sleep( self::RETRY_DELAY ); return $this->make_request_with_retry( $method, $endpoint, $data, $retry_count + 1 ); } return $response; } /** * Determine if request should be retried * * @param int $response_code HTTP response code. * @return bool */ private function should_retry( $response_code ) { // Retry for rate limiting and service unavailable. return in_array( $response_code, array( 429, 503 ) ); } /** * Extract error message from API response * * @param array $data Response data. * @return string */ private function extract_error_message( $data ) { if ( isset( $data['Message'] ) ) { return $data['Message']; } if ( isset( $data['message'] ) ) { return $data['message']; } if ( isset( $data['error'] ) ) { return is_string( $data['error'] ) ? $data['error'] : wp_json_encode( $data['error'] ); } return esc_html__( 'Unknown API error occurred', 'activetrail-for-woocommerce' ); } /** * Transform WooCommerce customer to ActiveTrail format * * @param WC_Customer|WC_Order $customer WooCommerce customer object or order object. * @return array */ private function transform_wc_customer_to_activetrail( $customer ) { $settings = $this->get_plugin_settings(); // Handle both WC_Customer and WC_Order objects. if ( method_exists( $customer, 'get_billing_email' ) ) { // This is a WC_Order object. $customer_id = $customer->get_customer_id(); // Generate proper external ID. $external_id = ''; if ( function_exists( 'activetrail_external_id_for_contact' ) ) { // Use the helper function if available. $external_id = activetrail_external_id_for_contact( $customer_id, $customer->get_billing_email() ); } else { // Fallback to a safe format that doesn't use email directly. if ( $customer_id ) { $external_id = 'WC-CONTACT-' . $customer_id; } else { $external_id = 'WC-CONTACT-GUEST-' . md5( $customer->get_billing_email() ); } } $data = array( 'email' => $customer->get_billing_email(), 'sms' => $customer->get_billing_phone(), 'first_name' => $customer->get_billing_first_name(), 'last_name' => $customer->get_billing_last_name(), 'city' => $customer->get_billing_city(), 'street' => trim( $customer->get_billing_address_1() . ' ' . $customer->get_billing_address_2() ), 'zip_code' => $customer->get_billing_postcode(), 'externalId' => $external_id, 'externalName' => 'WooCommerce', ); } else { // This is a WC_Customer object. $customer_id = $customer->get_id(); // Generate proper external ID. $external_id = ''; if ( function_exists( 'activetrail_external_id_for_contact' ) ) { // Use the helper function if available. $external_id = activetrail_external_id_for_contact( $customer_id, $customer->get_email() ); } else { // Fallback to a safe format that doesn't use email directly. $external_id = 'WC-CONTACT-' . $customer_id; } $data = array( 'email' => $customer->get_email(), 'sms' => $customer->get_billing_phone(), 'first_name' => $customer->get_first_name(), 'last_name' => $customer->get_last_name(), 'city' => $customer->get_billing_city(), 'street' => $customer->get_billing_address_1(), 'zip_code' => $customer->get_billing_postcode(), 'externalId' => $external_id, 'externalName' => 'WooCommerce', ); } // Add group ID if configured. if ( ! empty( $settings['group_id'] ) ) { $data['groupId'] = intval( $settings['group_id'] ); } // Add store ID if configured. if ( ! empty( $settings['store_id'] ) ) { $data['storeId'] = intval( $settings['store_id'] ); } // Add birthday if available (custom field). if ( method_exists( $customer, 'get_meta' ) ) { $birthday = $customer->get_meta( 'billing_birthdate' ); if ( ! empty( $birthday ) ) { $data['birthday'] = $birthday; } } return apply_filters( 'activetrail_customer_data', $data, $customer ); } /** * Check if order has an order-level coupon (fixed_cart type) * * @param WC_Order $order Order object. * @return bool True if order has a fixed_cart coupon */ private function is_order_level_coupon( $order ) { foreach ( $order->get_items( 'coupon' ) as $coupon_item ) { $coupon = new WC_Coupon( $coupon_item->get_code() ); if ( 'fixed_cart' === $coupon->get_discount_type() ) { return true; // Applies to entire order } } return false; } /** * Get all coupon types used in an order * * @param WC_Order $order Order object. * @return array Array of coupon codes and their discount types */ private function get_coupon_types( $order ) { $types = array(); foreach ( $order->get_items( 'coupon' ) as $coupon_item ) { $coupon = new WC_Coupon( $coupon_item->get_code() ); $types[ $coupon->get_code() ] = $coupon->get_discount_type(); // e.g. fixed_cart, percent_product, fixed_product } return $types; } /** * Normalize order-like objects (refunds, overrides) to WC_Order instances that expose * customer and billing information. * * @param WC_Order|WC_Order_Refund|object|null $order * @return WC_Order|null */ private function resolve_order_with_customer_details( $order ) { if ( ! $order || ! is_object( $order ) ) { return null; } $has_customer_methods = method_exists( $order, 'get_customer_id' ) && method_exists( $order, 'get_billing_email' ); if ( $has_customer_methods ) { return $order; } $parent_order = null; if ( method_exists( $order, 'get_parent_id' ) ) { $parent_id = $order->get_parent_id(); if ( $parent_id ) { $parent_order = wc_get_order( $parent_id ); } } if ( $parent_order && method_exists( $parent_order, 'get_customer_id' ) && method_exists( $parent_order, 'get_billing_email' ) ) { $this->log( 'Resolved order object to parent order for customer details', 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 ), ), 'debug' ); return $parent_order; } $this->log( 'Unable to resolve order object with customer details', array( 'order_id' => method_exists( $order, 'get_id' ) ? $order->get_id() : null, 'order_type' => get_class( $order ), ), 'warning' ); return null; } /** * Transform WooCommerce order to ActiveTrail format * * @param WC_Order $order WooCommerce order object. * @return array */ public function transform_wc_order_to_activetrail( $order ) { $order = $this->resolve_order_with_customer_details( $order ); if ( ! $order ) { throw new \InvalidArgumentException( 'Invalid order provided for ActiveTrail order transformation.' ); } // Get settings first to ensure they're available throughout the method. $settings = $this->get_plugin_settings(); // Get all coupon types used in this order. $coupon_types = $this->get_coupon_types( $order ); // Only consider it an order-level discount if there's a fixed_cart coupon. $is_order_level_discount = ! empty( $coupon_types ) && in_array( 'fixed_cart', $coupon_types, true ); // Get all coupons used in this order with their details. $coupons = array(); foreach ( $order->get_items( 'coupon' ) as $coupon_item ) { $coupon_code = $coupon_item->get_code(); $coupon_obj = new WC_Coupon( $coupon_code ); $coupons[ $coupon_code ] = array( 'code' => $coupon_code, 'type' => $coupon_obj->get_discount_type(), 'product_ids' => $coupon_obj->get_product_ids(), 'excluded_product_ids' => $coupon_obj->get_excluded_product_ids(), 'product_categories' => $coupon_obj->get_product_categories(), 'excluded_product_categories' => $coupon_obj->get_excluded_product_categories(), 'amount' => $coupon_obj->get_amount(), ); } // Enhanced logging for coupon handling. $this->log( 'Coupon Type Handling', array( 'order_id' => $order->get_id(), 'coupon_types' => $coupon_types, 'is_order_level' => $is_order_level_discount, 'total_discount' => $order->get_discount_total(), 'coupons' => $coupons, ) ); // Prepare products array. $products = array(); $total_quantity = 0; $order_subtotal = $order->get_subtotal(); foreach ( $order->get_items() as $item ) { $product = $item->get_product(); if ( $product ) { $quantity = $item->get_quantity(); $total_quantity += $quantity; // Get product image. $image_id = $product->get_image_id(); $image_url = $image_id ? wp_get_attachment_url( $image_id ) : ''; // Calculate proportional shipping allocation for this line item. $shipping_price = 0; if ( $order_subtotal > 0 && $order->get_shipping_total() > 0 ) { // Calculate this item's share of the shipping cost based on its proportion of the subtotal. $item_subtotal = $item->get_subtotal(); $shipping_price = $order->get_shipping_total() * ( $item_subtotal / $order_subtotal ); // Round to 2 decimal places. $shipping_price = round( $shipping_price, 2 ); } // WooCommerce already apportions discounts per line item, so capture the exact amount. $product_discount = floatval( max( 0, $item->get_subtotal() - $item->get_total() ) ); // Determine if any product-specific coupons apply to this product. $product_coupon_codes = array(); foreach ( $coupons as $coupon_code => $coupon_data ) { // Skip order-level coupons. if ( 'fixed_cart' === $coupon_data['type'] ) { continue; } $product_id = $product->get_id(); $product_cats = wp_get_post_terms( $product_id, 'product_cat', array( 'fields' => 'ids' ) ); // Check if coupon applies to this product. $applies_to_product = false; // Check product IDs. if ( ! empty( $coupon_data['product_ids'] ) ) { if ( in_array( $product_id, $coupon_data['product_ids'], true ) ) { $applies_to_product = true; } } // Check product categories. elseif ( ! empty( $coupon_data['product_categories'] ) && ! empty( $product_cats ) ) { foreach ( $product_cats as $cat_id ) { if ( in_array( $cat_id, $coupon_data['product_categories'], true ) ) { $applies_to_product = true; break; } } } // If no specific products or categories are set, this is a cart-level coupon. // Cart-level coupons should NOT appear at product level, only at order level. // So we skip adding it to products here. // Check excluded products. if ( $applies_to_product && ! empty( $coupon_data['excluded_product_ids'] ) ) { if ( in_array( $product_id, $coupon_data['excluded_product_ids'], true ) ) { $applies_to_product = false; } } // Check excluded categories. if ( $applies_to_product && ! empty( $coupon_data['excluded_product_categories'] ) && ! empty( $product_cats ) ) { foreach ( $product_cats as $cat_id ) { if ( in_array( $cat_id, $coupon_data['excluded_product_categories'], true ) ) { $applies_to_product = false; break; } } } // If coupon applies to this product, add it to the list. if ( $applies_to_product ) { $product_coupon_codes[] = $coupon_code; } } // Create product data array. $product_data = array( 'id' => strval( $product->get_id() ), 'name' => $product->get_name(), 'quantity' => floatval( $quantity ), 'totalamount' => floatval( $item->get_total() ), 'description' => $product->get_description() ? $product->get_description() : ($product->get_short_description() ? $product->get_short_description() : $product->get_name()), 'sku' => $product->get_sku() ? $product->get_sku() : strval( $product->get_id() ), 'type' => $product->get_type(), 'category' => $this->get_product_categories( $product ), 'unitprice' => floatval( $item->get_subtotal() / $quantity ), // Unit price before discounts 'discount' => $product_discount, 'tax' => floatval( $item->get_total_tax() ), 'totalprice' => floatval( $item->get_total() ), 'image' => $image_url, 'brand' => $this->get_product_brand( $product ), 'color' => $product->get_attribute( 'pa_color' ), 'size' => $this->get_product_size( $product ), // Using the new helper method 'weight' => $product->get_weight(), 'shipping_price' => max( 0, $shipping_price ), // Ensure shipping price is never negative 'event_date' => $this->format_date_field( $product->get_meta( '_event_date' ) ), 'scheduled_date' => $this->format_date_field( $product->get_meta( '_scheduled_date' ) ), 'pickup_date' => $this->format_date_field( $product->get_meta( '_pickup_date' ) ), 'ext2' => $this->get_product_custom_field( $product, 'ext2' ), // Adding the .2 field ); // Add product-specific coupon codes if any. if ( ! empty( $product_coupon_codes ) ) { $product_data['coupon'] = implode( ', ', $product_coupon_codes ); } $products[] = $product_data; } } // Get coupon information - only cart-level coupons at order level. // Cart-level coupons are: fixed_cart OR percent/fixed_product without product restrictions. // Product-level coupons (percent/fixed_product WITH product restrictions) should only appear at product level. $cart_coupon_codes = array(); foreach ( $order->get_items( 'coupon' ) as $coupon_item ) { $coupon_code = $coupon_item->get_code(); $coupon_obj = new WC_Coupon( $coupon_code ); $coupon_type = $coupon_obj->get_discount_type(); // fixed_cart coupons are always cart-level. if ( 'fixed_cart' === $coupon_type ) { $cart_coupon_codes[] = $coupon_code; } // For percent and fixed_product coupons, check if they have product restrictions. // If no restrictions, they're cart-level and should appear at order level. // If they have restrictions, they're product-level and should only appear at product level. elseif ( in_array( $coupon_type, array( 'percent', 'fixed_product' ), true ) ) { $has_product_restrictions = ! empty( $coupon_obj->get_product_ids() ) || ! empty( $coupon_obj->get_product_categories() ); // If no product restrictions, it's a cart-level coupon. if ( ! $has_product_restrictions ) { $cart_coupon_codes[] = $coupon_code; } } } $coupon = ! empty( $cart_coupon_codes ) ? implode( ', ', $cart_coupon_codes ) : ''; // Check if customer gave consent for marketing emails - always set explicitly. $consent = get_post_meta( $order->get_id(), '_activetrail_consent', true ); $subscription_status = ( $consent === 'yes' ) ? true : false; // Tracking consent (CNIL) - always sent as a boolean. $tracking_consent = ( get_post_meta( $order->get_id(), '_activetrail_tracking_consent', true ) === 'yes' ); // Log consent status for debugging. if ( function_exists( 'activetrail_logger' ) ) { activetrail_logger()->api( sprintf( 'Order %s consent status: %s, subscriberStatus: %s', $order->get_id(), $consent, $subscription_status ? 'true' : 'false' ), 'DEBUG' ); } // Generate proper external ID for the customer. $customer_id = $order->get_customer_id(); $stored_external_id = $order->get_meta( '_activetrail_external_id', true ); $external_id = ''; if ( ! empty( $stored_external_id ) ) { $external_id = $stored_external_id; } elseif ( function_exists( 'activetrail_external_id_for_contact' ) ) { // Use the helper function if available. $external_id = activetrail_external_id_for_contact( $customer_id, $order->get_billing_email() ); } else { // Fallback to a safe format that doesn't use email directly. if ( $customer_id ) { $external_id = 'WC-CONTACT-' . $customer_id; } else { $external_id = 'WC-CONTACT-GUEST-' . $order->get_id(); } } // Prepare main order data according to ActiveTrail API format (FULL FORMAT). $data = array( 'orderId' => strval( $order->get_id() ), 'orderName' => 'Order #' . $order->get_order_number(), 'externalId' => $external_id, 'externalName' => 'WooCommerce', 'firstName' => $order->get_billing_first_name(), 'lastName' => $order->get_billing_last_name(), 'email' => $order->get_billing_email(), 'mobile' => $order->get_billing_phone(), 'netAmount' => floatval( $order->get_subtotal() - $order->get_discount_total() ), // Subtotal minus discounts 'tax' => floatval( $order->get_total_tax() ), 'discount' => floatval( $order->get_discount_total() ), 'coupon' => $coupon, 'quantity' => floatval( $total_quantity ), 'totalAmount' => floatval( $order->get_total() ), 'address' => trim( $order->get_billing_address_1() . ' ' . $order->get_billing_address_2() ), 'city' => $order->get_billing_city(), 'products' => $products, 'url' => $order->get_view_order_url(), 'purchaseDate' => $order->get_date_created()->format( 'c' ), 'lastUpdate' => $order->get_date_modified()->format( 'c' ), 'purchaseType' => 'online', 'storeId' => 1, // Required field 'locationType' => 'Online', // Fixed value for the store location type 'subscriptionStatus' => $subscription_status, // Always explicitly set to true or false 'tracking_consent' => $tracking_consent, // CNIL tracking consent, always sent as boolean 'orderLocationType' => $this->determine_order_location_type( $order ), // Dynamic based on order contents 'status' => $order->get_status(), // Added order status ); // Add store ID if configured. if ( ! empty( $settings['store_id'] ) ) { $data['storeId'] = intval( $settings['store_id'] ); } // Add group ID if configured - this ensures new contacts are added to the correct group. if ( ! empty( $settings['group_id'] ) ) { $data['groupId'] = intval( $settings['group_id'] ); } // Add fallback check for empty groupId. if ( empty( $data['groupId'] ) && ! empty( $settings['group_id'] ) ) { $data['groupId'] = intval( $settings['group_id'] ); } // Log group ID for debugging. $this->log( 'Sync Order - Group ID: ' . 'group_setting=' . ( $settings['group_id'] ?? 'none' ) . ', ' . 'payload_group=' . ( $data['groupId'] ?? 'none' ), 'debug' ); return apply_filters( 'activetrail_order_data', $data, $order ); } /** * Transform WooCommerce cart to ActiveTrail format * * @param WC_Cart $cart WooCommerce cart object. * @return array */ private function transform_wc_cart_to_activetrail( $cart ) { $settings = $this->get_plugin_settings(); $customer = WC()->customer; $data = array( 'cartId' => 'WC-CART-' . $customer->get_id() . '-' . time(), 'email' => $customer->get_email(), 'totalAmount' => floatval( $cart->get_total( 'edit' ) ), 'currency' => get_woocommerce_currency(), 'url' => wc_get_cart_url(), 'products' => array(), ); // Add store ID if configured. if ( ! empty( $settings['store_id'] ) ) { $data['storeId'] = $settings['store_id']; } // Add products. foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) { $product = $cart_item['data']; if ( $product ) { $data['products'][] = array( 'productId' => $product->get_id(), 'productName' => $product->get_name(), 'price' => floatval( $cart_item['line_total'] ), 'quantity' => $cart_item['quantity'], 'category' => $this->get_product_categories( $product ), 'url' => get_permalink( $product->get_id() ), ); } } return apply_filters( 'activetrail_cart_data', $data, $cart ); } /** * Get product categories as comma-separated string * * @param WC_Product $product Product object. * @return string */ private function get_product_categories( $product ) { $categories = wp_get_post_terms( $product->get_id(), 'product_cat', array( 'fields' => 'names' ) ); return is_array( $categories ) ? implode( ', ', $categories ) : ''; } /** * Get product brand * * @param WC_Product $product Product object. * @return string */ private function get_product_brand( $product ) { // Try to get brand from various sources. $brand = ''; // First check product categories for brand information. $categories = wp_get_post_terms( $product->get_id(), 'product_cat', array( 'fields' => 'names' ) ); if ( ! empty( $categories ) && is_array( $categories ) ) { // Use first category as brand if no other brand info is found. $potential_brand = $categories[0]; } // Check for brand taxonomy (common in WooCommerce brand plugins). $brand_taxonomies = array( 'product_brand', 'pwb-brand', 'brand', 'pa_brand', 'product_brands', 'yith_product_brand' ); foreach ( $brand_taxonomies as $taxonomy ) { if ( taxonomy_exists( $taxonomy ) ) { $terms = get_the_terms( $product->get_id(), $taxonomy ); if ( $terms && ! is_wp_error( $terms ) && ! empty( $terms ) ) { $brand = $terms[0]->name; break; } } } // Check for brand attribute with expanded attribute names. if ( empty( $brand ) ) { $brand_attributes = array( 'pa_brand', 'pa_manufacturer', 'brand', 'manufacturer' ); foreach ( $brand_attributes as $attr ) { $attr_value = $product->get_attribute( $attr ); if ( ! empty( $attr_value ) ) { $brand = $attr_value; break; } } } // Check for brand meta field (common in custom implementations). if ( empty( $brand ) ) { $meta_keys = array( '_brand', 'brand', '_product_brand', 'product_brand', '_manufacturer', 'manufacturer' ); foreach ( $meta_keys as $key ) { $meta_brand = $product->get_meta( $key ); if ( ! empty( $meta_brand ) ) { $brand = $meta_brand; break; } } } // Use the potential brand from categories if still empty. if ( empty( $brand ) && isset( $potential_brand ) ) { $brand = $potential_brand; } // Fallback to site name as last resort. if ( empty( $brand ) ) { $brand = get_bloginfo( 'name' ); } return $brand; } /** * Get product size * * @param WC_Product $product Product object. * @return string */ private function get_product_size( $product ) { // Try different size attribute variations. $size_attributes = array( 'pa_size', 'pa_sizes', 'size', 'sizes', 'pa_taille', 'taille' ); foreach ( $size_attributes as $attr ) { $size = $product->get_attribute( $attr ); if ( ! empty( $size ) ) { return $size; } } // Check for size in product variations. if ( $product->is_type( 'variable' ) ) { $variations = $product->get_available_variations(); foreach ( $variations as $variation ) { if ( isset( $variation['attributes']['attribute_pa_size'] ) ) { return $variation['attributes']['attribute_pa_size']; } } } // Check meta fields. $size_meta_keys = array( '_size', 'size', 'product_size' ); foreach ( $size_meta_keys as $key ) { $size = $product->get_meta( $key ); if ( ! empty( $size ) ) { return $size; } } return ''; } /** * Format date field * * @param string $date_string Date string. * @return string Formatted date */ private function format_date_field( $date_string ) { if ( empty( $date_string ) ) { return ''; } // Try to parse the date. $timestamp = strtotime( $date_string ); if ( $timestamp === false ) { return $date_string; // Return original if parsing fails } // Return formatted date. return gmdate( 'Y-m-d', $timestamp ); } /** * Get product custom field * * @param WC_Product $product Product object. * @param string $field_name Field name. * @return string Field value */ private function get_product_custom_field( $product, $field_name ) { // Check meta data first. $meta_value = $product->get_meta( '_' . $field_name ); if ( ! empty( $meta_value ) ) { return $meta_value; } // Check attributes. $attr_value = $product->get_attribute( 'pa_' . $field_name ); if ( ! empty( $attr_value ) ) { return $attr_value; } return ''; } /** * Validate customer data * * @param array $data Customer data. * @return array */ private function validate_customer_data( $data ) { $errors = array(); if ( empty( $data['email'] ) || ! is_email( $data['email'] ) ) { $errors[] = esc_html__( 'Valid email address is required', 'activetrail-for-woocommerce' ); } if ( empty( $data['externalId'] ) ) { $errors[] = esc_html__( 'External ID is required', 'activetrail-for-woocommerce' ); } return array( 'valid' => empty( $errors ), 'message' => empty( $errors ) ? '' : implode( ', ', $errors ), ); } /** * Validate order data * * @param array $data Order data. * @return array */ public function validate_order_data( $data ) { $errors = array(); if ( empty( $data['orderId'] ) ) { $errors[] = esc_html__( 'Order ID is required', 'activetrail-for-woocommerce' ); } if ( empty( $data['email'] ) || ! is_email( $data['email'] ) ) { $errors[] = esc_html__( 'Valid email address is required', 'activetrail-for-woocommerce' ); } if ( ! isset( $data['totalAmount'] ) || ! is_numeric( $data['totalAmount'] ) ) { $errors[] = esc_html__( 'Valid total amount is required', 'activetrail-for-woocommerce' ); } return array( 'valid' => empty( $errors ), 'message' => empty( $errors ) ? '' : implode( ', ', $errors ), ); } /** * Validate cart data * * @param array $data Cart data. * @return array */ private function validate_cart_data( $data ) { $errors = array(); if ( empty( $data['cartId'] ) ) { $errors[] = esc_html__( 'Cart ID is required', 'activetrail-for-woocommerce' ); } if ( empty( $data['email'] ) || ! is_email( $data['email'] ) ) { $errors[] = esc_html__( 'Valid email address is required', 'activetrail-for-woocommerce' ); } if ( ! isset( $data['totalAmount'] ) || ! is_numeric( $data['totalAmount'] ) ) { $errors[] = esc_html__( 'Valid total amount is required', 'activetrail-for-woocommerce' ); } return array( 'valid' => empty( $errors ), 'message' => empty( $errors ) ? '' : implode( ', ', $errors ), ); } /** * Log API success * * @param string $context Success context. * @param string $message Success message. * @param array $data Response data. */ private function log_api_success( $context, $message, $data = array() ) { if ( $this->settings && method_exists( $this->settings, 'log_api_success' ) ) { $this->settings->log_api_success( $context, $message, $data ); } elseif ( function_exists( 'activetrail_logger' ) ) { activetrail_logger()->api( $context . ': ' . $message, 'INFO', $data ); } } /** * Log API error * * @param string $context Error context. * @param string $error_message Error message. * @param array $additional_data Additional debug data. */ private function log_api_error( $context, $error_message, $additional_data = array() ) { if ( $this->settings && method_exists( $this->settings, 'log_api_error' ) ) { $this->settings->log_api_error( $context, $error_message, $additional_data ); } elseif ( function_exists( 'activetrail_logger' ) ) { activetrail_logger()->api( $context . ': ' . $error_message, 'ERROR', $additional_data ); } } /** * Log API debug information * * @param string $context Debug context. * @param array $debug_data Debug data. */ private function log_api_debug( $context, $debug_data = array() ) { if ( $this->settings && method_exists( $this->settings, 'log_api_debug' ) ) { $this->settings->log_api_debug( $context, $debug_data ); } elseif ( function_exists( 'activetrail_logger' ) ) { $masked_data = $this->mask_pii_in_data( $debug_data ); activetrail_logger()->api( $context, 'DEBUG', $masked_data ); } } /** * Get plugin settings * * @return array */ private function get_plugin_settings() { if ( $this->settings && method_exists( $this->settings, 'get_all' ) ) { return $this->settings->get_all(); } // Fallback to direct option access. return get_option( 'activetrail_wc_settings', array() ); } /** * Get last API response for debugging * * @return array */ public function get_last_response() { return $this->last_response; } /** * Check if API key is in test mode * * @return bool */ public function is_test_mode() { return $this->api_key === 'test_key_123'; } /** * Add contact to group via external ID * * @param string $external_id External contact ID. * @param int $group_id Group ID. * @return array */ public function add_contact_to_group_via_external( $external_id, $group_id, $email = '' ) { // Check if API key is configured. if ( empty( $this->api_key ) ) { return array( 'success' => false, 'message' => esc_html__( 'API key is required', 'activetrail-for-woocommerce' ), ); } // Check if required parameters are provided. if ( empty( $external_id ) || empty( $group_id ) ) { return array( 'success' => false, 'message' => esc_html__( 'External ID and Group ID are required', 'activetrail-for-woocommerce' ), ); } // Fallback: Try to get contact by external ID first. $contact = $this->get_contact_by_identifier( $external_id, $email ); if ( ! is_wp_error( $contact ) && $contact['exists'] ) { // If contact found, add to group using contact ID. $contact_id = $contact['data']['id'] ?? null; if ( $contact_id ) { return $this->add_contact_to_group( (int) $contact_id, $group_id ); } } // If all else fails, log the error and return failure. $this->log( 'Failed to add contact to group: ' . $external_id . ' to group ' . $group_id, 'error' ); return array( 'success' => false, 'message' => esc_html__( 'Contact not found by external ID', 'activetrail-for-woocommerce' ), ); } /** * Add contact to group * * @param int $contact_id Contact ID. * @param int $group_id Group ID. * @return array */ public function add_contact_to_group( $contact_id, $group_id ) { if ( empty( $this->api_key ) ) { return array( 'success' => false, 'message' => esc_html__( 'API key is required', 'activetrail-for-woocommerce' ), ); } if ( empty( $contact_id ) || empty( $group_id ) ) { return array( 'success' => false, 'message' => esc_html__( 'Contact ID and Group ID are required', 'activetrail-for-woocommerce' ), ); } // Try to add to group using the groups API. $endpoint = '/groups/' . urlencode( $group_id ) . '/members/' . urlencode( $contact_id ); $response = $this->make_request( 'PUT', $endpoint ); if ( is_wp_error( $response ) ) { return array( 'success' => false, 'message' => $response->get_error_message(), ); } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); $response_code = wp_remote_retrieve_response_code( $response ); if ( in_array( $response_code, array( 200, 201, 204 ) ) ) { $this->log( 'Contact added to group successfully: ' . $contact_id . ' to group ' . $group_id ); return array( 'success' => true, 'message' => esc_html__( 'Contact added to group successfully', 'activetrail-for-woocommerce' ), 'data' => $data, ); } else { $error_message = $this->extract_error_message( $data ); // Check if contact is already in group (consider this a success). if ( strpos( $error_message, 'already exists' ) !== false || strpos( $error_message, 'duplicate' ) !== false || strpos( $error_message, 'already a member' ) !== false ) { $this->log( 'Contact already in group: ' . $contact_id . ' in group ' . $group_id ); return array( 'success' => true, 'message' => esc_html__( 'Contact already in group', 'activetrail-for-woocommerce' ), 'data' => $data, ); } $this->log( 'Failed to add contact to group: ' . $error_message, 'error' ); return array( 'success' => false, 'message' => $error_message, 'data' => $data, ); } } /** * Get API client version * * @return string */ public function get_version() { return self::API_VERSION; } /** * Connect store to ActiveTrail * * @return array Result with success/failure status */ public function connect_store() { if ( empty( $this->api_key ) ) { return array( 'success' => false, 'message' => esc_html__( 'API key is required', 'activetrail-for-woocommerce' ), ); } // Get the coupon token. $coupon_token = $this->get_coupon_token(); // Get the catalog URL (may be empty if feed not accessible). $catalog_url = $this->get_catalog_url(); // Prepare the request data. $data = array( 'storeType' => 'woocommerce', 'storeUrl' => get_site_url(), 'couponToken' => $coupon_token, 'action' => 'connect', ); // Only include catalogUrl if it's not empty (API may require it or allow omission). if ( ! empty( $catalog_url ) ) { $data['catalogUrl'] = $catalog_url; } // Make the request. $response = $this->make_request( 'POST', '/commerce/store', $data ); if ( is_wp_error( $response ) ) { $this->log_api_error( 'Store Connection', $response->get_error_message(), $data ); return array( 'success' => false, 'message' => $response->get_error_message(), ); } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); $response_code = wp_remote_retrieve_response_code( $response ); if ( in_array( $response_code, array( 200, 201 ) ) ) { // Check if the response contains the expected data. if ( isset( $data['status'] ) ) { $status = $data['status']; $is_connected = ( $status === 'connected' ); // Store the connection status. update_option( 'activetrail_store_status', $status ); $this->log_api_success( 'Store Connection', 'Store ' . ( $is_connected ? 'connected' : 'disconnected' ) . ' successfully', $data ); $status_text = $is_connected ? esc_html__( 'connected', 'activetrail-for-woocommerce' ) : esc_html__( 'disconnected', 'activetrail-for-woocommerce' ); return array( 'success' => true, /* translators: %s: Connection status (connected or disconnected) */ 'message' => sprintf( esc_html__( 'Store %s successfully', 'activetrail-for-woocommerce' ), $status_text ), 'data' => $data, ); } else { $this->log_api_error( 'Store Connection', 'Unexpected response format', $data ); return array( 'success' => false, 'message' => esc_html__( 'Unexpected response format', 'activetrail-for-woocommerce' ), 'data' => $data, ); } } else { $error_message = $this->extract_error_message( $data ); $this->log_api_error( 'Store Connection', $error_message, array( 'response_code' => $response_code, 'response_data' => $data, 'request_data' => $data, ) ); return array( 'success' => false, 'message' => $error_message, 'data' => $data, ); } } /** * Get coupon token for store connection * * @return string */ private function get_coupon_token() { // Check if we already have a stored token. $token = get_option( 'activetrail_coupon_token', '' ); if ( ! empty( $token ) ) { return $token; } // Generate a new token. $token = wp_generate_password( 32, false ); // Store the token for future use. update_option( 'activetrail_coupon_token', $token ); return $token; } /** * Disconnect store from ActiveTrail * Called on plugin deactivation/uninstall * * @return array Result with success/failure status */ public function disconnect_store() { if ( empty( $this->api_key ) ) { // No API key configured, nothing to disconnect. return array( 'success' => true, 'message' => esc_html__( 'No API key configured, nothing to disconnect', 'activetrail-for-woocommerce' ), ); } // Get the coupon token. $coupon_token = get_option( 'activetrail_coupon_token', '' ); // Get the catalog URL (may be empty). $catalog_url = $this->get_catalog_url(); // Prepare the request data. $data = array( 'storeType' => 'woocommerce', 'storeUrl' => get_site_url(), 'action' => 'disconnect', ); // Include optional fields if available. if ( ! empty( $coupon_token ) ) { $data['couponToken'] = $coupon_token; } // Only include catalogUrl if it's not empty. if ( ! empty( $catalog_url ) ) { $data['catalogUrl'] = $catalog_url; } // Log the payload (without headers / API key) for debugging. if ( function_exists( 'activetrail_logger' ) ) { $log_data = $data; // Optionally mask coupon token a bit so שלא נרשום אותו מלא בלוגים. if ( isset( $log_data['couponToken'] ) && is_string( $log_data['couponToken'] ) ) { $token = $log_data['couponToken']; if ( strlen( $token ) > 8 ) { $log_data['couponToken'] = substr( $token, 0, 4 ) . '****' . substr( $token, -4 ); } } activetrail_logger()->info( 'Store disconnect payload (sanitized): ' . wp_json_encode( $log_data ), 'SETTINGS' ); } // Make the request with short timeout to avoid blocking admin UX. // Use wp_remote_request directly to set custom timeout. $url = self::API_BASE_URL . '/commerce/store'; $headers = array( 'Authorization' => $this->api_key, // ActiveTrail uses simple token, not Bearer. 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'User-Agent' => 'ActiveTrail-WooCommerce/' . ACTIVETRAIL_WC_VERSION, ); $response = wp_remote_request( $url, array( 'method' => 'POST', 'headers' => $headers, 'body' => wp_json_encode( $data ), 'timeout' => 5, // Short timeout to avoid blocking admin UX. 'connect_timeout' => 3, // Short connect timeout. 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { // Log minimal error without secrets, fail silently. $error_message = $response->get_error_message(); if ( function_exists( 'activetrail_logger' ) ) { activetrail_logger()->error( 'Store disconnect failed: ' . $error_message, 'SETTINGS' ); } // Return success to avoid blocking admin UX. return array( 'success' => true, 'message' => esc_html__( 'Disconnect attempted (API call failed silently)', 'activetrail-for-woocommerce' ), ); } $body = wp_remote_retrieve_body( $response ); $response_data = json_decode( $body, true ); $response_code = wp_remote_retrieve_response_code( $response ); if ( in_array( $response_code, array( 200, 201 ) ) ) { if ( function_exists( 'activetrail_logger' ) ) { activetrail_logger()->info( 'Store disconnected successfully', 'SETTINGS' ); } return array( 'success' => true, 'message' => esc_html__( 'Store disconnected successfully', 'activetrail-for-woocommerce' ), ); } else { // Log minimal error, fail silently. $error_message = $this->extract_error_message( $response_data ); if ( function_exists( 'activetrail_logger' ) ) { activetrail_logger()->error( 'Store disconnect failed: ' . $error_message, 'SETTINGS' ); } // Return success to avoid blocking admin UX. return array( 'success' => true, 'message' => esc_html__( 'Disconnect attempted (API returned error, but proceeding)', 'activetrail-for-woocommerce' ), ); } } /** * Get catalog URL for store connection * Returns accessible feed URL or empty string if feed is not available * * @return string Feed URL or empty string if not accessible */ private function get_catalog_url() { return home_url( 'activetrail-feed.xml' ); } /** * Get API base URL * * @return string */ public function get_api_base_url() { return self::API_BASE_URL; } /** * Mask PII (Personally Identifiable Information) in data for logging * * @param array $data Data to mask. * @return array Masked data */ private function mask_pii_in_data( $data ) { if ( ! is_array( $data ) ) { return $data; } $masked_data = $data; // Fields to mask. $pii_fields = array( 'email', 'sms', 'mobile', 'phone', 'billing_email', 'billing_phone', 'first_name', 'last_name', 'billing_first_name', 'billing_last_name', ); // Recursively mask PII fields. foreach ( $masked_data as $key => $value ) { if ( is_array( $value ) ) { $masked_data[ $key ] = $this->mask_pii_in_data( $value ); } elseif ( in_array( $key, $pii_fields, true ) && is_string( $value ) && ! empty( $value ) ) { if ( strpos( $key, 'email' ) !== false && is_email( $value ) ) { // Mask email: user***@domain.com $parts = explode( '@', $value ); if ( count( $parts ) === 2 ) { $username = $parts[0]; $domain = $parts[1]; $masked_username = substr( $username, 0, min( 4, strlen( $username ) ) ) . str_repeat( '*', max( 3, strlen( $username ) - 4 ) ); $masked_data[ $key ] = $masked_username . '@' . $domain; } } elseif ( strpos( $key, 'phone' ) !== false || strpos( $key, 'mobile' ) !== false || strpos( $key, 'sms' ) !== false ) { // Mask phone: ***-***-1234 (last 4 digits visible). $digits = preg_replace( '/\D/', '', $value ); $length = strlen( $digits ); if ( $length > 4 ) { $visible = substr( $digits, -4 ); $masked = str_repeat( '*', $length - 4 ); $masked_data[ $key ] = $masked . $visible; } else { $masked_data[ $key ] = str_repeat( '*', $length ); } } else { // Mask names: Jo** Do*. $masked_data[ $key ] = substr( $value, 0, 2 ) . str_repeat( '*', max( 1, strlen( $value ) - 2 ) ); } } } return $masked_data; } /** * Calculate order subtotal before discounts * * @param WC_Order $order Order object. * @return float */ private function calculate_order_subtotal_before_discounts( $order ) { $subtotal = 0; foreach ( $order->get_items() as $item ) { $subtotal += (float) $item->get_subtotal(); } return $subtotal; } /** * Determine order location type based on order contents * * Logic: * - All items virtual → Virtual * - Has local_pickup shipping → Pickup * - Else → Delivery * * @param WC_Order $order Order object. * @return string Location type (Virtual, Pickup, or Delivery) */ private function determine_order_location_type( $order ) { $items = $order->get_items( 'line_item' ); if ( ! $items ) { return 'Delivery'; // Default to delivery if no items } // Check if all items are virtual. $all_virtual = true; foreach ( $items as $item ) { $product = $item->get_product(); if ( ! $product || ! $product->is_virtual() ) { $all_virtual = false; break; } } if ( $all_virtual ) { return 'Virtual'; } // Check if order has local pickup shipping. foreach ( $order->get_shipping_methods() as $shipping_method ) { $method_id = $shipping_method->get_method_id(); if ( $method_id && false !== strpos( $method_id, 'local_pickup' ) ) { return 'Pickup'; } } // Default to delivery. return 'Delivery'; } /** * Get order refund date * * @param WC_Order $order Order object. * @return string */ private function get_order_refund_date( $order ) { $refunds = $order->get_refunds(); if ( ! empty( $refunds ) ) { $refund = current( $refunds ); if ( $refund && $refund->get_date_created() ) { return $refund->get_date_created()->format( 'c' ); } } return ''; } /** * Log message * * @param string $message Log message. * @param array|string $data Additional data to log. * @param string $level Log level. */ private function log( $message, $data = array(), $level = 'info' ) { if ( function_exists( 'activetrail_logger' ) ) { $level_map = array( 'info' => 'INFO', 'debug' => 'DEBUG', 'warning' => 'WARNING', 'error' => 'ERROR', ); $mapped_level = isset( $level_map[ $level ] ) ? $level_map[ $level ] : 'INFO'; activetrail_logger()->api( $message, $mapped_level, $data ); } } }