rawurlencode( $return_url ), 'state' => $state, 'site_url' => rawurlencode( home_url() ), ), self::RELAY_URL . '/start' ); } /** * Handle OAuth Callback * * Processes the returning OAuth tokens from the relay server. * Called when the user is redirected back to the Google Ads settings page. * * @return bool|WP_Error True on success, WP_Error on failure. */ public static function handle_callback() { $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; $stored_state = isset( $_COOKIE['ftcon_gads_oauth_state'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['ftcon_gads_oauth_state'] ) ) : ''; if ( empty( $state ) || empty( $stored_state ) || ! hash_equals( $stored_state, $state ) ) { return new WP_Error( 'invalid_state', __( 'Invalid OAuth state. Please try connecting again.', 'forge-trueconversion' ) ); } setcookie( 'ftcon_gads_oauth_state', '', time() - 3600, '/' ); $token_payload = isset( $_GET['ftcon_gads_token'] ) ? sanitize_text_field( wp_unslash( $_GET['ftcon_gads_token'] ) ) : ''; if ( empty( $token_payload ) ) { $error = isset( $_GET['error'] ) ? sanitize_text_field( wp_unslash( $_GET['error'] ) ) : __( 'No token received.', 'forge-trueconversion' ); return new WP_Error( 'no_token', $error ); } // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode $decoded = base64_decode( $token_payload, true ); if ( ! $decoded ) { return new WP_Error( 'decode_failed', __( 'Failed to decode token payload.', 'forge-trueconversion' ) ); } $tokens = json_decode( $decoded, true ); if ( ! is_array( $tokens ) || empty( $tokens['access_token'] ) || empty( $tokens['refresh_token'] ) ) { return new WP_Error( 'invalid_tokens', __( 'Invalid token data received.', 'forge-trueconversion' ) ); } update_option( 'ftcon_gads_access_token', FTCON_Encryption::encrypt( $tokens['access_token'] ) ); update_option( 'ftcon_gads_refresh_token', FTCON_Encryption::encrypt( $tokens['refresh_token'] ) ); update_option( 'ftcon_gads_token_expiry', time() + ( isset( $tokens['expires_in'] ) ? (int) $tokens['expires_in'] : 3600 ) ); update_option( 'ftcon_gads_connected', true ); set_transient( 'ftcon_gads_admin_notice', array( 'type' => 'success', 'message' => __( 'Google Ads connected successfully! Now enter your Customer ID below.', 'forge-trueconversion' ), ), 60 ); wp_safe_redirect( admin_url( 'admin.php?page=ftcon-google-ads&ftcon_gads_connected=1' ) ); exit; } /** * Disconnect Google Ads * * Revokes tokens via the relay server and clears all stored data. */ public static function disconnect() { // Try to revoke via relay. $refresh_token = self::get_refresh_token(); if ( $refresh_token ) { wp_remote_post( self::RELAY_URL . '/revoke', array( 'body' => array( 'refresh_token' => $refresh_token ), 'timeout' => 10, ) ); } // Clear stored tokens and connection status. update_option( 'ftcon_gads_access_token', '' ); update_option( 'ftcon_gads_refresh_token', '' ); update_option( 'ftcon_gads_token_expiry', 0 ); update_option( 'ftcon_gads_customer_id', '' ); update_option( 'ftcon_gads_conversion_action_id', '' ); update_option( 'ftcon_gads_connected', false ); } /** * Refresh Token if Needed * * Checks if the access token has expired and refreshes it * via the relay server. * * @return bool True if token is valid (refreshed if needed), false on failure. */ public static function refresh_token_if_needed() { $expiry = (int) get_option( 'ftcon_gads_token_expiry', 0 ); // Add 60-second buffer to prevent edge cases. if ( $expiry > ( time() + 60 ) ) { return true; // Token is still valid. } $refresh_token = self::get_refresh_token(); if ( empty( $refresh_token ) ) { return false; } $response = wp_remote_post( self::RELAY_URL . '/refresh', array( 'body' => array( 'refresh_token' => $refresh_token ), 'timeout' => 15, ) ); if ( is_wp_error( $response ) ) { return false; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( empty( $body['access_token'] ) ) { // Token refresh failed — mark as disconnected. update_option( 'ftcon_gads_connected', false ); return false; } update_option( 'ftcon_gads_access_token', FTCON_Encryption::encrypt( $body['access_token'] ) ); update_option( 'ftcon_gads_token_expiry', time() + ( isset( $body['expires_in'] ) ? (int) $body['expires_in'] : 3600 ) ); return true; } /** * Upload a Single Conversion to Google Ads * * @param object $entry The entry object from the database. * * @return bool True on success, false on failure. */ public static function upload_conversion( $entry ) { if ( ! self::is_connected() ) { return false; } if ( empty( $entry->click_id ) || 'gclid' !== $entry->click_id_type ) { return false; } $customer_id = get_option( 'ftcon_gads_customer_id', '' ); $conversion_action = get_option( 'ftcon_gads_conversion_action_id', '' ); if ( empty( $customer_id ) || empty( $conversion_action ) ) { return false; } if ( ! self::refresh_token_if_needed() ) { return false; } // Format the customer ID (remove dashes). $customer_id_clean = str_replace( '-', '', $customer_id ); // Format datetime: MySQL → Google Ads format (yyyy-mm-dd HH:mm:ss+TZ:TZ). $datetime = self::format_datetime( $entry->submitted_at ); $url = sprintf( 'https://googleads.googleapis.com/%s/customers/%s:uploadClickConversions', self::API_VERSION, $customer_id_clean ); // Build the conversion object. $conversion = array( 'gclid' => $entry->click_id, 'conversionAction' => $conversion_action, 'conversionDateTime' => $datetime, 'conversionValue' => 1.0, 'currencyCode' => 'USD', ); // Enhanced Conversions — include hashed user-provided data so // Google can better match conversions to ad clicks. Data is // SHA-256 hashed before sending (Google's requirement). $user_identifiers = self::build_user_identifiers( $entry ); if ( ! empty( $user_identifiers ) ) { $conversion['userIdentifiers'] = $user_identifiers; } $body = array( 'conversions' => array( $conversion ), 'partialFailure' => true, ); $response = self::api_request( 'POST', $url, $body ); if ( is_wp_error( $response ) ) { return false; } // Check for partial failure errors — the API can return 200 OK // but include partialFailureError when individual conversions fail. if ( is_array( $response ) && ! empty( $response['partialFailureError'] ) ) { return false; } // Mark as sent. FTCON_Entry_Manager::update_field( $entry->id, 'conversion_sent', 1 ); FTCON_Entry_Manager::update_field( $entry->id, 'conversion_sent_at', current_time( 'mysql' ) ); return true; } /** * Get Conversion Actions from Google Ads * * Fetches the list of upload-type conversion actions * for the connected customer account. * * @return array|WP_Error Array of conversion actions, or WP_Error on failure. */ public static function get_conversion_actions() { if ( ! self::is_connected() || ! self::refresh_token_if_needed() ) { return new WP_Error( 'not_connected', __( 'Google Ads is not connected.', 'forge-trueconversion' ) ); } $customer_id = str_replace( '-', '', get_option( 'ftcon_gads_customer_id', '' ) ); if ( empty( $customer_id ) ) { return new WP_Error( 'no_customer_id', __( 'No Google Ads customer ID configured.', 'forge-trueconversion' ) ); } $url = sprintf( 'https://googleads.googleapis.com/%s/customers/%s/googleAds:searchStream', self::API_VERSION, $customer_id ); $body = array( 'query' => "SELECT conversion_action.id, conversion_action.name, conversion_action.resource_name FROM conversion_action WHERE conversion_action.status = 'ENABLED' AND conversion_action.type = 'UPLOAD_CLICKS'", ); $response = self::api_request( 'POST', $url, $body ); if ( is_wp_error( $response ) ) { return $response; } $actions = array(); if ( is_array( $response ) ) { foreach ( $response as $batch ) { if ( isset( $batch['results'] ) && is_array( $batch['results'] ) ) { foreach ( $batch['results'] as $result ) { if ( isset( $result['conversionAction'] ) ) { $actions[] = array( 'id' => $result['conversionAction']['id'], 'name' => $result['conversionAction']['name'], 'resource_name' => $result['conversionAction']['resourceName'], ); } } } } } return $actions; } /** * Create a Conversion Action via the API * * Creates an UPLOAD_CLICKS conversion action in the connected * Google Ads account so the user doesn't have to create one * manually in the Google Ads UI. * * @param string $name Optional name for the conversion action. Defaults to 'TrueConversion Lead'. * * @return array|WP_Error The created conversion action data, or WP_Error on failure. */ public static function create_conversion_action( $name = '' ) { if ( ! self::is_connected() || ! self::refresh_token_if_needed() ) { return new WP_Error( 'not_connected', __( 'Google Ads is not connected.', 'forge-trueconversion' ) ); } $customer_id = str_replace( '-', '', get_option( 'ftcon_gads_customer_id', '' ) ); if ( empty( $customer_id ) ) { return new WP_Error( 'no_customer_id', __( 'No Google Ads customer ID configured.', 'forge-trueconversion' ) ); } if ( empty( $name ) ) { $name = 'TrueConversion Lead'; } $name = sanitize_text_field( $name ); $url = sprintf( 'https://googleads.googleapis.com/%s/customers/%s/conversionActions:mutate', self::API_VERSION, $customer_id ); $body = array( 'operations' => array( array( 'create' => array( 'name' => $name, 'type' => 'UPLOAD_CLICKS', 'category' => 'QUALIFIED_LEAD', 'status' => 'ENABLED', 'viewThroughLookbackWindowDays' => 1, 'clickThroughLookbackWindowDays' => 90, 'countingType' => 'ONE_PER_CLICK', 'valueSettings' => array( 'defaultValue' => 1.0, 'defaultCurrencyCode' => 'USD', 'alwaysUseDefaultValue' => true, ), ), ), ), ); $response = self::api_request( 'POST', $url, $body ); if ( is_wp_error( $response ) ) { return $response; } // Extract the created conversion action resource name. if ( isset( $response['results'][0]['resourceName'] ) ) { $resource_name = $response['results'][0]['resourceName']; // Auto-save as the selected conversion action. update_option( 'ftcon_gads_conversion_action_id', $resource_name ); return array( 'success' => true, 'resource_name' => $resource_name, 'name' => $name, 'message' => sprintf( /* translators: %s: conversion action name */ __( 'Conversion action "%s" created and selected successfully.', 'forge-trueconversion' ), $name ), ); } return new WP_Error( 'create_failed', __( 'The conversion action was sent to Google Ads but no resource name was returned. Please try refreshing the actions list.', 'forge-trueconversion' ) ); } /** * Get Customer Info * * Validates the connection by fetching basic customer info. * * @return array|WP_Error Customer info or WP_Error. */ public static function get_customer_info() { if ( ! self::is_connected() || ! self::refresh_token_if_needed() ) { return new WP_Error( 'not_connected', __( 'Google Ads is not connected.', 'forge-trueconversion' ) ); } $customer_id = str_replace( '-', '', get_option( 'ftcon_gads_customer_id', '' ) ); if ( empty( $customer_id ) ) { return new WP_Error( 'no_customer_id', __( 'No customer ID.', 'forge-trueconversion' ) ); } $url = sprintf( 'https://googleads.googleapis.com/%s/customers/%s/googleAds:searchStream', self::API_VERSION, $customer_id ); $body = array( 'query' => 'SELECT customer.id, customer.descriptive_name FROM customer LIMIT 1', ); return self::api_request( 'POST', $url, $body ); } /** * Send Test Conversion * * Sends a test conversion to verify the setup works. * * @return array|WP_Error Result or WP_Error. */ public static function send_test_conversion() { if ( ! self::is_connected() ) { return new WP_Error( 'not_connected', __( 'Google Ads is not connected.', 'forge-trueconversion' ) ); } $customer_id = str_replace( '-', '', get_option( 'ftcon_gads_customer_id', '' ) ); $conversion_action = get_option( 'ftcon_gads_conversion_action_id', '' ); if ( empty( $customer_id ) || empty( $conversion_action ) ) { return new WP_Error( 'missing_config', __( 'Customer ID or conversion action not configured.', 'forge-trueconversion' ) ); } if ( ! self::refresh_token_if_needed() ) { return new WP_Error( 'token_error', __( 'Failed to refresh access token.', 'forge-trueconversion' ) ); } $url = sprintf( 'https://googleads.googleapis.com/%s/customers/%s:uploadClickConversions', self::API_VERSION, $customer_id ); $body = array( 'conversions' => array( array( 'gclid' => 'test_gclid_' . wp_generate_password( 16, false ), 'conversionAction' => $conversion_action, 'conversionDateTime' => self::format_datetime( current_time( 'mysql' ) ), 'conversionValue' => 0.01, 'currencyCode' => 'USD', ), ), 'partialFailure' => true, ); $response = self::api_request( 'POST', $url, $body ); if ( is_wp_error( $response ) ) { return $response; } return array( 'success' => true, 'message' => __( 'Test conversion sent successfully.', 'forge-trueconversion' ), ); } /** * Make an Authenticated Google Ads API Request * * @param string $method HTTP method (GET, POST). * @param string $url Full API URL. * @param array $body Request body (will be JSON-encoded). * * @return array|WP_Error Decoded response body or WP_Error. */ private static function api_request( $method, $url, $body = array() ) { $access_token = self::get_access_token(); if ( empty( $access_token ) ) { return new WP_Error( 'no_token', __( 'No access token available.', 'forge-trueconversion' ) ); } // Route the request through the relay server which adds the // developer-token header. This keeps the developer token off // customer sites entirely. $relay_url = self::RELAY_URL . '/api'; $relay_body = array( 'access_token' => $access_token, 'method' => $method, 'url' => $url, 'body' => $body, ); $response = wp_remote_post( $relay_url, array( 'timeout' => 45, // Extra buffer since the relay forwards to Google. 'headers' => array( 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( $relay_body ), ) ); if ( is_wp_error( $response ) ) { return $response; } $code = wp_remote_retrieve_response_code( $response ); $decoded_body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $code >= 400 ) { $error_message = __( 'Google Ads API error.', 'forge-trueconversion' ); // Try multiple paths where Google Ads API might put the error message. if ( isset( $decoded_body['error']['details'][0]['errors'][0]['message'] ) ) { $error_message = $decoded_body['error']['details'][0]['errors'][0]['message']; } elseif ( isset( $decoded_body['error']['message'] ) ) { $error_message = $decoded_body['error']['message']; } elseif ( isset( $decoded_body['message'] ) ) { $error_message = $decoded_body['message']; } elseif ( isset( $decoded_body['raw'] ) ) { $error_message = $decoded_body['raw']; } $error_message .= ' (HTTP ' . $code . ')'; // Log the full response for debugging. // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log, WordPress.PHP.DevelopmentFunctions.error_log_print_r error_log( 'TrueConversion Google Ads API error: ' . print_r( $decoded_body, true ) ); return new WP_Error( 'api_error', $error_message, array( 'status' => $code ) ); } return $decoded_body; } /** * Get Decrypted Access Token * * @return string The access token, or empty string. */ private static function get_access_token() { $encrypted = get_option( 'ftcon_gads_access_token', '' ); if ( empty( $encrypted ) ) { return ''; } return FTCON_Encryption::decrypt( $encrypted ); } /** * Get Decrypted Refresh Token * * @return string The refresh token, or empty string. */ private static function get_refresh_token() { $encrypted = get_option( 'ftcon_gads_refresh_token', '' ); if ( empty( $encrypted ) ) { return ''; } return FTCON_Encryption::decrypt( $encrypted ); } /** * Build User Identifiers for Enhanced Conversions * * Creates an array of hashed user-provided data (email, first name, * last name) for Google Ads Enhanced Conversions. All values are * normalised (lowercased, trimmed) and SHA-256 hashed before sending. * * @param object $entry The entry object from the database. * * @return array Array of userIdentifier objects, or empty array. */ private static function build_user_identifiers( $entry ) { $identifiers = array(); // Hashed email. if ( ! empty( $entry->submitter_email ) ) { $identifiers[] = array( 'hashedEmail' => FTCON_Hash_Helper::hash_pii( $entry->submitter_email ), ); } // Hashed first/last name from the submitter name. if ( ! empty( $entry->submitter_name ) ) { $name_parts = explode( ' ', trim( $entry->submitter_name ), 2 ); $first_name = isset( $name_parts[0] ) ? trim( $name_parts[0] ) : ''; $last_name = isset( $name_parts[1] ) ? trim( $name_parts[1] ) : ''; if ( $first_name ) { $address_info = array( 'hashedFirstName' => FTCON_Hash_Helper::hash_pii( $first_name ), ); if ( $last_name ) { $address_info['hashedLastName'] = FTCON_Hash_Helper::hash_pii( $last_name ); } $identifiers[] = array( 'addressInfo' => $address_info, ); } } return $identifiers; } /** * Format MySQL Datetime for Google Ads API * * Converts MySQL datetime (YYYY-MM-DD HH:MM:SS) to * Google Ads format (yyyy-mm-dd HH:mm:ss+HH:MM). * * The input comes from current_time('mysql') which returns * the time in WordPress's configured timezone (without any * timezone indicator). We must interpret it in the WP timezone * before converting to a UTC offset string for Google Ads. * * @param string $mysql_datetime MySQL datetime string (in WP timezone). * * @return string Google Ads formatted datetime with UTC offset. */ private static function format_datetime( $mysql_datetime ) { $wp_tz = wp_timezone(); try { $dt = new DateTime( $mysql_datetime, $wp_tz ); } catch ( Exception $e ) { $dt = new DateTime( 'now', $wp_tz ); } // Format with the actual UTC offset so Google knows the real time. // e.g. "2026-03-09 15:30:00+13:00" for NZDT. return $dt->format( 'Y-m-d H:i:sP' ); } }