.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 .= '
' . __( 'Log in to Salesforce, click the gear icon (top-right) and open Setup. On Starter/Pro Suite, choose Open Advanced Setup.', 'advanced-form-integration' ) . '
';
$instructions .= '
' . __( 'Type "External Client App" in Quick Find, open External Client App Manager and click New External Client App.', 'advanced-form-integration' ) . '
';
$instructions .= '
' . __( 'Enter the App Name, API Name and Contact Email. Keep Distribution State as Local.', 'advanced-form-integration' ) . '
';
$instructions .= '
' . __( 'Expand API (Enable OAuth Settings) and tick Enable OAuth.', 'advanced-form-integration' ) . '
';
$instructions .= '
' . __( 'Copy the Redirect URI below and paste it into Callback URL:', 'advanced-form-integration' ) . '
';
$instructions .= '
' . esc_html( $redirect_uri ) . '
';
$instructions .= '
' . __( 'Add the OAuth scopes "Manage user data via APIs (api)" and "Perform requests at any time (refresh_token, offline_access)".', 'advanced-form-integration' ) . '
';
$instructions .= '
' . __( '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' ) . '
';
$instructions .= '
' . __( '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' ) . '
';
$instructions .= '
' . __( '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' ) . '
';
$instructions .= '
' . __( 'Using an older Connected App? It keeps working, no changes needed.', 'advanced-form-integration' ) . '
';
$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;
}