.my.salesforce.com` * for My-Domain-enforced orgs. */ protected $login_domain = ''; public static function get_instance() { if (empty(self::$instance)) { self::$instance = new self; } return self::$instance; } public function __construct() { $this->authorization_endpoint = self::authorization_endpoint; $this->token_endpoint = self::token_endpoint; $this->refresh_token_endpoint = self::refresh_token_endpoint; // Load legacy credentials for backward compatibility $this->load_legacy_credentials(); add_action('admin_init', array($this, 'auth_redirect')); add_filter('adfoin_action_providers', array($this, 'adfoin_salesforce_actions'), 10, 1); add_filter('adfoin_settings_tabs', array($this, 'adfoin_salesforce_settings_tab'), 10, 1); add_action('adfoin_settings_view', array($this, 'adfoin_salesforce_settings_view'), 10, 1); add_action('adfoin_action_fields', array($this, 'action_fields'), 10, 1); add_action('rest_api_init', array($this, 'create_webhook_route')); add_action('wp_ajax_adfoin_get_salesforce_credentials', array($this, 'get_credentials')); add_filter('adfoin_get_credentials', array($this, 'modify_credentials'), 10, 2); add_action('wp_ajax_adfoin_save_salesforce_credentials', array($this, 'save_credentials')); add_action('wp_ajax_adfoin_test_salesforce_connection', array($this, 'test_connection')); add_action('wp_ajax_adfoin_get_salesforce_fields', array($this, 'get_fields')); add_action('wp_ajax_adfoin_get_salesforce_campaigns', array($this, 'get_campaigns')); add_action('wp_ajax_adfoin_get_salesforce_owners', array($this, 'get_owner_list')); add_action('wp_ajax_adfoin_get_salesforce_record_types', array($this, 'get_record_types')); } /** * Load legacy credentials from old option for backward compatibility */ protected function load_legacy_credentials() { $option = (array) maybe_unserialize(get_option('adfoin_salesforce_keys')); if (isset($option['client_id'])) { $this->client_id = $option['client_id']; } if (isset($option['client_secret'])) { $this->client_secret = $option['client_secret']; } if (isset($option['access_token'])) { $this->access_token = $option['access_token']; } if (isset($option['refresh_token'])) { $this->refresh_token = $option['refresh_token']; } if (isset($option['instance_url'])) { $this->instance_url = $option['instance_url']; } if (isset($option['login_domain'])) { $this->login_domain = $option['login_domain']; } } /** * Load credentials for a specific account */ public function load_credentials( $cred_id ) { if ( empty( $cred_id ) ) { return false; } // Handle legacy credential ID if ( strpos( $cred_id, 'legacy_' ) === 0 ) { $this->load_legacy_credentials(); $this->update_oauth_endpoints(); return true; } // Load from OAuth Manager $credentials = adfoin_read_credentials( 'salesforce' ); foreach ( $credentials as $credential ) { if ( isset( $credential['id'] ) && $credential['id'] == $cred_id ) { $this->cred_id = $cred_id; $this->client_id = isset( $credential['client_id'] ) ? $credential['client_id'] : ''; $this->client_secret = isset( $credential['client_secret'] ) ? $credential['client_secret'] : ''; $this->access_token = isset( $credential['access_token'] ) ? $credential['access_token'] : ''; $this->refresh_token = isset( $credential['refresh_token'] ) ? $credential['refresh_token'] : ''; $this->instance_url = isset( $credential['instance_url'] ) ? $credential['instance_url'] : ''; $this->login_domain = isset( $credential['login_domain'] ) ? $credential['login_domain'] : ''; $this->update_oauth_endpoints(); return true; } } return false; } /** * Re-derive the authorization/token endpoints from $this->login_domain. * Called after load_credentials hydrates the per-account login domain. * Empty login_domain falls back to the production defaults set in the * constructor — preserves behavior for credentials saved before this * field existed. */ protected function update_oauth_endpoints() { $domain = $this->normalize_login_domain( $this->login_domain ); if ( ! $domain ) { // Reset to production defaults (legacy behavior). $this->authorization_endpoint = self::authorization_endpoint; $this->token_endpoint = self::token_endpoint; $this->refresh_token_endpoint = self::refresh_token_endpoint; return; } $this->authorization_endpoint = 'https://' . $domain . '/services/oauth2/authorize'; $this->token_endpoint = 'https://' . $domain . '/services/oauth2/token'; $this->refresh_token_endpoint = $this->token_endpoint; } /** * Accept user-supplied login domain in any reasonable form (with or * without scheme, with or without trailing slash) and return the bare * host. Returns '' for empty input. */ protected function normalize_login_domain( $value ) { $value = is_string( $value ) ? trim( $value ) : ''; if ( '' === $value ) { return ''; } // Strip scheme + trailing slash if user pasted a full URL. if ( preg_match( '~^https?://~i', $value ) ) { $parsed = wp_parse_url( $value ); $value = $parsed['host'] ?? ''; } $value = rtrim( $value, '/' ); // Salesforce login hosts are alphanumeric, dot, dash. Reject the rest. if ( ! preg_match( '/^[A-Za-z0-9.\-]+$/', $value ) ) { return ''; } return strtolower( $value ); } public function create_webhook_route() { register_rest_route('advancedformintegration', '/salesforce', [ 'methods' => 'GET', 'callback' => [$this, 'get_webhook_data'], 'permission_callback' => '__return_true', ]); } public function get_webhook_data($request) { $params = $request->get_params(); $code = isset($params['code']) ? sanitize_text_field($params['code']) : ''; $state = isset($params['state']) ? sanitize_text_field($params['state']) : ''; $context = self::consume_oauth_state( $state, 'salesforce' ); $state = $context ? $context['cred_id'] : ''; $code_verifier = ( $context && ! empty( $context['extra']['code_verifier'] ) ) ? $context['extra']['code_verifier'] : ''; if ($code) { // New OAuth Manager flow with state parameter if ( $state ) { // load_credentials sets client_id/secret/login_domain AND // refreshes $this->token_endpoint via update_oauth_endpoints(). // We can't use any pre-existing tokens (we're getting a new // pair), but the side effect of setting the right login // domain is what we need. $this->load_credentials( $state ); $response = $this->request_token( $code, $code_verifier ); $success = false; $message = 'Unknown error'; if ( ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) == 200 ) { $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( isset( $body['access_token'] ) ) { $success = true; $message = 'Connected successfully!'; } else { $message = isset( $body['error'] ) ? $body['error'] : 'Token exchange failed.'; } } else { $message = is_wp_error( $response ) ? $response->get_error_message() : 'HTTP Error'; } require_once plugin_dir_path( __FILE__ ) . '../../includes/class-adfoin-oauth-manager.php'; ADFOIN_OAuth_Manager::handle_callback_close_popup( $success, $message ); exit; } // Legacy flow - redirect to admin $redirect_to = add_query_arg( [ 'service' => 'authorize', 'action' => 'adfoin_salesforce_auth_redirect', 'code' => $code, ], admin_url('admin.php?page=advanced-form-integration')); wp_safe_redirect($redirect_to); exit(); } } public function auth_redirect() { $action = isset($_GET['action']) ? sanitize_text_field( wp_unslash( $_GET['action'] ) ) : ''; if ('adfoin_salesforce_auth_redirect' == $action) { // admin_init fires for every logged-in user; only an admin should // be able to complete this OAuth flow (CWE-862). if ( ! current_user_can( 'manage_options' ) ) { return; } $code = isset($_GET['code']) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; $state = isset($_GET['state']) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; $context = self::consume_oauth_state( $state, 'salesforce' ); $state = $context ? $context['cred_id'] : ''; if ($code) { // If state exists, use new credential system if ( $state ) { $this->load_credentials( $state ); } $this->request_token($code); // For popup flow if ( $state ) { require_once plugin_dir_path( __FILE__ ) . '../../includes/class-adfoin-oauth-manager.php'; ADFOIN_OAuth_Manager::handle_callback_close_popup( true, 'Connected via Legacy Redirect' ); exit; } } wp_safe_redirect(admin_url('admin.php?page=advanced-form-integration-settings&tab=salesforce')); exit(); } } public function adfoin_salesforce_actions($actions) { $actions['salesforce'] = [ 'title' => __('Salesforce', 'advanced-form-integration'), 'tasks' => [ 'add_lead' => __('Add new Lead', 'advanced-form-integration'), 'add_contact' => __('Add new Account, Contact, Opportunity, Case', 'advanced-form-integration'), ], ]; return $actions; } public function adfoin_salesforce_settings_tab($providers) { $providers['salesforce'] = __('Salesforce', 'advanced-form-integration'); return $providers; } public function adfoin_salesforce_settings_view($current_tab) { if ($current_tab !== 'salesforce') return; $redirect_uri = $this->get_redirect_uri(); // Define fields for OAuth Manager $fields = array( array( 'name' => 'client_id', 'label' => __( 'Consumer Key', 'advanced-form-integration' ), 'type' => 'text', 'required' => true, 'mask' => true, 'show_in_table' => true, ), array( 'name' => 'client_secret', 'label' => __( 'Consumer Secret', 'advanced-form-integration' ), 'type' => 'text', 'required' => false, 'mask' => true, 'show_in_table' => true, 'placeholder' => __( 'Leave blank to keep current', 'advanced-form-integration' ), ), array( 'name' => 'login_domain', 'label' => __( 'Login Domain', 'advanced-form-integration' ), 'type' => 'text', 'required' => false, 'show_in_table' => false, 'placeholder' => 'login.salesforce.com', 'description' => __( 'Production / Developer Edition: leave blank or use login.salesforce.com. Sandbox: test.salesforce.com. My Domain: yourorg.my.salesforce.com.', 'advanced-form-integration' ), ), ); // Instructions $instructions = '
    '; $instructions .= '
  1. ' . __( 'Log in to Salesforce, click the gear icon (top-right) and open Setup. On Starter/Pro Suite, choose Open Advanced Setup.', 'advanced-form-integration' ) . '
  2. '; $instructions .= '
  3. ' . __( 'Type "External Client App" in Quick Find, open External Client App Manager and click New External Client App.', 'advanced-form-integration' ) . '
  4. '; $instructions .= '
  5. ' . __( 'Enter the App Name, API Name and Contact Email. Keep Distribution State as Local.', 'advanced-form-integration' ) . '
  6. '; $instructions .= '
  7. ' . __( 'Expand API (Enable OAuth Settings) and tick Enable OAuth.', 'advanced-form-integration' ) . '
  8. '; $instructions .= '
  9. ' . __( 'Copy the Redirect URI below and paste it into Callback URL:', 'advanced-form-integration' ) . '
  10. '; $instructions .= '
  11. ' . esc_html( $redirect_uri ) . '
  12. '; $instructions .= '
  13. ' . __( 'Add the OAuth scopes "Manage user data via APIs (api)" and "Perform requests at any time (refresh_token, offline_access)".', 'advanced-form-integration' ) . '
  14. '; $instructions .= '
  15. ' . __( 'Under Security, tick Require secret for Web Server Flow and Require secret for Refresh Token Flow. Leave PKCE enabled (the plugin supports it). Click Create.', 'advanced-form-integration' ) . '
  16. '; $instructions .= '
  17. ' . __( 'Open the app\'s Settings tab, expand OAuth Settings and click Consumer Key and Secret. Verify with the code Salesforce emails you, then copy the Consumer Key and Consumer Secret.', 'advanced-form-integration' ) . '
  18. '; $instructions .= '
  19. ' . __( 'Click Add Account here, paste both values and click Save & Authorize. A newly created app can take a few minutes before it accepts logins.', 'advanced-form-integration' ) . '
  20. '; $instructions .= '
  21. ' . __( 'Using an older Connected App? It keeps working, no changes needed.', 'advanced-form-integration' ) . '
  22. '; $instructions .= '
'; // Configuration $config = array( 'show_status' => true, 'enable_test' => true, 'modal_title' => __( 'Connect Salesforce', 'advanced-form-integration' ), 'submit_text' => __( 'Save & Authorize', 'advanced-form-integration' ), ); // Render using OAuth Manager require_once plugin_dir_path( __FILE__ ) . '../../includes/class-adfoin-oauth-manager.php'; ADFOIN_OAuth_Manager::render_oauth_settings_view( 'salesforce', 'Salesforce', $fields, $instructions, $config ); } /** * Get credentials via AJAX */ public function get_credentials() { adfoin_require_manage_options(); if ( ! wp_verify_nonce( isset( $_POST['_nonce'] ) ? $_POST['_nonce'] : '', 'advanced-form-integration' ) ) { wp_send_json_error( array( 'message' => __( 'Security check failed', 'advanced-form-integration' ) ) ); } wp_send_json_success( $this->safe_credentials_list() ); } /** * Modify credentials to include legacy data for backward compatibility */ public function modify_credentials( $credentials, $platform ) { if ( 'salesforce' == $platform && empty( $credentials ) ) { $option = (array) maybe_unserialize( get_option( 'adfoin_salesforce_keys' ) ); if ( isset( $option['client_id'] ) && isset( $option['client_secret'] ) && ! empty( $option['client_id'] ) ) { $credentials[] = array( 'id' => 'legacy_123456', 'title' => __( 'Default Account (Legacy)', 'advanced-form-integration' ), 'client_id' => $option['client_id'], 'client_secret' => $option['client_secret'], 'access_token' => isset( $option['access_token'] ) ? $option['access_token'] : '', 'refresh_token' => isset( $option['refresh_token'] ) ? $option['refresh_token'] : '', 'instance_url' => isset( $option['instance_url'] ) ? $option['instance_url'] : '', ); } } return $credentials; } /** * Save credentials via AJAX */ public function save_credentials() { adfoin_require_manage_options(); if ( ! wp_verify_nonce( $_POST['_nonce'], 'advanced-form-integration' ) ) { die( __( 'Security check Failed', 'advanced-form-integration' ) ); } $platform = 'salesforce'; $credentials = adfoin_read_credentials( $platform ); if ( ! is_array( $credentials ) ) { $credentials = array(); } // Handle Deletion if ( isset( $_POST['delete_index'] ) ) { $index = intval( wp_unslash( $_POST['delete_index'] ) ); if ( isset( $credentials[ $index ] ) ) { // If deleting legacy credential, also clear the old option if ( isset( $credentials[ $index ]['id'] ) && strpos( $credentials[ $index ]['id'], 'legacy_' ) === 0 ) { delete_option( 'adfoin_salesforce_keys' ); } array_splice( $credentials, $index, 1 ); adfoin_save_credentials( $platform, $credentials ); wp_send_json_success( array( 'message' => 'Deleted' ) ); } wp_send_json_error( __( 'Invalid index', 'advanced-form-integration' ) ); } // Handle Save/Update $id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : ''; $title = isset( $_POST['title'] ) ? sanitize_text_field( wp_unslash( $_POST['title'] ) ) : ''; $client_id = isset( $_POST['client_id'] ) ? sanitize_text_field( wp_unslash( $_POST['client_id'] ) ) : ''; $client_secret = isset( $_POST['client_secret'] ) ? sanitize_text_field( wp_unslash( $_POST['client_secret'] ) ) : ''; $login_domain = isset( $_POST['login_domain'] ) ? sanitize_text_field( wp_unslash( $_POST['login_domain'] ) ) : ''; // Validate / canonicalize: bare host only, default to '' (= production). $login_domain = $this->normalize_login_domain( $login_domain ); if ( empty( $id ) ) { $id = wp_generate_uuid4(); } // Preserve previously-saved values when the edit form submits an // empty client_id / client_secret. client_secret is the common case // because we never ship it to the browser (safe_credentials_list // strips it). Without this guard, editing the title would silently // wipe the stored secret. $existing = null; foreach ( $credentials as $cred_check ) { if ( isset( $cred_check['id'] ) && $cred_check['id'] === $id ) { $existing = $cred_check; break; } } if ( $existing ) { if ( '' === $client_id && ! empty( $existing['client_id'] ) ) { $client_id = $existing['client_id']; } if ( '' === $client_secret && ! empty( $existing['client_secret'] ) ) { $client_secret = $existing['client_secret']; } // login_domain can be intentionally blank (= production) so we // don't auto-fill it; the user submitted what they wanted. } elseif ( '' === $client_id || '' === $client_secret ) { wp_send_json_error( array( 'message' => __( 'Consumer Key and Consumer Secret are required.', 'advanced-form-integration' ), ) ); } $new_data = array( 'id' => $id, 'title' => $title, 'client_id' => $client_id, 'client_secret' => $client_secret, 'login_domain' => $login_domain, 'access_token' => '', 'refresh_token' => '', 'instance_url' => '', ); // Check if updating existing credential $found = false; foreach ( $credentials as &$cred ) { if ( $cred['id'] == $id ) { // Preserve tokens if credentials + login domain haven't changed. $existing_domain = isset( $cred['login_domain'] ) ? $cred['login_domain'] : ''; if ( isset( $cred['client_id'] ) && $cred['client_id'] == $client_id && isset( $cred['client_secret'] ) && $cred['client_secret'] == $client_secret && $existing_domain === $login_domain ) { $new_data['access_token'] = isset( $cred['access_token'] ) ? $cred['access_token'] : ''; $new_data['refresh_token'] = isset( $cred['refresh_token'] ) ? $cred['refresh_token'] : ''; $new_data['instance_url'] = isset( $cred['instance_url'] ) ? $cred['instance_url'] : ''; } $cred = $new_data; $found = true; break; } } unset( $cred ); if ( ! $found ) { $credentials[] = $new_data; } adfoin_save_credentials( $platform, $credentials ); // Generate Auth URL — use the per-credential login domain so the // user is sent to the right Salesforce host (production vs. // sandbox vs. My Domain). $auth_base = $login_domain ? 'https://' . $login_domain . '/services/oauth2/authorize' : self::authorization_endpoint; // PKCE: Salesforce External Client Apps enforce it (and it can't be // turned off without Support). Connected Apps accept it too, so we // always send it. The verifier round-trips server-side via the state. $pkce = self::generate_pkce_pair(); $auth_url = add_query_arg( array( 'response_type' => 'code', 'client_id' => $client_id, 'redirect_uri' => $this->get_redirect_uri(), 'prompt' => 'login consent', 'code_challenge' => $pkce['challenge'], 'code_challenge_method' => $pkce['method'], 'state' => self::issue_oauth_state( 'salesforce', $id, array( 'code_verifier' => $pkce['verifier'] ) ), ), $auth_base ); wp_send_json_success( array( 'auth_url' => $auth_url ) ); } /** * Test Connection AJAX — verifies the cached access token works by * hitting Salesforce's /services/data/ (lists API versions; cheap + * doesn't need any object-level permission). */ public function test_connection() { $this->run_test_connection_ajax( function () { return $this->salesforce_request( '', 'GET' ); } ); } protected function authorize($scope = '') { $endpoint = add_query_arg( array( 'response_type' => 'code', 'client_id' => $this->client_id, 'redirect_uri' => urlencode($this->get_redirect_uri()), 'prompt' => 'login consent' ), $this->authorization_endpoint ); if (wp_redirect(esc_url_raw($endpoint))) { exit(); } } protected function request_token( $code, $code_verifier = '' ) { $body = array( 'grant_type' => 'authorization_code', 'code' => $code, 'redirect_uri' => $this->get_redirect_uri(), 'client_id' => $this->client_id, 'client_secret' => $this->client_secret, ); if ( '' !== $code_verifier ) { $body['code_verifier'] = $code_verifier; } $args = array( 'headers' => array( 'user-agent' => 'wordpress/advanced-form-integration', 'Content-Type' => 'application/x-www-form-urlencoded', ), 'timeout' => 30, 'method' => 'POST', 'body' => $body, ); // $this->token_endpoint is set by update_oauth_endpoints() to honor // the per-credential login domain (production / sandbox / My Domain). $response = wp_remote_request( esc_url_raw( $this->token_endpoint ), $args ); $response_body = json_decode( wp_remote_retrieve_body( $response ), true ); $this->apply_token_response( $response_body ); if ( isset( $response_body['instance_url'] ) ) { $this->instance_url = $response_body['instance_url']; } $this->save_data(); return $response; } protected function refresh_token() { $ref_endpoint = $this->refresh_token_endpoint; $endpoint = add_query_arg( array( 'refresh_token' => $this->refresh_token, 'grant_type' => 'refresh_token', 'client_id' => $this->client_id, 'client_secret' => $this->client_secret, ), $ref_endpoint ); $request = [ 'timeout' => 30, 'headers' => array( 'Content-Type' => 'application/x-www-form-urlencoded', ), ]; $response = wp_remote_post(esc_url_raw($endpoint), $request); $response_code = (int) wp_remote_retrieve_response_code($response); $response_body = json_decode(wp_remote_retrieve_body($response), true); $this->apply_token_response( $response_body ); $this->save_data(); return $response; } protected function get_redirect_uri() { return site_url('/wp-json/advancedformintegration/salesforce'); } protected function save_data() { // OAuth Manager flow: persist canonical token fields plus the // Salesforce-specific instance_url + login_domain via the base helper. if ( ! empty( $this->cred_id ) && strpos( $this->cred_id, 'legacy_' ) !== 0 ) { $this->persist_token_to_credential( array( 'instance_url' => $this->instance_url, 'login_domain' => $this->login_domain, ) ); return; } // Legacy save method for backward compatibility $data = [ 'client_id' => $this->client_id, 'client_secret' => $this->client_secret, 'access_token' => $this->access_token, 'refresh_token' => $this->refresh_token, 'instance_url' => $this->instance_url, 'login_domain' => $this->login_domain, ]; update_option('adfoin_salesforce_keys', maybe_serialize( $data )); } protected function reset_data() { $this->client_id = ''; $this->client_secret = ''; $this->access_token = ''; $this->refresh_token = ''; $this->instance_url = ''; $this->save_data(); } public function action_fields() { ?> find_lead($lead_data['Email']) : false; if ($existing_lead) { // Update existing lead $url = $this->instance_url . '/services/data/' . self::API_VERSION . '/sobjects/Lead/' . $existing_lead['Id']; $args = [ 'timeout' => 30, 'headers' => [ 'Content-Type' => 'application/json', ], 'body' => wp_json_encode($lead_data), 'method' => 'PATCH', ]; $response = $this->remote_request($url, $args, $record); $response_body = json_decode(wp_remote_retrieve_body($response), true); if (isset($response_body['id'])) { return $response_body['id']; } return $existing_lead['Id']; } else { // Create new lead $url = $this->instance_url . '/services/data/' . self::API_VERSION . '/sobjects/Lead/'; $args = [ 'timeout' => 30, 'headers' => [ 'Content-Type' => 'application/json', ], 'body' => wp_json_encode($lead_data), 'method' => 'POST', ]; $response = $this->remote_request($url, $args, $record); $response_body = json_decode(wp_remote_retrieve_body($response), true); if (isset($response_body['id'])) { return $response_body['id']; } return false; } } private function find_lead($email) { $email = $this->soql_escape( $email ); $url = $this->instance_url . '/services/data/' . self::API_VERSION . '/query/?q=' . urlencode("SELECT Id FROM Lead WHERE Email = '$email' AND IsConverted = false ORDER BY CreatedDate DESC LIMIT 1"); $args = [ 'timeout' => 30, 'headers' => [ 'Authorization' => 'Bearer ' . $this->access_token, ], 'method' => 'GET', ]; $response = $this->remote_request($url, $args); $response_body = json_decode(wp_remote_retrieve_body($response), true); if (isset($response_body['records']) && !empty($response_body['records'])) { return $response_body['records'][0]; } return false; } public function create_or_update_account($account_data, $record) { // Check if account already exists $existing_account = $this->find_account($account_data['Name']); if ($existing_account) { // Update existing account $url = $this->instance_url . '/services/data/' . self::API_VERSION . '/sobjects/Account/' . $existing_account['Id']; $args = [ 'timeout' => 30, 'headers' => [ 'Content-Type' => 'application/json', ], 'body' => wp_json_encode($account_data), 'method' => 'PATCH', ]; $response = $this->remote_request($url, $args, $record); $response_body = json_decode(wp_remote_retrieve_body($response), true); if (isset($response_body['id'])) { return $response_body['id']; } return $existing_account['Id']; } else { // Create new account $url = $this->instance_url . '/services/data/' . self::API_VERSION . '/sobjects/Account/'; $args = [ 'timeout' => 30, 'headers' => [ 'Content-Type' => 'application/json', ], 'body' => wp_json_encode($account_data), 'method' => 'POST', ]; $response = $this->remote_request($url, $args, $record); $response_body = json_decode(wp_remote_retrieve_body($response), true); if (isset($response_body['id'])) { return $response_body['id']; } return false; } } private function find_account($name) { $name = $this->soql_escape( $name ); $url = $this->instance_url . '/services/data/' . self::API_VERSION . '/query/?q=' . urlencode("SELECT Id FROM Account WHERE Name = '$name' LIMIT 1"); $args = [ 'timeout' => 30, 'headers' => [ 'Authorization' => 'Bearer ' . $this->access_token, ], 'method' => 'GET', ]; $response = $this->remote_request($url, $args); $response_body = json_decode(wp_remote_retrieve_body($response), true); if (isset($response_body['records']) && !empty($response_body['records'])) { return $response_body['records'][0]; } return false; } public function create_or_update_contact($contact_data, $record) { // Check if contact already exists $existing_contact = !empty($contact_data['Email']) ? $this->find_contact($contact_data['Email']) : false; if ($existing_contact) { // Update existing contact $url = $this->instance_url . '/services/data/' . self::API_VERSION . '/sobjects/Contact/' . $existing_contact['Id']; $args = [ 'timeout' => 30, 'headers' => [ 'Content-Type' => 'application/json', ], 'body' => wp_json_encode($contact_data), 'method' => 'PATCH', ]; $response = $this->remote_request($url, $args, $record); $response_body = json_decode(wp_remote_retrieve_body($response), true); if (isset($response_body['id'])) { return $response_body['id']; } return $existing_contact['Id']; } else { // Create new contact $url = $this->instance_url . '/services/data/' . self::API_VERSION . '/sobjects/Contact/'; $args = [ 'timeout' => 30, 'headers' => [ 'Content-Type' => 'application/json', ], 'body' => wp_json_encode($contact_data), 'method' => 'POST', ]; $response = $this->remote_request($url, $args, $record); $response_body = json_decode(wp_remote_retrieve_body($response), true); if (isset($response_body['id'])) { return $response_body['id']; } return false; } } private function find_contact($email) { $email = $this->soql_escape( $email ); $url = $this->instance_url . '/services/data/' . self::API_VERSION . '/query/?q=' . urlencode("SELECT Id FROM Contact WHERE Email = '$email' LIMIT 1"); $args = [ 'timeout' => 30, 'headers' => [ 'Authorization' => 'Bearer ' . $this->access_token, ], 'method' => 'GET', ]; $response = $this->remote_request($url, $args); $response_body = json_decode(wp_remote_retrieve_body($response), true); if (isset($response_body['records']) && !empty($response_body['records'])) { return $response_body['records'][0]; } return false; } public function create_or_update_opportunity($opportunity_data, $record) { // Check if opportunity already exists $existing_opportunity = $this->find_opportunity($opportunity_data['Name']); if ($existing_opportunity) { // Update existing opportunity $url = $this->instance_url . '/services/data/' . self::API_VERSION . '/sobjects/Opportunity/' . $existing_opportunity['Id']; $args = [ 'timeout' => 30, 'headers' => [ 'Content-Type' => 'application/json', ], 'body' => wp_json_encode($opportunity_data), 'method' => 'PATCH', ]; $response = $this->remote_request($url, $args, $record); $response_body = json_decode(wp_remote_retrieve_body($response), true); if (isset($response_body['id'])) { return $response_body['id']; } return $existing_opportunity['Id']; } else { // Create new opportunity $url = $this->instance_url . '/services/data/' . self::API_VERSION . '/sobjects/Opportunity/'; $args = [ 'timeout' => 30, 'headers' => [ 'Content-Type' => 'application/json', ], 'body' => wp_json_encode($opportunity_data), 'method' => 'POST', ]; $response = $this->remote_request($url, $args, $record); $response_body = json_decode(wp_remote_retrieve_body($response), true); if (isset($response_body['id'])) { return $response_body['id']; } return false; } } private function find_opportunity($name) { $name = $this->soql_escape( $name ); $url = $this->instance_url . '/services/data/' . self::API_VERSION . '/query/?q=' . urlencode("SELECT Id FROM Opportunity WHERE Name = '$name' LIMIT 1"); $args = [ 'timeout' => 30, 'headers' => [ 'Authorization' => 'Bearer ' . $this->access_token, ], 'method' => 'GET', ]; $response = $this->remote_request($url, $args); $response_body = json_decode(wp_remote_retrieve_body($response), true); if (isset($response_body['records']) && !empty($response_body['records'])) { return $response_body['records'][0]; } return false; } public function create_or_update_case($case_data, $record) { // Check if case already exists $existing_case = $this->find_case($case_data['Subject']); if ($existing_case) { // Update existing case $url = $this->instance_url . '/services/data/' . self::API_VERSION . '/sobjects/Case/' . $existing_case['Id']; $args = [ 'timeout' => 30, 'headers' => [ 'Content-Type' => 'application/json', ], 'body' => wp_json_encode($case_data), 'method' => 'PATCH', ]; $response = $this->remote_request($url, $args, $record); $response_body = json_decode(wp_remote_retrieve_body($response), true); if (isset($response_body['id'])) { return $response_body['id']; } return $existing_case['Id']; } else { // Create new case $url = $this->instance_url . '/services/data/' . self::API_VERSION . '/sobjects/Case/'; $args = [ 'timeout' => 30, 'headers' => [ 'Content-Type' => 'application/json', ], 'body' => wp_json_encode($case_data), 'method' => 'POST', ]; $response = $this->remote_request($url, $args, $record); $response_body = json_decode(wp_remote_retrieve_body($response), true); if (isset($response_body['id'])) { return $response_body['id']; } return false; } } private function find_case($subject) { $subject = $this->soql_escape( $subject ); $url = $this->instance_url . '/services/data/' . self::API_VERSION . '/query/?q=' . urlencode("SELECT Id FROM Case WHERE Subject = '$subject' LIMIT 1"); $args = [ 'timeout' => 30, 'headers' => [ 'Authorization' => 'Bearer ' . $this->access_token, ], 'method' => 'GET', ]; $response = $this->remote_request($url, $args); $response_body = json_decode(wp_remote_retrieve_body($response), true); if (isset($response_body['records']) && !empty($response_body['records'])) { return $response_body['records'][0]; } return false; } /* * Updates the open Opportunity with this name on the same Account, or * creates one. Without an Account (or name) a new one is always created. * Returns array( id|false, created ). */ public function upsert_opportunity( $opportunity_data, $record ) { if ( ! empty( $opportunity_data['Name'] ) && ! empty( $opportunity_data['AccountId'] ) && apply_filters( 'adfoin_salesforce_update_existing_opportunity', true, $record ) ) { $soql = sprintf( "SELECT Id FROM Opportunity WHERE Name = '%s' AND AccountId = '%s' AND IsClosed = false ORDER BY CreatedDate DESC LIMIT 1", $this->soql_escape( $opportunity_data['Name'] ), $this->soql_escape( $opportunity_data['AccountId'] ) ); $response = $this->salesforce_request( 'query/?q=' . rawurlencode( $soql ) ); $body = is_wp_error( $response ) ? array() : json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! empty( $body['records'][0]['Id'] ) ) { $id = $body['records'][0]['Id']; // ContactId is only writable when the Opportunity is created. unset( $opportunity_data['ContactId'] ); $response = $this->salesforce_request( 'sobjects/Opportunity/' . $id, 'PATCH', $opportunity_data, $record ); return array( ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) < 300 ? $id : false, false ); } } return array( $this->create_opportunity( $opportunity_data, $record ), true ); } public function create_opportunity($opportunity_data, $record) { $url = $this->instance_url . '/services/data/' . self::API_VERSION . '/sobjects/Opportunity/'; $args = [ 'timeout' => 30, 'headers' => [ 'Content-Type' => 'application/json', ], 'body' => wp_json_encode($opportunity_data), 'method' => 'POST', ]; $response = $this->remote_request($url, $args, $record); $response_body = json_decode(wp_remote_retrieve_body($response), true); if (isset($response_body['id'])) { return $response_body['id']; } return false; } public function create_case($case_data, $record) { $url = $this->instance_url . '/services/data/' . self::API_VERSION . '/sobjects/Case/'; $args = [ 'timeout' => 30, 'headers' => [ 'Content-Type' => 'application/json', ], 'body' => wp_json_encode($case_data), 'method' => 'POST', ]; $response = $this->remote_request($url, $args, $record); $response_body = json_decode(wp_remote_retrieve_body($response), true); if (isset($response_body['id'])) { return $response_body['id']; } return false; } public function get_campaigns() { adfoin_verify_nonce(); // Load credentials for the requested account so this AJAX call // doesn't accidentally hit whichever org the singleton was last // hydrated for. Without this, multi-account sites silently leaked // campaigns from one org into another's UI. $cred_id = isset( $_POST['credId'] ) ? sanitize_text_field( wp_unslash( $_POST['credId'] ) ) : ''; if ( $cred_id ) { $this->load_credentials( $cred_id ); } if ( ! $this->instance_url || ! $this->access_token ) { wp_send_json_success( array() ); } $records = $this->soql_query_all( 'SELECT Id, Name FROM Campaign ORDER BY Name' ); if ( is_wp_error( $records ) ) { wp_send_json_error( $records->get_error_message() ); } $campaigns = []; foreach ( $records as $record ) { if ( isset( $record['Id'], $record['Name'] ) ) { $campaigns[ $record['Id'] ] = $record['Name']; } } wp_send_json_success($campaigns); } /* * Run a SOQL query and return ALL records, following nextRecordsUrl. * Salesforce caps a query page at 2000 rows, so without this the Owner and * Campaign dropdowns were truncated on large orgs. Returns an array of * records, or a WP_Error so callers can surface the real reason instead of * a silently-empty dropdown. */ protected function soql_query_all( $soql ) { $records = array(); $url = $this->instance_url . '/services/data/' . self::API_VERSION . '/query/?q=' . urlencode( $soql ); $guard = 0; while ( $url && $guard < 50 ) { $response = $this->remote_request( $url, array( 'timeout' => 30, 'method' => 'GET' ) ); if ( is_wp_error( $response ) ) { return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 200 !== $code ) { $msg = ( is_array( $body ) && isset( $body[0]['message'] ) ) ? $body[0]['message'] : sprintf( __( 'Salesforce API returned HTTP %d.', 'advanced-form-integration' ), $code ); return new WP_Error( 'salesforce_soql_error', $msg ); } if ( ! empty( $body['records'] ) && is_array( $body['records'] ) ) { $records = array_merge( $records, $body['records'] ); } // nextRecordsUrl is a path on the same instance; absolutize it. $url = ( empty( $body['done'] ) && ! empty( $body['nextRecordsUrl'] ) ) ? rtrim( $this->instance_url, '/' ) . $body['nextRecordsUrl'] : ''; $guard++; } return $records; } public function assign_lead_to_campaign($lead_id, $campaign_id, $record) { $campaign_member_data = [ 'LeadId' => $lead_id, 'CampaignId' => $campaign_id, ]; $url = $this->instance_url . '/services/data/' . self::API_VERSION . '/sobjects/CampaignMember/'; $args = [ 'timeout' => 30, 'headers' => [ 'Content-Type' => 'application/json', ], 'body' => wp_json_encode($campaign_member_data), 'method' => 'POST', ]; $campaign_response = $this->remote_request($url, $args, $record); if (is_wp_error($campaign_response)) { // Handle error } else { // Handle success } } /* * Add a Contact to a Campaign (CampaignMember). Mirrors * assign_lead_to_campaign() but for the Contact path, so the * "Add Account/Contact/Opportunity/Case" task can also drop the * contact into a campaign. */ public function assign_contact_to_campaign( $contact_id, $campaign_id, $record ) { $url = $this->instance_url . '/services/data/' . self::API_VERSION . '/sobjects/CampaignMember/'; $args = array( 'timeout' => 30, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'ContactId' => $contact_id, 'CampaignId' => $campaign_id ) ), 'method' => 'POST', ); return $this->remote_request( $url, $args, $record ); } /* * Create an OpportunityContactRole linking a Contact to an Opportunity. * Salesforce's Opportunity.ContactId only sets the primary contact field; * the related "Contact Roles" list needs an explicit OpportunityContactRole * record, which is what most reports/automation actually read. */ public function create_opportunity_contact_role( $opportunity_id, $contact_id, $record, $is_primary = true, $role = '' ) { $body = array( 'OpportunityId' => $opportunity_id, 'ContactId' => $contact_id, 'IsPrimary' => (bool) $is_primary, ); if ( $role ) { $body['Role'] = $role; } $url = $this->instance_url . '/services/data/' . self::API_VERSION . '/sobjects/OpportunityContactRole/'; $args = array( 'timeout' => 30, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( $body ), 'method' => 'POST', ); return $this->remote_request( $url, $args, $record ); } /* * Resolve a Record Type for an object. Accepts an 18/15-char RecordType Id * (returned as-is), or a Name / DeveloperName which is looked up via the * object describe (cached 6h per credential+object). Lets users map a * human-readable record type instead of hunting for the Id. */ public function resolve_record_type( $object, $value ) { $value = trim( (string) $value ); $object = preg_replace( '/[^A-Za-z0-9_]/', '', (string) $object ); if ( '' === $value || '' === $object ) { return ''; } // Already a Salesforce Id (RecordType Ids start with 012). if ( preg_match( '/^012[A-Za-z0-9]{12,15}$/', $value ) ) { return $value; } $map = $this->get_record_type_map( $object ); foreach ( $map as $rt ) { if ( 0 === strcasecmp( $rt['name'], $value ) || 0 === strcasecmp( $rt['developerName'], $value ) ) { return $rt['id']; } } return ''; } /* * Return [ ['id','name','developerName'], ... ] of active, available record * types for an object, cached per credential + object. */ public function get_record_type_map( $object ) { $object = preg_replace( '/[^A-Za-z0-9_]/', '', (string) $object ); $cache_key = 'adfoin_sf_rectypes_' . md5( (string) $this->cred_id . '|' . (string) $this->instance_url . '|' . $object ); $cached = get_transient( $cache_key ); if ( is_array( $cached ) ) { return $cached; } $types = array(); $response = $this->salesforce_request( 'sobjects/' . $object . '/describe/' ); if ( ! is_wp_error( $response ) && 200 === (int) wp_remote_retrieve_response_code( $response ) ) { $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! empty( $body['recordTypeInfos'] ) && is_array( $body['recordTypeInfos'] ) ) { foreach ( $body['recordTypeInfos'] as $rt ) { if ( empty( $rt['available'] ) ) { continue; } $types[] = array( 'id' => isset( $rt['recordTypeId'] ) ? $rt['recordTypeId'] : '', 'name' => isset( $rt['name'] ) ? $rt['name'] : '', 'developerName' => isset( $rt['developerName'] ) ? $rt['developerName'] : '', ); } } } set_transient( $cache_key, $types, 6 * HOUR_IN_SECONDS ); return $types; } /* * AJAX: list record types for an object (data source for a dropdown). */ public function get_record_types() { adfoin_verify_nonce(); $cred_id = isset( $_POST['credId'] ) ? sanitize_text_field( wp_unslash( $_POST['credId'] ) ) : ''; $object = isset( $_POST['object'] ) ? sanitize_text_field( wp_unslash( $_POST['object'] ) ) : ''; if ( $cred_id ) { $this->load_credentials( $cred_id ); } if ( ! $this->instance_url || ! $this->access_token || ! $object ) { wp_send_json_success( array() ); } $list = array(); foreach ( $this->get_record_type_map( $object ) as $rt ) { if ( $rt['id'] ) { $list[ $rt['id'] ] = $rt['name'] . ( $rt['developerName'] ? ' (' . $rt['developerName'] . ')' : '' ); } } wp_send_json_success( $list ); } /* * The org's default "converted" LeadStatus (used when the convert task * doesn't specify one). Falls back to the common 'Closed - Converted'. */ public function get_default_converted_status() { $response = $this->salesforce_request( 'query/?q=' . rawurlencode( 'SELECT MasterLabel FROM LeadStatus WHERE IsConverted = true ORDER BY SortOrder LIMIT 1' ) ); if ( ! is_wp_error( $response ) && 200 === (int) wp_remote_retrieve_response_code( $response ) ) { $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! empty( $body['records'][0]['MasterLabel'] ) ) { return $body['records'][0]['MasterLabel']; } } return 'Closed - Converted'; } /** * Escape a value for embedding in a SOAP/XML envelope. * * esc_html() is an HTML escaper, and _wp_specialchars() deliberately does not * double-encode, so a value containing ` ` (or any `&word;`) passes through * untouched. XML predefines only amp/lt/gt/quot/apos, so such a value produces an * undefined entity and Salesforce rejects the whole envelope. ENT_XML1 always * encodes the ampersand. * * @param mixed $value Raw value. * @return string XML-safe text. */ private function xml_escape( $value ) { return htmlspecialchars( (string) $value, ENT_XML1 | ENT_QUOTES, 'UTF-8' ); } /* * Convert a Lead via the SOAP API's convertLead() call. Salesforce has no * native REST lead-convert on this org (the convertLead invocable action * returns 404), so we post a SOAP envelope to /services/Soap/u/{ver} using * the OAuth access token as the SOAP sessionId. The session id is redacted * from the log. */ public function convert_lead_soap( $params, $record = array() ) { if ( ! $this->instance_url || ! $this->access_token ) { return new WP_Error( 'salesforce_no_instance', __( 'Salesforce account is not connected.', 'advanced-form-integration' ) ); } $lead_id = isset( $params['leadId'] ) ? $params['leadId'] : ''; $status = isset( $params['convertedStatus'] ) ? $params['convertedStatus'] : ''; if ( ! $lead_id || ! $status ) { return new WP_Error( 'salesforce_convert_missing', __( 'Convert Lead requires a Lead Id and a converted status.', 'advanced-form-integration' ) ); } $converts = '' . $this->xml_escape( $lead_id ) . ''; $converts .= '' . $this->xml_escape( $status ) . ''; $converts .= '' . ( ! empty( $params['doNotCreateOpportunity'] ) ? 'true' : 'false' ) . ''; foreach ( array( 'opportunityName', 'accountId', 'contactId', 'ownerId' ) as $opt ) { if ( ! empty( $params[ $opt ] ) ) { $converts .= '' . $this->xml_escape( $params[ $opt ] ) . ''; } } $envelope = '' . '' . '' . $this->xml_escape( $this->access_token ) . '' . '' . $converts . '' . ''; $url = rtrim( $this->instance_url, '/' ) . '/services/Soap/u/' . ltrim( self::API_VERSION, 'v' ); $args = array( 'timeout' => 30, 'method' => 'POST', 'headers' => array( 'Content-Type' => 'text/xml; charset=UTF-8', 'SOAPAction' => '""' ), 'body' => $envelope, ); $response = wp_remote_request( esc_url_raw( $url ), $args ); if ( $record ) { // Redact the SOAP body (it carries the session id) before logging. $log_args = $args; $log_args['body'] = '[SOAP convertLead leadId=' . $lead_id . ' status=' . $status . ']'; adfoin_add_to_log( $response, $url, $log_args, $record ); } return $response; } // get owner list through api public function get_owner_list() { // Was missing nonce verification entirely — every other AJAX // handler in this class checks. Closes a CSRF gap. adfoin_verify_nonce(); // Multi-account fix — see get_campaigns(). $cred_id = isset( $_POST['credId'] ) ? sanitize_text_field( wp_unslash( $_POST['credId'] ) ) : ''; if ( $cred_id ) { $this->load_credentials( $cred_id ); } if ( ! $this->instance_url || ! $this->access_token ) { wp_send_json_success( array() ); } // Active users only (no point assigning records to deactivated users), // and follow pagination so orgs with >2000 users aren't truncated. $records = $this->soql_query_all( 'SELECT Id, Name FROM User WHERE IsActive = true ORDER BY Name' ); if ( is_wp_error( $records ) ) { wp_send_json_error( $records->get_error_message() ); } $owners = []; foreach ( $records as $record ) { if ( isset( $record['Id'], $record['Name'] ) ) { $owners[ $record['Id'] ] = $record['Name']; } } wp_send_json_success($owners); } /* ---------- Public helpers for sub-platforms ---------- */ /** * Public accessor for the Salesforce instance URL. Sub-platforms * (Tasks, Files, Knowledge, etc.) need this to construct API URLs * that aren't under /services/data/. */ public function get_instance_url() { return $this->instance_url; } /** * Public accessor for the access token. Used by sub-platforms that * need to build multipart requests by hand (e.g., Files). */ public function get_access_token() { return $this->access_token; } /** * Render ', esc_attr( $option['id'] ), esc_html( $option['title'] ?? $option['id'] ) ); } } /** * Escape a value for safe inclusion inside a SOQL string literal. * SOQL escapes both backslash and single quote with a backslash. */ public function soql_escape( $value ) { return str_replace( array( "\\", "'" ), array( "\\\\", "\\'" ), (string) $value ); } /** * Wrapper around the Salesforce REST API. Accepts a relative endpoint * (e.g. 'sobjects/Contact/') and the method/body, builds the full URL * via the loaded credential's instance_url, and routes through the * existing remote_request (so the 401 → refresh retry path is * preserved). Public so sub-platforms can use the same auth state. * * @param string $endpoint Relative path under /services/data/' . self::API_VERSION . '/ * @param string $method HTTP method (default GET). * @param mixed $body Array/object to JSON-encode for POST/PUT/PATCH. * @param array $record Integration record (for log writes). * @return array|WP_Error wp_remote_* response. */ public function salesforce_request( $endpoint, $method = 'GET', $body = null, $record = array() ) { if ( ! $this->instance_url ) { return new WP_Error( 'salesforce_no_instance', __( 'Salesforce instance URL is not set. Reconnect the account.', 'advanced-form-integration' ) ); } $url = rtrim( $this->instance_url, '/' ) . '/services/data/' . self::API_VERSION . '/' . ltrim( $endpoint, '/' ); $args = array( 'method' => strtoupper( $method ), 'timeout' => 30, 'headers' => array( 'Accept' => 'application/json', ), ); if ( in_array( $args['method'], array( 'POST', 'PUT', 'PATCH' ), true ) && null !== $body ) { $args['headers']['Content-Type'] = 'application/json'; $args['body'] = is_string( $body ) ? $body : wp_json_encode( $body ); } return $this->remote_request( $url, $args, $record ); } protected function remote_request($url, $request = array(), $record = array()) { // Shared refresh-and-retry: refreshes once per call on 401 and // rebuilds the Bearer header from the new token. $response = $this->request_with_token_refresh($url, $request, null, $sent_args); // Salesforce returns 429 (REQUEST_LIMIT_EXCEEDED / concurrent limit) // when rate limited. Back off once honouring Retry-After, then retry. if ( 429 === (int) wp_remote_retrieve_response_code( $response ) ) { $retry_after = wp_remote_retrieve_header( $response, 'retry-after' ); $wait = is_numeric( $retry_after ) ? max( 1, min( (int) $retry_after, 30 ) ) : 5; sleep( $wait ); $response = $this->request_with_token_refresh($url, $request, null, $sent_args); } if ($record) { adfoin_add_to_log(self::readable_response($response, $url), $url, $sent_args, $record); } return $response; } /* * Copy of the response for the log: Salesforce's error message instead of * "Bad Request", and "already a campaign member" logged as OK. */ public static function readable_response( $response, $url = '' ) { if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) < 300 ) { return $response; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); $errors = isset( $body[0] ) && is_array( $body ) ? $body : array(); if ( isset( $errors[0]['errorCode'] ) && 'DUPLICATE_VALUE' === $errors[0]['errorCode'] && false !== strpos( $url, 'sobjects/CampaignMember' ) ) { $response['response']['code'] = 200; $response['response']['message'] = __( 'OK (already a campaign member)', 'advanced-form-integration' ); return $response; } $messages = array_filter( wp_list_pluck( $errors, 'message' ) ); if ( $messages ) { $response['response']['message'] = implode( ' ', $messages ); } return $response; } /** * @deprecated Kept as a thin wrapper so Pro (and any third-party * extensions that may have called it) keep working. * Internally delegates to load_legacy_credentials() which * handles the same legacy single-account fallback. */ public function set_credentials() { $this->load_legacy_credentials(); } public function get_fields() { adfoin_verify_nonce(); // Multi-account fix: the describe API call inside get_object_fields // uses $this->instance_url / $this->access_token. Without loading // the requested account first, multi-account sites would describe // the wrong org's schema. $cred_id = isset( $_POST['credId'] ) ? sanitize_text_field( wp_unslash( $_POST['credId'] ) ) : ''; if ( $cred_id ) { $this->load_credentials( $cred_id ); } $fields = []; $task = isset($_POST['task']) ? sanitize_text_field( wp_unslash( $_POST['task'] ) ) : ''; if ('add_lead' == $task) { $fields = array_merge($fields, $this->get_lead_fields()); } elseif ('add_contact' == $task) { $fields = array_merge($fields, $this->get_account_fields()); $fields = array_merge($fields, $this->get_contact_fields()); $fields = array_merge($fields, $this->get_opportunity_fields()); $fields = array_merge($fields, $this->get_case_fields()); } wp_send_json_success($fields); } public function get_lead_fields() { return $this->get_object_fields('Lead'); } public function get_account_fields() { return $this->get_object_fields('Account'); } public function get_contact_fields() { return $this->get_object_fields('Contact'); } public function get_opportunity_fields() { return $this->get_object_fields('Opportunity'); } public function get_case_fields() { return $this->get_object_fields('Case'); } private function get_object_fields($object) { $url = $this->instance_url . '/services/data/' . self::API_VERSION . '/sobjects/' . $object . '/describe/'; $args = [ 'timeout' => 30, 'headers' => [ 'Authorization' => 'Bearer ' . $this->access_token, ], 'method' => 'GET', ]; $response = $this->remote_request($url, $args); $response_body = json_decode(wp_remote_retrieve_body($response), true); $fields = []; if (isset($response_body['fields'])) { $skip_list = ['OwnerId', 'AccountId', 'ContactId']; foreach ($response_body['fields'] as $field) { if( in_array($field['name'], $skip_list) ) continue; if ($field['updateable'] == true && strpos($field['name'], '__c') === false) { $description = ''; if (isset($field['picklistValues']) && !empty($field['picklistValues'])) { $values = wp_list_pluck($field['picklistValues'], 'value'); $description = implode(', ', $values); } // Make RecordTypeId self-documenting: list the available // record type names so the user can map one (the send-side // resolver converts a name/developerName to its Id). if ( 'RecordTypeId' === $field['name'] && ! empty( $response_body['recordTypeInfos'] ) ) { $rt_names = array(); foreach ( $response_body['recordTypeInfos'] as $rt ) { if ( ! empty( $rt['available'] ) && empty( $rt['master'] ) && ! empty( $rt['name'] ) ) { $rt_names[] = $rt['name']; } } if ( $rt_names ) { $description = __( 'Enter a Record Type name: ', 'advanced-form-integration' ) . implode( ', ', $rt_names ); } } $label = $field['label']; if (in_array($object, ['Account', 'Contact', 'Opportunity', 'Case'])) { $label = $label . " [$object]"; } if ( ($object == 'Account' && $field['name'] == 'Name') || ($object == 'Contact' && $field['name'] == 'LastName') || ($object == 'Lead' && in_array($field['name'], ['Company', 'LastName', 'Email'])) || ($object == 'Opportunity' && in_array($field['name'], ['Name', 'CloseDate'])) ) { $description = 'Required'; } $fields[] = ['key' => strtolower($object) . '__' . $field['type'] . '__' . $field['name'], 'value' => $label, 'description' => $description]; } } } return $fields; } } $salesforce = ADFOIN_Salesforce::get_instance(); add_action('adfoin_salesforce_job_queue', 'adfoin_salesforce_job_queue', 10, 1); function adfoin_salesforce_job_queue($data) { adfoin_salesforce_send_data($data['record'], $data['posted_data']); } /* * Handles sending data to Salesforce API */ /* * Coerce a mapped form value into the type Salesforce expects, using the field * type carried in the mapping key (object__TYPE__name). Salesforce rejects the * whole record on a type mismatch — most importantly DATES: a raw form value * like "01/15/1990" returns HTTP 400 "Cannot deserialize instance of date", so * the record silently fails to save. Numbers are left as-is (Salesforce accepts * numeric strings). */ function adfoin_salesforce_coerce_value( $type, $value ) { if ( '' === $value || null === $value || is_bool( $value ) || is_array( $value ) ) { return $value; } switch ( $type ) { case 'boolean': $v = strtolower( trim( (string) $value ) ); return in_array( $v, array( 'true', '1', 'yes', 'on', 'checked' ), true ); case 'date': $dt = date_create( (string) $value ); return $dt ? $dt->format( 'Y-m-d' ) : $value; case 'datetime': $dt = date_create( (string) $value, wp_timezone() ); return $dt ? $dt->format( 'c' ) : $value; case 'multipicklist': // Salesforce wants semicolon-separated; accept comma- or semicolon- // separated form input. $parts = array_filter( array_map( 'trim', preg_split( '/[;,]/', (string) $value ) ), 'strlen' ); return implode( ';', $parts ); default: return $value; } } function adfoin_salesforce_send_data($record, $posted_data) { $record_data = json_decode($record['data'], true); if ( isset($record_data['action_data']['cl']) && adfoin_check_conditional_logic($record_data['action_data']['cl'], $posted_data) ) return; $salesforce = ADFOIN_Salesforce::get_instance(); $data = $record_data['field_data']; if ( ! adfoin_salesforce_load_account( $salesforce, $data, $record ) ) return; adfoin_salesforce_process_task( $salesforce, $record['task'], $data, $posted_data, $record ); } /* * Loads the integration's account. Returns false (after logging why) when the * account no longer exists or isn't connected, so nothing is sent with another * account's login. Shared with Salesforce [PRO]. */ function adfoin_salesforce_load_account( $salesforce, $data, $record ) { $cred_id = isset( $data['credId'] ) ? $data['credId'] : ''; if ( $cred_id && ! $salesforce->load_credentials( $cred_id ) ) { adfoin_add_to_log( new WP_Error( 'salesforce_no_account', __( 'Not sent: the Salesforce account selected in this integration no longer exists. Pick an account again.', 'advanced-form-integration' ) ), '', array(), $record ); return false; } if ( ! $salesforce->get_instance_url() ) { adfoin_add_to_log( new WP_Error( 'salesforce_not_connected', __( 'Not sent: the Salesforce account is not connected. Authorize it again in AFI Settings > Salesforce.', 'advanced-form-integration' ) ), '', array(), $record ); return false; } return true; } /* * The "Add new Lead" and "Add Account, Contact, Opportunity, Case" tasks, * shared by the free and PRO actions. Field keys are object__TYPE__name. */ function adfoin_salesforce_process_task( $salesforce, $task, $data, $posted_data, $record ) { $owner_id = isset($data['ownerId']) ? $data['ownerId'] : ''; $campaign_id = isset($data['campaignId']) ? $data['campaignId'] : ''; unset($data['credId'], $data['ownerId'], $data['campaignId']); $objects = array(); foreach ($data as $key => $value) { if ( ! is_string( $value ) || substr_count( $key, '__' ) < 2 ) continue; list($object, $type, $field) = explode('__', $key, 3); $parsed_value = adfoin_get_parsed_values($value, $posted_data); // Explicit null/'' check so legitimate values like 0 (e.g. // NumberOfEmployees) and false (boolean picklists) aren't dropped. if ('' !== $parsed_value && null !== $parsed_value) { $objects[$object][$field] = adfoin_salesforce_coerce_value($type, $parsed_value); } } if ($task == 'add_lead') { $lead_data = isset($objects['lead']) ? $objects['lead'] : array(); if ($owner_id) { $lead_data['OwnerId'] = $owner_id; } $lead_data = adfoin_salesforce_apply_record_type($salesforce, 'Lead', $lead_data); $lead_id = $salesforce->create_or_update_lead($lead_data, $record); if ($campaign_id && $lead_id) { $salesforce->assign_lead_to_campaign($lead_id, $campaign_id, $record); } return; } if ($task != 'add_contact') { return; } // Resolve a mapped Record Type Name/DeveloperName to its Id per object. $account_data = adfoin_salesforce_apply_record_type($salesforce, 'Account', isset($objects['account']) ? $objects['account'] : array()); $contact_data = adfoin_salesforce_apply_record_type($salesforce, 'Contact', isset($objects['contact']) ? $objects['contact'] : array()); $opportunity_data = adfoin_salesforce_apply_record_type($salesforce, 'Opportunity', isset($objects['opportunity']) ? $objects['opportunity'] : array()); $case_data = adfoin_salesforce_apply_record_type($salesforce, 'Case', isset($objects['case']) ? $objects['case'] : array()); $account_id = false; $contact_id = false; if (!empty($account_data)) { if ($owner_id) { $account_data['OwnerId'] = $owner_id; } $account_id = $salesforce->create_or_update_account($account_data, $record); } if (!empty($contact_data)) { if ($account_id) { $contact_data['AccountId'] = $account_id; } if ($owner_id) { $contact_data['OwnerId'] = $owner_id; } $contact_id = $salesforce->create_or_update_contact($contact_data, $record); // Add the contact to a campaign (CampaignMember), if one was chosen. if ($campaign_id && $contact_id) { $salesforce->assign_contact_to_campaign($contact_id, $campaign_id, $record); } } if (!empty($opportunity_data)) { if ($account_id) { $opportunity_data['AccountId'] = $account_id; } if ($contact_id) { $opportunity_data['ContactId'] = $contact_id; } if ($owner_id) { $opportunity_data['OwnerId'] = $owner_id; } list($opportunity_id, $created) = $salesforce->upsert_opportunity($opportunity_data, $record); // Salesforce adds the primary Contact Role itself when a new // Opportunity is created with ContactId, so one is only created here // when no ContactId was sent. Filterable. if ($created && $opportunity_id && $contact_id && empty($opportunity_data['ContactId']) && apply_filters('adfoin_salesforce_create_contact_role', true, $record)) { $salesforce->create_opportunity_contact_role($opportunity_id, $contact_id, $record); } } if (!empty($case_data)) { if ($account_id) { $case_data['AccountId'] = $account_id; } if ($contact_id) { $case_data['ContactId'] = $contact_id; } if ($owner_id) { $case_data['OwnerId'] = $owner_id; } $salesforce->create_case($case_data, $record); } } /* * If a RecordTypeId field was mapped with a human-readable Record Type * Name/DeveloperName, resolve it to the actual Id before the record is created. */ function adfoin_salesforce_apply_record_type( $salesforce, $object, $obj_data ) { if ( ! empty( $obj_data['RecordTypeId'] ) ) { $resolved = $salesforce->resolve_record_type( $object, $obj_data['RecordTypeId'] ); if ( $resolved ) { $obj_data['RecordTypeId'] = $resolved; } } return $obj_data; }