__( 'Pipedrive', 'advanced-form-integration' ), 'tasks' => array( 'add_ocdna' => __( 'Create New Contact, Organization, Deal, Note, Activity', 'advanced-form-integration' ) ) ); return $actions; } add_filter( 'adfoin_settings_tabs', 'adfoin_pipedrive_settings_tab', 10, 1 ); function adfoin_pipedrive_settings_tab( $providers ) { $providers['pipedrive'] = __( 'Pipedrive', 'advanced-form-integration' ); return $providers; } add_action( 'adfoin_settings_view', 'adfoin_pipedrive_settings_view', 10, 1 ); function adfoin_pipedrive_settings_view( $current_tab ) { if( $current_tab != 'pipedrive' ) { return; } $title = __( 'Pipedrive', 'advanced-form-integration' ); $key = 'pipedrive'; $arguments = wp_json_encode([ 'platform' => $key, 'fields' => [ [ 'key' => 'accessToken', 'label' => __( 'API Token', 'advanced-form-integration' ), 'hidden' => true ] ] ]); $instructions = sprintf( '
  1. %s
', __('Go to Profile > Personal preferences > API to get API Token', 'advanced-form-integration') ); echo adfoin_platform_settings_template( $title, $key, $arguments, $instructions ); } function adfoin_pipedrive_credentials_list() { $html = ''; $credentials = adfoin_read_credentials( 'pipedrive' ); foreach( $credentials as $option ) { $html .= ''; } echo $html; } add_action( 'adfoin_action_fields', 'adfoin_pipedrive_action_fields', 10, 1 ); function adfoin_pipedrive_action_fields() { ?> ' . $option['title'] . ''; } echo $html; } function adfoin_pipedrive_get_credentials( $cred_id ) { $credentials = array(); $all_credentials = adfoin_read_credentials( 'pipedrive' ); if( is_array( $all_credentials ) ) { $credentials = $all_credentials[0]; foreach( $all_credentials as $single ) { if( $cred_id && $cred_id == $single['id'] ) { $credentials = $single; } } } return $credentials; } // Legacy single-account import: surfaces old `adfoin_pipedrive_*` options // as a Legacy Account record when the new credentials store is empty. add_action( 'plugins_loaded', function() { if ( class_exists( 'ADFOIN_Account_Manager' ) ) { ADFOIN_Account_Manager::register_legacy_option_importer( 'pipedrive', array( 'accessToken' => 'adfoin_pipedrive_api_token', ), array( 'id' => '123456', 'title' => 'Untitled', ) ); } }, 20 ); add_action( 'wp_ajax_adfoin_get_pipedrive_credentials', 'adfoin_get_pipedrive_credentials', 10, 0 ); function adfoin_get_pipedrive_credentials() { // Security Check if (! wp_verify_nonce( $_POST['_nonce'], 'advanced-form-integration' ) ) { die( __( 'Security check Failed', 'advanced-form-integration' ) ); } $all_credentials = adfoin_read_credentials( 'pipedrive' ); $formatted = array(); // loop through all and hide part of access token foreach( $all_credentials as $single ) { $single['accessToken'] = substr( $single['accessToken'], 0, 6 ) . '**********'; array_push( $formatted, $single ); } wp_send_json_success( $formatted ); } add_action( 'wp_ajax_adfoin_save_pipedrive_credentials', 'adfoin_save_pipedrive_credentials', 10, 0 ); /* * Get pipedrive subscriber lists */ function adfoin_save_pipedrive_credentials() { // Security Check // Authorization check adfoin_require_manage_options(); if (! wp_verify_nonce( $_POST['_nonce'], 'advanced-form-integration' ) ) { die( __( 'Security check Failed', 'advanced-form-integration' ) ); } $platform = sanitize_text_field( wp_unslash( $_POST['platform'] ) ); if( 'pipedrive' == $platform ) { $data = $_POST['data']; adfoin_save_credentials( $platform, $data ); } wp_send_json_success(); } /* * Delete all cached Pipedrive field/user transients for a credential, so the * next fetch re-reads them live from the API. Used by the "Refresh Fields" * button and reusable elsewhere. */ function adfoin_pipedrive_clear_field_cache( $cred_id ) { $hash = md5( $cred_id ); delete_transient( 'adfoin_pipedrive_users_' . $hash ); delete_transient( 'adfoin_pipedrive_org_fields_' . $hash ); delete_transient( 'adfoin_pipedrive_person_fields_' . $hash ); delete_transient( 'adfoin_pipedrive_deal_fields_' . $hash ); delete_transient( 'adfoin_pipedrive_domain_' . $hash ); delete_transient( 'adfoin_pipedrive_field_types_organization_' . $hash ); delete_transient( 'adfoin_pipedrive_field_types_person_' . $hash ); delete_transient( 'adfoin_pipedrive_field_types_deal_' . $hash ); } add_action( 'wp_ajax_adfoin_get_pipedrive_fields', 'adfoin_get_pipedrive_fields', 10, 0 ); /* * Get Pipedrive Owner list */ function adfoin_get_pipedrive_fields() { // Security Check if (! wp_verify_nonce( $_POST['_nonce'], 'advanced-form-integration' ) ) { die( __( 'Security check Failed', 'advanced-form-integration' ) ); } $cred_id = sanitize_text_field( wp_unslash( $_POST['credId'] ) ); // Allow the "Refresh Fields" button to bypass the 24h field cache so newly // added Pipedrive custom fields show up immediately. if( ! empty( $_POST['refresh'] ) ) { adfoin_pipedrive_clear_field_cache( $cred_id ); } $fields = array(); $users = adfoin_get_pipedrive_users( $cred_id ); array_push( $fields, array( 'key' => 'owner', 'value' => 'Owner', 'description' => implode(', ', $users ) ) ); // Get Organization Fields $org_fields = adfoin_get_pipedrive_org_fields( $cred_id ); $fields = array_merge( $fields, $org_fields ); // Get Person Fields $person_fields = adfoin_get_pipedrive_person_fields( $cred_id ); $fields = array_merge( $fields, $person_fields ); // Get Deal Fields $deal_fields = adfoin_get_pipedrive_deal_fields( $cred_id ); $fields = array_merge( $fields, $deal_fields ); array_push( $fields, array( 'key' => 'note_content', 'value' => 'Content [Note]', 'description' => '' ) ); array_push( $fields, array( 'key' => 'act_subject', 'value' => 'Subject [Activity]', 'description' => 'Required for creating an activity' ) ); array_push( $fields, array( 'key' => 'act_type', 'value' => 'Type [Activity]', 'description' => 'Example: call, meeting, task, deadline, email, lunch' ) ); array_push( $fields, array( 'key' => 'act_due_date', 'value' => 'Due Date [Activity]', 'description' => 'Format: YYYY-MM-DD' ) ); array_push( $fields, array( 'key' => 'act_after_days', 'value' => 'Due Date After X days [Activity]', 'description' => 'Accepts numeric value. If filled, due date will be calculated and set' ) ); array_push( $fields, array( 'key' => 'act_due_time', 'value' => 'Due Time [Activity]', 'description' => 'Format: HH:MM' ) ); array_push( $fields, array( 'key' => 'act_duration', 'value' => 'Duration [Activity]', 'description' => 'Format: HH:MM' ) ); array_push( $fields, array( 'key' => 'act_note', 'value' => 'Note [Activity]', 'description' => '' ) ); wp_send_json_success( $fields ); } function adfoin_get_pipedrive_users( $cred_id ) { // Check cache first (24 hour expiration) $cache_key = 'adfoin_pipedrive_users_' . md5( $cred_id ); $cached_users = get_transient( $cache_key ); if( false !== $cached_users ) { return $cached_users; } $user_data = adfoin_pipedrive_request( 'users?limit=500', 'GET', array(), array(), $cred_id ); if ( is_wp_error( $user_data ) ) { return array(); } $user_body = json_decode( wp_remote_retrieve_body( $user_data ), true ); $users = array(); foreach( $user_body['data'] as $single ) { $users[] = $single['name'] . ': ' . $single['id']; } // Cache for 24 hours set_transient( $cache_key, $users, DAY_IN_SECONDS ); return $users; } /* * Get Pipedrive Organization Fields */ function adfoin_get_pipedrive_org_fields( $cred_id) { // Check cache first (24 hour expiration) $cache_key = 'adfoin_pipedrive_org_fields_' . md5( $cred_id ); $cached_fields = get_transient( $cache_key ); if( false !== $cached_fields ) { return $cached_fields; } $org_fields = array( array( 'key' => 'org_name', 'value' => 'Name [Organziation]', 'description' => '' ), array( 'key' => 'org_address', 'value' => 'Address [Organziation]', 'description' => '' ), ); $data = adfoin_pipedrive_request( 'organizationFields?limit=500', 'GET', array(), array(), $cred_id ); if( is_wp_error( $data ) ) { wp_send_json_error(); } $body = json_decode( $data['body'] ); foreach( $body->data as $single ) { if( strlen( $single->key ) == 40 || $single->key == 'label' ) { $description = ''; if( $single->field_type == 'enum' || $single->field_type == 'set' ) { foreach( $single->options as $value ) { $description .= $value->label . ': ' . $value->id . ' '; } } array_push( $org_fields, array( 'key' => 'org_' . $single->key, 'value' => $single->name . ' [Organziation]', 'description' => $description ) ); } } // Cache for 24 hours set_transient( $cache_key, $org_fields, DAY_IN_SECONDS ); return $org_fields; } /* * Get Pipedrive Peson Fields */ function adfoin_get_pipedrive_person_fields( $cred_id ) { // Check cache first (24 hour expiration) $cache_key = 'adfoin_pipedrive_person_fields_' . md5( $cred_id ); $cached_fields = get_transient( $cache_key ); if( false !== $cached_fields ) { return $cached_fields; } $person_fields = array(); $cred_id = sanitize_text_field( wp_unslash( $_POST['credId'] ) ); $data = adfoin_pipedrive_request( 'personFields?limit=500', 'GET', array(), array(), $cred_id ); if( is_wp_error( $data ) ) { wp_send_json_success( $person_fields ); } $body = json_decode( wp_remote_retrieve_body( $data ) ); foreach( $body->data as $single ) { $description = ''; // Standard editable fields expose bulk_edit_allowed = true. Custom fields use a // 40-character hash key and may have bulk_edit_allowed = false depending on their // type (address, monetary, time, date, etc.), so detect them by key length too — // mirroring how org and deal fields are detected. $is_custom_field = ( strlen( $single->key ) == 40 ); $is_editable = ( isset( $single->bulk_edit_allowed ) && true == $single->bulk_edit_allowed ); if( $is_custom_field || $is_editable ) { if( 'name' == $single->key ) { $description = __( 'Required for creating a person', 'advanced-form-integration' ); } if( 'visible_to' == $single->key ) { $description = __( 'Owner & followers (private): 1 Entire company (shared): 3', 'advanced-form-integration' ); } if( 'first_name' == $single->key || 'last_name' == $single->key || 'org_id' == $single->key || 'owner_id' == $single->key ) { continue; } if( $single->field_type == 'enum' || $single->field_type == 'set' ) { foreach( $single->options as $value ) { $description .= $value->label . ': ' . $value->id . ' '; } } array_push( $person_fields, array( 'key' => 'per_' . $single->key, 'value' => $single->name . ' [Person]', 'description' => $description ) ); } } // Cache for 24 hours set_transient( $cache_key, $person_fields, DAY_IN_SECONDS ); return $person_fields; } /* * Get Pipedrive Deal Fields */ function adfoin_get_pipedrive_deal_fields( $cred_id) { // Check cache first (24 hour expiration) $cache_key = 'adfoin_pipedrive_deal_fields_' . md5( $cred_id ); $cached_fields = get_transient( $cache_key ); if( false !== $cached_fields ) { return $cached_fields; } $stages = ''; $cred_id = sanitize_text_field( wp_unslash( $_POST['credId'] ) ); // API v2 stage objects no longer carry pipeline_name (removed in v2), so // fetch pipelines once and map pipeline_id to its name for the description. $pipeline_names = array(); $pipeline_data = adfoin_pipedrive_request( 'pipelines?limit=500', 'GET', array(), array(), $cred_id ); if( ! is_wp_error( $pipeline_data ) ) { $pipeline_body = json_decode( wp_remote_retrieve_body( $pipeline_data ) ); if( isset( $pipeline_body->data ) && is_array( $pipeline_body->data ) ) { foreach( $pipeline_body->data as $single ) { $pipeline_names[ $single->id ] = $single->name; } } } $stage_data = adfoin_pipedrive_request( 'stages?limit=500', 'GET', array(), array(), $cred_id ); if( ! is_wp_error( $stage_data ) ) { $stage_body = json_decode( wp_remote_retrieve_body( $stage_data ) ); if( isset( $stage_body->data ) && is_array( $stage_body->data ) ) { foreach( $stage_body->data as $single ) { $pipeline_name = ''; if( isset( $single->pipeline_name ) && $single->pipeline_name ) { // v1 fallback response $pipeline_name = $single->pipeline_name; } elseif( isset( $single->pipeline_id, $pipeline_names[ $single->pipeline_id ] ) ) { // v2 response $pipeline_name = $pipeline_names[ $single->pipeline_id ]; } $stages .= ( $pipeline_name ? $pipeline_name . '/' : '' ) . $single->name . ': ' . $single->id . ' '; } } } $deal_fields = array( array( 'key' => 'deal_title', 'value' => 'Title [Deal]', 'description' => __( 'Required for creating a deal.', 'advanced-form-integration' ) ), array( 'key' => 'deal_value', 'value' => 'Value [Deal]', 'description' => 'Numeric value of the deal. If omitted, it will be set to 0.' ), array( 'key' => 'deal_currency', 'value' => 'Currency [Deal]', 'description' => 'Accepts a 3-character currency code. If omitted, currency will be set to the default currency of the authorized user.' ), array( 'key' => 'deal_probability', 'value' => 'Probability [Deal]', 'description' => '' ), array( 'key' => 'deal_stage_id', 'value' => 'Stage ID [Deal]', 'description' => $stages ), array( 'key' => 'deal_status', 'value' => 'Status [Deal]', 'description' => 'Example: open, lost, won, deleted' ), array( 'key' => 'deal_lost_reason', 'value' => 'Lost Reason [Deal]', 'description' => '' ), array( 'key' => 'deal_expected_close_date', 'value' => 'Expected Close Date [Deal]', 'description' => 'YYYY-MM-DD' ) ); $data = adfoin_pipedrive_request( 'dealFields?limit=500', 'GET', array(), array(), $cred_id ); if( is_wp_error( $data ) ) { wp_send_json_error(); } $body = json_decode( $data['body'] ); foreach( $body->data as $single ) { if( strlen( $single->key ) == 40 || $single->key == 'label' ) { $description = ''; if( $single->field_type == 'enum' || $single->field_type == 'set' ) { foreach( $single->options as $value ) { $description .= $value->label . ': ' . $value->id . ' '; } } array_push( $deal_fields, array( 'key' => 'deal_' . $single->key, 'value' => $single->name . ' [Deal]', 'description' => $description ) ); } } // Cache for 24 hours set_transient( $cache_key, $deal_fields, DAY_IN_SECONDS ); return $deal_fields; } add_action( 'adfoin_pipedrive_job_queue', 'adfoin_pipedrive_job_queue', 10, 1 ); function adfoin_pipedrive_job_queue( $data ) { adfoin_pipedrive_send_data( $data['record'], $data['posted_data'] ); } /* * Handles sending data to Pipedrive API */ function adfoin_pipedrive_send_data( $record, $posted_data ) { $record_data = json_decode( $record['data'], true ); if ( adfoin_check_conditional_logic( $record_data['action_data']['cl'] ?? array(), $posted_data ) ) { return; } $data = $record_data['field_data']; $task = $record['task']; $owner = isset( $data['owner'] ) ? adfoin_get_parsed_values( $data['owner'], $posted_data ) : ''; $duplicate = isset( $data['duplicate'] ) ? $data['duplicate'] : ''; $duplicate_org = isset( $data['duplicateOrg'] ) ? $data['duplicateOrg'] : ''; $cred_id = isset( $data['credId'] ) ? $data['credId'] : ''; $org_id = ''; $person_id = ''; $deal_id = ''; if( $task == 'add_ocdna' ) { $holder = array(); $org_data = array(); $person_data = array(); $deal_data = array(); $note_data = array(); $act_data = array(); foreach( $data as $key => $value ) { $holder[$key] = adfoin_get_parsed_values( $value, $posted_data ); } foreach( $holder as $key => $value ) { if( substr( $key, 0, 4 ) == 'org_' && $value ) { $key = substr( $key, 4 ); $org_data[$key] = $value; } if( substr( $key, 0, 4 ) == 'per_' && $value ) { $key = substr( $key, 4 ); $person_data[$key] = $value; } if( substr( $key, 0, 5 ) == 'deal_' && $value ) { $key = substr( $key, 5 ); $deal_data[$key] = $value; } if( substr( $key, 0, 5 ) == 'note_' && $value ) { $key = substr( $key, 5 ); $note_data[$key] = $value; } if( substr( $key, 0, 4 ) == 'act_' && $value ) { $key = substr( $key, 4 ); $act_data[$key] = $value; } } if( isset( $org_data['name'] ) && $org_data['name'] ) { $org_data['owner_id'] = $owner; $org_data = array_filter( array_map( 'trim', $org_data ) ); // Only search for existing organization if duplicate checking is DISABLED // When duplicates are allowed, skip search and always create new (saves 40 tokens) if( 'true' != $duplicate_org ) { $org_id = adfoin_pipedrive_organization_exists( $org_data['name'], $cred_id ); } // Data stays in flat v1 shape here; adfoin_pipedrive_request() converts // it to the v2 schema (and PUT to PATCH) when the call is routed to v2. if( $org_id ) { // Existing organization found - update it $org_response = adfoin_pipedrive_request( 'organizations/' . $org_id, 'PUT', $org_data, $record, $cred_id ); } else { // No existing organization found (or duplicates allowed) - create new usleep( 250000 ); // 0.25 seconds $org_response = adfoin_pipedrive_request( 'organizations', 'POST', $org_data, $record, $cred_id ); $org_body = json_decode( wp_remote_retrieve_body( $org_response ) ); if( $org_body->success == true ) { $org_id = $org_body->data->id; } } } if( isset( $person_data['name'] ) && $person_data['name'] ) { $person_data['owner_id'] = $owner; if( $org_id ) { $person_data['org_id'] = $org_id; } $person_data = array_filter( array_map( 'trim', $person_data ) ); // Search for an existing person when duplicate checking is DISABLED. // Matches on email or phone, so a person mapped with only a phone is // still de-duplicated. if( 'true' != $duplicate ) { $dedupe_email = isset( $person_data['email'] ) ? $person_data['email'] : ''; $dedupe_phone = isset( $person_data['phone'] ) ? $person_data['phone'] : ''; if( $dedupe_email || $dedupe_phone ) { $person_id = adfoin_pipedrive_person_exists( $dedupe_email, $cred_id, $dedupe_phone ); } } if( $person_id ) { // Existing person found - update it usleep( 250000 ); // 0.25 seconds $person_response = adfoin_pipedrive_request( 'persons/' . $person_id, 'PUT', $person_data, $record, $cred_id ); } else { // No existing person found (or duplicates allowed) - create new usleep( 250000 ); // 0.25 seconds $person_response = adfoin_pipedrive_request( 'persons', 'POST', $person_data, $record, $cred_id ); $person_body = json_decode( wp_remote_retrieve_body( $person_response ) ); if( $person_body->success == true ) { $person_id = $person_body->data->id; } } } if( isset( $deal_data['title'] ) && $deal_data['title'] ) { $deal_data['user_id'] = $owner; if( $org_id ) { $deal_data['org_id'] = $org_id; } if( $person_id ) { $deal_data['person_id'] = $person_id; } $deal_data = array_filter( array_map( 'trim', $deal_data ) ); usleep( 250000 ); // 0.25 seconds $deal_response = adfoin_pipedrive_request( 'deals', 'POST', $deal_data, $record, $cred_id ); $deal_body = json_decode( wp_remote_retrieve_body( $deal_response ) ); if( $deal_body->success == true ) { $deal_id = $deal_body->data->id; } } if( isset( $note_data['content'] ) && $note_data['content'] ) { $note_data['user_id'] = $owner; if( $org_id ) { $note_data['org_id'] = $org_id; } if( $person_id ) { $note_data['person_id'] = $person_id; } if( $deal_id ) { $note_data['deal_id'] = $deal_id; } $note_data = array_filter( array_map( 'trim', $note_data ) ); usleep( 250000 ); // 0.25 seconds $note_response = adfoin_pipedrive_request( 'notes', 'POST', $note_data, $record, $cred_id ); $note_body = json_decode( wp_remote_retrieve_body( $note_response ) ); } if( isset( $act_data['subject'] ) && $act_data['subject'] ) { $act_data['user_id'] = $owner; if( $org_id ) { $act_data['org_id'] = $org_id; } if( $person_id ) { $act_data['person_id'] = $person_id; } if( $deal_id ) { $act_data['deal_id'] = $deal_id; } if( isset( $act_data['after_days'] ) && $act_data['after_days'] ) { $after_days = (int) $act_data['after_days']; if( $after_days ) { $timezone = wp_timezone(); $date = date_create( '+' . $after_days . ' days', $timezone ); $formatted_date = date_format( $date, 'Y-m-d' ); $act_data['due_date'] = $formatted_date; unset( $act_data['after_days'] ); } } $act_data = array_filter( array_map( 'trim', $act_data ) ); usleep( 250000 ); // 0.25 seconds $act_response = adfoin_pipedrive_request( 'activities', 'POST', $act_data, $record, $cred_id ); // $act_body = json_decode( wp_remote_retrieve_body( $act_response ) ); } } return; } function adfoin_pipedrive_request( $endpoint, $method = 'GET', $data = array(), $record = array(), $cred_id = '', $retry_count = 0 ) { $credentials = adfoin_pipedrive_get_credentials( $cred_id ); $api_token = isset( $credentials['accessToken'] ) ? $credentials['accessToken'] : ''; $args = array( 'timeout' => 30, 'method' => $method, 'headers' => array( 'Accept' => 'application/json', 'Content-Type' => 'application/json' ) ); // Pipedrive has deprecated API v1 for every resource that has a v2 // equivalent (activities, deals, persons, organizations, pipelines, // stages, search) and is sunsetting those v1 endpoints, so they are // routed to v2 by default. Resources WITHOUT a v2 counterpart (users, // notes, leads, leadLabels, *Fields) remain on v1, which stays supported. // The filter is a kill switch: return 'v1' to force everything back to v1. $api_version = apply_filters( 'adfoin_pipedrive_api_version', 'v2' ); $resource = adfoin_pipedrive_get_endpoint_resource( $endpoint ); $v2_resources = array( 'activities', 'deals', 'persons', 'organizations', 'pipelines', 'stages' ); if ( ! in_array( $resource, $v2_resources, true ) ) { $api_version = 'v1'; } // IMPORTANT: the Pipedrive API v2 is ONLY served from the company-specific // domain — https://.pipedrive.com/api/v2/ — NOT from the // generic api.pipedrive.com host. Calling the generic host for v2 returns a // 404 HTML page, which silently broke every dedup search (so "Allow // Duplicate" off still created duplicates). Resolve + cache the domain and // fall back to v1 if it can't be determined, so the request still works. if ( 'v2' == $api_version ) { $domain = adfoin_pipedrive_get_company_domain( $cred_id ); if ( $domain ) { $base_url = "https://{$domain}.pipedrive.com/api/v2/"; } else { $api_version = 'v1'; } } if ( 'v1' == $api_version ) { $base_url = 'https://api.pipedrive.com/v1/'; } // v2 update endpoints only accept PATCH; PUT is v1-only. if ( 'v2' == $api_version && 'PUT' == $method ) { $method = 'PATCH'; $args['method'] = 'PATCH'; } $url = $base_url . $endpoint; $url = add_query_arg( 'api_token', $api_token, $url ); // Retries resend this untouched copy; converting already-converted data // again breaks it (e.g. multi-option IDs become an empty array). $raw_data = $data; if( 'POST' == $method || 'PUT' == $method || 'PATCH' == $method ) { if ( 'v2' == $api_version ) { // Convert the flat v1-style body to the v2 write schema: custom // fields nested under `custom_fields` with strictly typed values, // person email/phone as emails/phones arrays, organization address // as an object, user_id renamed to owner_id, IDs cast to integers. $data = adfoin_pipedrive_transform_to_v2( $data, $resource, $cred_id ); } elseif ( is_array( $data ) && isset( $data['custom_fields'] ) ) { // v1 write endpoints expect custom fields flat at the root keyed by // the 40-char hash. Sending the wrapper to v1 makes Pipedrive // silently drop the values (saved as null), so flatten it back out // whenever the request is actually hitting v1. $data = adfoin_pipedrive_transform_from_v2( $data ); } $args['body'] = wp_json_encode( $data ); } $response = wp_remote_request( $url, $args ); $response_code = wp_remote_retrieve_response_code( $response ); // Handle rate limiting with exponential backoff if( 429 == $response_code && $retry_count < 3 ) { $headers = wp_remote_retrieve_headers( $response ); $retry_after = isset( $headers['x-ratelimit-reset'] ) ? (float) $headers['x-ratelimit-reset'] : 2; // Exponential backoff: 2s, 4s, 8s $delay = min( $retry_after, pow( 2, $retry_count + 1 ) ); sleep( $delay ); return adfoin_pipedrive_request( $endpoint, $method, $raw_data, $record, $cred_id, $retry_count + 1 ); } if( $record ) { adfoin_add_to_log( $response, $url, $args, $record ); } // v2 accepts these person fields only with contact sync enabled (403 // otherwise). Retry once without them so the person is still saved. if ( 'v2' == $api_version && 'persons' == $resource && in_array( $method, array( 'POST', 'PATCH' ), true ) && in_array( $response_code, array( 400, 403 ), true ) && is_array( $data ) && $retry_count < 3 ) { $sync_only = array_flip( array( 'im', 'ims', 'postal_address', 'notes', 'birthday', 'job_title' ) ); if ( array_intersect_key( $data, $sync_only ) ) { return adfoin_pipedrive_request( $endpoint, $method, array_diff_key( $raw_data, $sync_only ), $record, $cred_id, 3 ); } } return $response; } /** * Format custom fields for the v1 leads endpoint. Leads use the deal custom * fields but in the v1 shape (verified live): money as `` plus * `_currency`, ranges as `` plus `_until`, times as HH:MM:SS. * Other keys pass through unchanged. * * @param array $data Lead data with custom fields under their 40-char keys. * @param string $currency Currency for money fields; the account default if empty. * @param string $cred_id Credential ID. * @return array */ function adfoin_pipedrive_format_lead_custom_fields( $data, $currency, $cred_id ) { $field_types = adfoin_pipedrive_get_field_types( 'deal', $cred_id ); foreach ( $data as $key => $value ) { if ( 40 != strlen( $key ) || ! isset( $field_types[ $key ] ) || ! is_scalar( $value ) ) { continue; } $options = $field_types[ $key ]['options']; $parts = array_values( array_filter( array_map( 'trim', explode( ',', (string) $value ) ), 'strlen' ) ); switch ( $field_types[ $key ]['type'] ) { case 'monetary': $data[ $key ] = is_numeric( $value ) ? (float) $value : $value; $data[ $key . '_currency' ] = $currency ? $currency : adfoin_pipedrive_get_default_currency( $cred_id ); break; case 'double': case 'int': $data[ $key ] = is_numeric( $value ) ? $value + 0 : $value; break; case 'enum': $data[ $key ] = adfoin_pipedrive_resolve_option_id( $value, $options ); break; case 'set': $ids = array(); foreach ( $parts as $part ) { $ids[] = adfoin_pipedrive_resolve_option_id( $part, $options ); } $data[ $key ] = implode( ',', $ids ); break; case 'date': $data[ $key ] = adfoin_pipedrive_normalize_date_v2( $value ); break; case 'time': $data[ $key ] = adfoin_pipedrive_normalize_time_v2( $value ); break; case 'daterange': case 'timerange': $normalize = 'daterange' == $field_types[ $key ]['type'] ? 'adfoin_pipedrive_normalize_date_v2' : 'adfoin_pipedrive_normalize_time_v2'; $start = isset( $parts[0] ) ? call_user_func( $normalize, $parts[0] ) : ''; $data[ $key ] = $start; $data[ $key . '_until' ] = count( $parts ) > 1 ? call_user_func( $normalize, end( $parts ) ) : $start; break; } } return $data; } /** * The account's default currency code, cached per credential. */ function adfoin_pipedrive_get_default_currency( $cred_id ) { $cache_key = 'adfoin_pipedrive_currency_' . md5( (string) $cred_id ); $cached = get_transient( $cache_key ); if ( false !== $cached ) { return $cached; } $body = json_decode( wp_remote_retrieve_body( adfoin_pipedrive_request( 'users/me', 'GET', array(), array(), $cred_id ) ), true ); $currency = isset( $body['data']['default_currency'] ) ? sanitize_text_field( $body['data']['default_currency'] ) : ''; set_transient( $cache_key, $currency, $currency ? WEEK_IN_SECONDS : HOUR_IN_SECONDS ); return $currency; } /* * Log a step that was not sent to Pipedrive, with the data it would have * sent, so the submission can be recovered from the AFI log. */ function adfoin_pipedrive_log_skipped( $record, $endpoint, $message, $data = array() ) { $args = array( 'method' => 'POST', 'body' => wp_json_encode( $data ) ); adfoin_add_to_log( new WP_Error( 'pipedrive_not_sent', $message ), 'https://api.pipedrive.com/v1/' . $endpoint, $args, $record ); } /** * Resolve the Pipedrive company domain for a credential (e.g. "acme" for * acme.pipedrive.com). The v2 API is only served from this domain, so it's * required to build v2 URLs. Cached per credential; a negative result is cached * briefly so a transient failure doesn't disable v2 for a day. * * @return string Company domain, or '' if it couldn't be resolved. */ function adfoin_pipedrive_get_company_domain( $cred_id ) { $cache_key = 'adfoin_pipedrive_domain_' . md5( (string) $cred_id ); $cached = get_transient( $cache_key ); if ( false !== $cached ) { return $cached; } // users/me is a v1 GET, so this does not recurse into the v2 branch. $response = adfoin_pipedrive_request( 'users/me', 'GET', array(), array(), $cred_id ); $domain = ''; if ( ! is_wp_error( $response ) ) { $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( isset( $body['data']['company_domain'] ) && $body['data']['company_domain'] ) { $domain = sanitize_text_field( $body['data']['company_domain'] ); } } set_transient( $cache_key, $domain, $domain ? WEEK_IN_SECONDS : HOUR_IN_SECONDS ); return $domain; } /** * Extract the root resource from an endpoint string, e.g. * "organizations/123" -> "organizations", "persons/search?term=x" -> "persons", * "stages?limit=500" -> "stages". Used to decide v1 vs v2 routing. * * @param string $endpoint Relative endpoint passed to adfoin_pipedrive_request(). * @return string Root resource name. */ function adfoin_pipedrive_get_endpoint_resource( $endpoint ) { $path = explode( '?', (string) $endpoint ); $parts = explode( '/', trim( $path[0], '/' ) ); return $parts[0]; } /** * Fetch custom field definitions for an entity: field_type plus the enum/set * option list (label => id), so v2 payloads can be strictly typed and option * labels can be translated to the numeric IDs v2 requires. * Uses the v1 *Fields endpoints, which are not deprecated. Cached per credential. * * @param string $entity organization|person|deal * @param string $cred_id Credential ID. * @return array Map of 40-char field key => array( 'type' => field_type, 'options' => array( lowercased label => id ) ). */ function adfoin_pipedrive_get_field_types( $entity, $cred_id ) { $endpoint_map = array( 'organization' => 'organizationFields', 'person' => 'personFields', 'deal' => 'dealFields', ); if ( ! isset( $endpoint_map[ $entity ] ) ) { return array(); } $cache_key = 'adfoin_pipedrive_field_types_' . $entity . '_' . md5( (string) $cred_id ); $cached = get_transient( $cache_key ); // Only accept the current cache shape (each entry is an array with 'type'). if ( false !== $cached && is_array( $cached ) ) { $first = reset( $cached ); if ( empty( $cached ) || ( is_array( $first ) && isset( $first['type'] ) ) ) { return $cached; } } $types = array(); $response = adfoin_pipedrive_request( $endpoint_map[ $entity ] . '?limit=500', 'GET', array(), array(), $cred_id ); if ( ! is_wp_error( $response ) ) { $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( isset( $body['data'] ) && is_array( $body['data'] ) ) { foreach ( $body['data'] as $single ) { // Labels are kept too so label names can be resolved to IDs. if ( isset( $single['key'], $single['field_type'] ) && ( 40 == strlen( $single['key'] ) || in_array( $single['key'], array( 'label', 'label_ids' ), true ) ) ) { $options = array(); if ( isset( $single['options'] ) && is_array( $single['options'] ) ) { foreach ( $single['options'] as $option ) { if ( isset( $option['label'], $option['id'] ) ) { $options[ strtolower( trim( (string) $option['label'] ) ) ] = $option['id']; } } } $types[ $single['key'] ] = array( 'type' => $single['field_type'], 'options' => $options, ); } } } } // Cache a failed lookup only briefly so a transient error doesn't stick. set_transient( $cache_key, $types, $types ? DAY_IN_SECONDS : HOUR_IN_SECONDS ); return $types; } /** * Resolve an enum/set option value to its numeric option ID. Accepts the ID * itself or the option label (case-insensitive), since users map either. * * @param mixed $value Raw single option value. * @param array $options Map of lowercased label => id. * @return int|string Numeric ID, or the original value if it can't be resolved. */ function adfoin_pipedrive_resolve_option_id( $value, $options ) { $value = trim( (string) $value ); if ( is_numeric( $value ) ) { return (int) $value; } $lower = strtolower( $value ); if ( isset( $options[ $lower ] ) ) { return (int) $options[ $lower ]; } return $value; } /** * Normalize a date string to the strict YYYY-MM-DD format API v2 requires. * Leaves the value untouched if it already matches or can't be parsed. * * @param string $value Raw date value. * @return string Normalized date. */ function adfoin_pipedrive_normalize_date_v2( $value ) { $value = trim( (string) $value ); if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $value ) ) { return $value; } $timestamp = strtotime( $value ); if ( false !== $timestamp ) { return gmdate( 'Y-m-d', $timestamp ); } return $value; } /** * Normalize a time string to the HH:MM:SS format API v2 expects. * * @param string $value Raw time value. * @return string Normalized time. */ function adfoin_pipedrive_normalize_time_v2( $value ) { $value = trim( (string) $value ); if ( preg_match( '/^(\d{1,2}):(\d{2})$/', $value, $m ) ) { return str_pad( $m[1], 2, '0', STR_PAD_LEFT ) . ':' . $m[2] . ':00'; } return $value; } /** * Normalize a time string to the HH:MM format API v2 requires for the * standard Activity `due_time`/`duration` fields. Confirmed live against * the v2 API: "14:30:00" is rejected ("This value is not a valid * datetime"), "14:30" is accepted — the opposite direction from * adfoin_pipedrive_normalize_time_v2(), which pads custom time-type fields * out to HH:MM:SS for their nested `{value: ...}` object shape. Strips a * trailing :SS if present; leaves an already-HH:MM value unchanged. * * @param string $value Raw time value. * @return string Normalized HH:MM time. */ function adfoin_pipedrive_normalize_activity_time_v2( $value ) { $value = trim( (string) $value ); if ( preg_match( '/^(\d{1,2}):(\d{2})(?::\d{2})?$/', $value, $m ) ) { return str_pad( $m[1], 2, '0', STR_PAD_LEFT ) . ':' . $m[2]; } return $value; } /** * Cast a single custom field value to the shape API v2 requires. v2 has strict * input validation: enum/set option IDs must be integers (set as an array), * monetary/address/time/date-range values must be objects, numbers can't be * strings. See the v2 migration guide's custom fields section. * * @param mixed $value Raw mapped value (string from form parsing). * @param array|string $field Field definition array ('type', 'options'), or '' if unknown. * @return mixed Typed value for the v2 custom_fields object. */ function adfoin_pipedrive_cast_custom_field_v2( $value, $field ) { $type = is_array( $field ) && isset( $field['type'] ) ? $field['type'] : ( is_string( $field ) ? $field : '' ); $options = is_array( $field ) && isset( $field['options'] ) ? $field['options'] : array(); switch ( $type ) { case 'enum': return adfoin_pipedrive_resolve_option_id( $value, $options ); case 'set': $parts = array_filter( array_map( 'trim', explode( ',', (string) $value ) ), 'strlen' ); $ids = array(); foreach ( $parts as $part ) { $id = adfoin_pipedrive_resolve_option_id( $part, $options ); if ( is_int( $id ) ) { $ids[] = $id; } } return $ids; case 'user': case 'people': case 'org': case 'int': return is_numeric( $value ) ? (int) $value : $value; case 'double': return is_numeric( $value ) ? (float) $value : $value; case 'monetary': // currency subfield is optional; account default currency is used. return array( 'value' => is_numeric( $value ) ? (float) $value : $value ); case 'address': // Only `value` is required; other subfields are optional. return array( 'value' => (string) $value ); case 'date': return adfoin_pipedrive_normalize_date_v2( $value ); case 'daterange': // v2 requires an object with both `value` and `until`. The mapping UI // exposes a single input, so accept "start,end"; with a single date // both bounds are set to it (a one-day range). $parts = array_values( array_filter( array_map( 'trim', explode( ',', (string) $value ) ), 'strlen' ) ); $start = isset( $parts[0] ) ? adfoin_pipedrive_normalize_date_v2( $parts[0] ) : ''; $until = count( $parts ) > 1 ? adfoin_pipedrive_normalize_date_v2( end( $parts ) ) : $start; return array( 'value' => $start, 'until' => $until ); case 'time': // v2 time fields are objects; timezone_name is optional. return array( 'value' => adfoin_pipedrive_normalize_time_v2( $value ) ); case 'timerange': $parts = array_values( array_filter( array_map( 'trim', explode( ',', (string) $value ) ), 'strlen' ) ); $start = isset( $parts[0] ) ? adfoin_pipedrive_normalize_time_v2( $parts[0] ) : ''; $until = count( $parts ) > 1 ? adfoin_pipedrive_normalize_time_v2( end( $parts ) ) : $start; return array( 'value' => $start, 'until' => $until ); default: // text, varchar, varchar_auto, phone etc. pass through unchanged. return $value; } } /** * Transform a flat v1-style payload into the Pipedrive API v2 write schema: * - custom fields (40-char hash keys) move into a nested `custom_fields` * object with strictly typed values * - person: email/phone become emails/phones arrays of objects * - organization: address becomes an object with a `value` key * - deal/activity: user_id is renamed to owner_id * - single `label` enum becomes `label_ids` array of integers * - ID and numeric fields are cast to real numbers (v2 rejects numeric strings) * * @param array $data Flat v1-style data. * @param string $resource Root resource (organizations|persons|deals|activities). * @param string $cred_id Credential ID, used to look up custom field types. * @return array Payload for the v2 API. */ function adfoin_pipedrive_transform_to_v2( $data, $resource = '', $cred_id = '' ) { if ( ! is_array( $data ) ) { return $data; } $entity_map = array( 'organizations' => 'organization', 'persons' => 'person', 'deals' => 'deal', 'activities' => 'activity', ); $entity = isset( $entity_map[ $resource ] ) ? $entity_map[ $resource ] : ''; $field_types = array(); if ( in_array( $entity, array( 'organization', 'person', 'deal' ), true ) ) { $field_types = adfoin_pipedrive_get_field_types( $entity, $cred_id ); } $int_fields = array( 'owner_id', 'user_id', 'org_id', 'person_id', 'deal_id', 'lead_id', 'stage_id', 'pipeline_id', 'visible_to', 'probability', 'creator_user_id' ); $transformed = array(); $custom_fields = array(); foreach ( $data as $key => $value ) { // Custom fields have 40-character hash keys. if ( 40 == strlen( $key ) ) { $field_def = isset( $field_types[ $key ] ) ? $field_types[ $key ] : ''; $custom_fields[ $key ] = adfoin_pipedrive_cast_custom_field_v2( $value, $field_def ); continue; } // Support payloads that were already wrapped by an earlier version. if ( 'custom_fields' == $key && is_array( $value ) ) { foreach ( $value as $cf_key => $cf_value ) { $field_def = isset( $field_types[ $cf_key ] ) ? $field_types[ $cf_key ] : ''; $custom_fields[ $cf_key ] = adfoin_pipedrive_cast_custom_field_v2( $cf_value, $field_def ); } continue; } // v2 renamed the owner field on deals and activities. if ( 'user_id' == $key && in_array( $entity, array( 'deal', 'activity' ), true ) ) { $key = 'owner_id'; } // v2 persons take arrays of email/phone objects instead of strings. if ( 'email' == $key && 'person' == $entity ) { $transformed['emails'] = array( array( 'value' => (string) $value, 'primary' => true ) ); continue; } if ( 'phone' == $key && 'person' == $entity ) { $transformed['phones'] = array( array( 'value' => (string) $value, 'primary' => true ) ); continue; } // v2 person writes take `im` as an array of objects. `ims` is the // read-side name and is rejected ("Parameter 'ims' is not allowed"). if ( in_array( $key, array( 'im', 'ims' ), true ) && 'person' == $entity ) { $transformed['im'] = is_array( $value ) ? $value : array( array( 'value' => (string) $value, 'primary' => true ) ); continue; } // v2 activities treat person_id as read-only; the person is set as // the primary participant instead. if ( 'person_id' == $key && 'activity' == $entity ) { $transformed['participants'] = array( array( 'person_id' => (int) $value, 'primary' => true ) ); continue; } // v2 organization address is an object; the plain string goes in `value`. if ( 'address' == $key && 'organization' == $entity ) { $transformed['address'] = is_array( $value ) ? $value : array( 'value' => (string) $value ); continue; } // Labels are `label_ids` (array of integers) in v2. Field lists return // the key as `label` (v1) or `label_ids`; both may hold "1, 2". if ( in_array( $key, array( 'label', 'label_ids' ), true ) && in_array( $entity, array( 'organization', 'person', 'deal' ), true ) ) { $labels = is_array( $value ) ? $value : explode( ',', (string) $value ); $options = isset( $field_types['label_ids'] ) ? $field_types['label_ids']['options'] : ( isset( $field_types['label'] ) ? $field_types['label']['options'] : array() ); $ids = array(); // Accept label IDs or names; names that match no label are left out. foreach ( array_filter( array_map( 'trim', $labels ), 'strlen' ) as $label ) { $id = adfoin_pipedrive_resolve_option_id( $label, $options ); if ( is_int( $id ) ) { $ids[] = $id; } } $labels = array_values( array_unique( $ids ) ); if ( $labels ) { $transformed['label_ids'] = $labels; } continue; } // v2 rejects due_time/duration with seconds (confirmed live: "14:30:00" // 400s with "This value is not a valid datetime", "14:30" is accepted). if ( 'activity' == $entity && in_array( $key, array( 'due_time', 'duration' ), true ) ) { $value = adfoin_pipedrive_normalize_activity_time_v2( $value ); } // v2 rejects numeric strings for ID/number fields. if ( in_array( $key, $int_fields, true ) && is_numeric( $value ) ) { $value = (int) $value; } // Currency codes must be upper case ("eur" is rejected). if ( 'currency' == $key && is_string( $value ) ) { $value = strtoupper( trim( $value ) ); } if ( 'value' == $key && 'deal' == $entity && is_numeric( $value ) ) { $value = (float) $value; } $transformed[ $key ] = $value; } // Money custom fields take the deal's currency when one is mapped, // otherwise Pipedrive uses the account default. if ( 'deal' == $entity && ! empty( $data['currency'] ) ) { foreach ( $custom_fields as $cf_key => $cf_value ) { if ( isset( $field_types[ $cf_key ]['type'] ) && 'monetary' == $field_types[ $cf_key ]['type'] && is_array( $cf_value ) && ! isset( $cf_value['currency'] ) ) { $custom_fields[ $cf_key ]['currency'] = strtoupper( trim( (string) $data['currency'] ) ); } } } if ( ! empty( $custom_fields ) ) { $transformed['custom_fields'] = $custom_fields; } return $transformed; } /** * Transform data from Pipedrive API v2 response * Flattens custom_fields object for backward compatibility * * @param array $data Data from v2 API response * @return array Flattened data */ function adfoin_pipedrive_transform_from_v2( $data ) { if ( isset( $data['custom_fields'] ) && is_array( $data['custom_fields'] ) ) { // Flatten custom fields to root level foreach ( $data['custom_fields'] as $key => $value ) { $data[$key] = $value; } unset( $data['custom_fields'] ); } return $data; } function adfoin_pipedrive_person_exists( $email, $cred_id, $phone = '' ) { // Match on email first, then fall back to phone. Previously only email was // searched, so a person mapped without an email always created a duplicate // even with "Allow Duplicate Person" off. if ( $email ) { $person_id = adfoin_pipedrive_item_exists( 'persons/search', array( 'fields' => 'email', 'exact_match' => 'true', 'term' => $email ), $cred_id ); if ( $person_id ) { return $person_id; } } if ( $phone ) { $person_id = adfoin_pipedrive_item_exists( 'persons/search', array( 'fields' => 'phone', 'exact_match' => 'true', 'term' => $phone ), $cred_id ); if ( $person_id ) { return $person_id; } } return false; } function adfoin_pipedrive_organization_exists( $name, $cred_id ) { $endpoint = 'organizations/search'; $query_args = array( 'fields' => 'name', 'exact_match' => true, 'term' => $name ); $org_id = adfoin_pipedrive_item_exists( $endpoint, $query_args, $cred_id ); return $org_id; } function adfoin_pipedrive_item_exists( $endpoint, $query_args, $cred_id ) { $endpoint = add_query_arg( $query_args, $endpoint ); $response = adfoin_pipedrive_request( $endpoint, 'GET', array(), array(), $cred_id ); $response_code = wp_remote_retrieve_response_code( $response ); $item_id = ''; if( 200 == $response_code ) { $response_body = json_decode( wp_remote_retrieve_body( $response ), true ); if( isset( $response_body['data']['items'] ) && is_array( $response_body['data']['items'] ) ) { if( count( $response_body['data']['items'] ) > 0 ) { $item_id = $response_body['data']['items'][0]['item']['id']; } } } if( $item_id ) { return $item_id; } else{ return false; } }