feat: fallback on phone number to update lead (#13910)
When syncing contacts to LeadSquared, the `Lead.CreateOrUpdate` API defaults to searching by email. If a contact has no email (or a different email) but a phone number matching an existing lead, the API fails with `MXDuplicateEntryException` instead of finding and updating the existing lead. This accounted for ~69% of all LeadSquared integration errors, and cascaded into "Lead not found" failures when posting transcript and conversation activities (~14% of errors). ## What changed - `LeadClient#create_or_update_lead` now catches `MXDuplicateEntryException` and retries the request once with `SearchBy=Phone` appended to the body, telling the API to match on phone number instead - Once the retry succeeds, the returned lead ID is stored on the contact (existing behavior), so all future events use the direct `update_lead` path and never hit the duplicate error again ## How to reproduce 1. Create a lead in LeadSquared with phone number `+91-75076767676` and email `a@example.com` 2. In Chatwoot, create a contact with the same phone number but a different email (or no email) 3. Trigger a contact sync (via conversation creation or contact update) 4. Before fix: `MXDuplicateEntryException` error in logs, contact fails to sync 5. After fix: retry with `SearchBy=Phone` finds and updates the existing lead, stores the lead ID on the contact
This commit is contained in:
@@ -12,16 +12,21 @@ class Crm::Leadsquared::Api::LeadClient < Crm::Leadsquared::Api::BaseClient
|
||||
# https://apidocs.leadsquared.com/create-or-update/#api
|
||||
# The email address and phone fields are used as the default search criteria.
|
||||
# If none of these match with an existing lead, a new lead will be created.
|
||||
# We can pass the "SearchBy" attribute in the JSON body to search by a particular parameter, however
|
||||
# we don't need this capability at the moment
|
||||
# We pass the "SearchBy" attribute with value "Phone" when a MXDuplicateEntryException
|
||||
# occurs, indicating a duplicate mobile number match that the default search missed.
|
||||
def create_or_update_lead(lead_data)
|
||||
raise ArgumentError, 'Lead data is required' if lead_data.blank?
|
||||
|
||||
path = 'LeadManagement.svc/Lead.CreateOrUpdate'
|
||||
|
||||
formatted_data = format_lead_data(lead_data)
|
||||
response = post(path, {}, formatted_data)
|
||||
|
||||
response = post(path, {}, formatted_data)
|
||||
response['Message']['Id']
|
||||
rescue ApiError => e
|
||||
raise unless duplicate_phone_error?(e) && lead_data.key?('Mobile')
|
||||
|
||||
Rails.logger.warn 'LeadSquared duplicate phone detected, retrying with SearchBy=Phone'
|
||||
response = post(path, {}, formatted_data + [{ 'Attribute' => 'SearchBy', 'Value' => 'Phone' }])
|
||||
response['Message']['Id']
|
||||
end
|
||||
|
||||
@@ -47,4 +52,13 @@ class Crm::Leadsquared::Api::LeadClient < Crm::Leadsquared::Api::BaseClient
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def duplicate_phone_error?(error)
|
||||
return false if error.response.blank?
|
||||
|
||||
parsed = error.response.parsed_response
|
||||
parsed.is_a?(Hash) && parsed['ExceptionType'] == 'MXDuplicateEntryException'
|
||||
rescue StandardError
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -153,6 +153,131 @@ RSpec.describe Crm::Leadsquared::Api::LeadClient do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when request fails with MXDuplicateEntryException and lead has Mobile' do
|
||||
let(:lead_data) do
|
||||
{
|
||||
'FirstName' => 'John',
|
||||
'EmailAddress' => 'john.doe@example.com',
|
||||
'Mobile' => '+91-7507684392'
|
||||
}
|
||||
end
|
||||
let(:formatted_lead_data) do
|
||||
lead_data.map { |key, value| { 'Attribute' => key, 'Value' => value } }
|
||||
end
|
||||
let(:formatted_lead_data_with_search_by) do
|
||||
formatted_lead_data + [{ 'Attribute' => 'SearchBy', 'Value' => 'Phone' }]
|
||||
end
|
||||
let(:lead_id) { SecureRandom.uuid }
|
||||
let(:duplicate_error_response) do
|
||||
{
|
||||
'Status' => 'Error',
|
||||
'ExceptionType' => 'MXDuplicateEntryException',
|
||||
'ExceptionMessage' => 'A Lead with same Primary Mobile Number already exists.',
|
||||
'IsMXException' => true
|
||||
}
|
||||
end
|
||||
let(:success_response) do
|
||||
{ 'Status' => 'Success', 'Message' => { 'Id' => lead_id } }
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:post, full_url)
|
||||
.with(body: formatted_lead_data.to_json, headers: headers)
|
||||
.to_return(status: 500, body: duplicate_error_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
stub_request(:post, full_url)
|
||||
.with(body: formatted_lead_data_with_search_by.to_json, headers: headers)
|
||||
.to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'retries with SearchBy=Phone and returns lead ID' do
|
||||
expect(client.create_or_update_lead(lead_data)).to eq(lead_id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when request fails with MXDuplicateEntryException but lead has no Mobile' do
|
||||
let(:duplicate_error_response) do
|
||||
{
|
||||
'Status' => 'Error',
|
||||
'ExceptionType' => 'MXDuplicateEntryException',
|
||||
'ExceptionMessage' => 'A Lead with same Primary Mobile Number already exists.',
|
||||
'IsMXException' => true
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:post, full_url)
|
||||
.with(body: formatted_lead_data.to_json, headers: headers)
|
||||
.to_return(status: 500, body: duplicate_error_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'raises ApiError without retrying' do
|
||||
expect { client.create_or_update_lead(lead_data) }
|
||||
.to(raise_error { |error| expect(error.class.name).to eq('Crm::Leadsquared::Api::BaseClient::ApiError') })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when request fails with a non-duplicate 500 error' do
|
||||
let(:lead_data) do
|
||||
{ 'FirstName' => 'John', 'Mobile' => '+91-7507684392' }
|
||||
end
|
||||
let(:formatted_lead_data) do
|
||||
lead_data.map { |key, value| { 'Attribute' => key, 'Value' => value } }
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:post, full_url)
|
||||
.with(body: formatted_lead_data.to_json, headers: headers)
|
||||
.to_return(
|
||||
status: 500,
|
||||
body: { 'Status' => 'Error', 'ExceptionType' => 'SomeOtherException', 'ExceptionMessage' => 'Something else' }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises ApiError without retrying' do
|
||||
expect { client.create_or_update_lead(lead_data) }
|
||||
.to(raise_error { |error| expect(error.class.name).to eq('Crm::Leadsquared::Api::BaseClient::ApiError') })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when retry with SearchBy=Phone also fails' do
|
||||
let(:lead_data) do
|
||||
{ 'FirstName' => 'John', 'Mobile' => '+91-7507684392' }
|
||||
end
|
||||
let(:formatted_lead_data) do
|
||||
lead_data.map { |key, value| { 'Attribute' => key, 'Value' => value } }
|
||||
end
|
||||
let(:formatted_lead_data_with_search_by) do
|
||||
formatted_lead_data + [{ 'Attribute' => 'SearchBy', 'Value' => 'Phone' }]
|
||||
end
|
||||
let(:duplicate_error_response) do
|
||||
{
|
||||
'Status' => 'Error',
|
||||
'ExceptionType' => 'MXDuplicateEntryException',
|
||||
'ExceptionMessage' => 'A Lead with same Primary Mobile Number already exists.',
|
||||
'IsMXException' => true
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:post, full_url)
|
||||
.with(body: formatted_lead_data.to_json, headers: headers)
|
||||
.to_return(status: 500, body: duplicate_error_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
stub_request(:post, full_url)
|
||||
.with(body: formatted_lead_data_with_search_by.to_json, headers: headers)
|
||||
.to_return(
|
||||
status: 500,
|
||||
body: { 'Status' => 'Error', 'ExceptionMessage' => 'Still failing' }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises ApiError from the retry attempt' do
|
||||
expect { client.create_or_update_lead(lead_data) }
|
||||
.to(raise_error { |error| expect(error.class.name).to eq('Crm::Leadsquared::Api::BaseClient::ApiError') })
|
||||
end
|
||||
end
|
||||
|
||||
# Add test for update_lead method
|
||||
describe '#update_lead' do
|
||||
let(:path) { 'LeadManagement.svc/Lead.Update' }
|
||||
|
||||
Reference in New Issue
Block a user