Skip to content

Commit 83f4ae3

Browse files
mkholtClaude <noreply@anthropic.com> via Conducktor
andauthored
Add QualifyLead request handler (#343)
* Add QualifyLead request handler Implements QualifyLeadRequestHandler for the QualifyLead message: optionally creates account/contact/opportunity from the lead (copying the standard mapped fields), links the opportunity via originatingleadid, and closes the lead in the status/state supplied on the request. Attributes not present on the target entity's metadata are skipped with a warning rather than failing the request (Core now exposes its ILogger so handlers can surface such diagnostics). Lead metadata is absent from the leaned test environment, so it is supplied as a standalone LeadMetadata.xml (custom-publisher fields stripped), merged like the existing RemovedEntitiesMetadata.xml. Adds TestLeads covering creation, mapping, qualify/disqualify, and the validation/guard paths. Re-implements and builds on the original work by Niels Ladekarl (@Ladekarl) in #51, updated for the current codebase and test environment. Co-Authored-By: Claude <noreply@anthropic.com> via Conducktor <conducktor@contextand.com> * Address Copilot review on QualifyLead - Validate OpportunityCurrencyId references a transactioncurrency, throwing a clear FaultException early. (A non-existent currency already surfaced as a FaultException from the create pipeline via XrmDb.GetDbRow, not an NRE; added TestQualifyLeadNonexistentCurrencyThrowsFault to lock that in.) - Test capture logger's BeginScope now returns a NullScope singleton instead of null, matching the precedent in TestLogging.cs. Co-Authored-By: Claude <noreply@anthropic.com> via Conducktor <conducktor@contextand.com> --------- Co-authored-by: Claude <noreply@anthropic.com> via Conducktor <conducktor@contextand.com>
1 parent 0660c65 commit 83f4ae3

6 files changed

Lines changed: 795 additions & 0 deletions

File tree

RELEASE_NOTES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
### 1.18.7 - 7 July 2026
2+
* Add: `QualifyLead` request support. Qualifying a lead optionally creates an account, contact, and/or opportunity (copying the standard mapped fields from the lead), links the opportunity back via `originatingleadid`, and closes the lead in the status/state supplied on the request. Attributes not present on the target entity's metadata are skipped with a warning rather than failing the request.
3+
14
### 1.18.6 - 7 July 2026
25
* Add: Collect Plugin Trace Logs and handle invalid formatting strings correctly (by throwing formatting exceptions) (#341)
36

src/XrmMockup365/Core.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ internal class Core : IXrmMockupExtension, ICoreOperations
7070
private List<string> systemAttributeNames;
7171
internal FileBlockStore FileBlockStore { get; private set; }
7272

73+
/// <summary>Logger scoped to the mockup engine; used by request handlers to surface warnings.</summary>
74+
internal ILogger Logger { get; private set; }
75+
7376
/// <summary>
7477
/// Creates a new instance of Core
7578
/// </summary>
@@ -121,6 +124,7 @@ private void InitializeCore(CoreInitializationData initData)
121124
var sw = Stopwatch.StartNew();
122125
var loggerFactory = initData.LoggerFactory ?? NullLoggerFactory.Instance;
123126
var coreLogger = loggerFactory.CreateLogger(typeof(Core).FullName);
127+
Logger = coreLogger;
124128

125129
TimeOffset = new TimeSpan();
126130
settings = initData.Settings;
@@ -415,6 +419,7 @@ private void InitializeDB()
415419
new RevokeAccessRequestHandler(this, db, metadata, security),
416420
new WinOpportunityRequestHandler(this, db, metadata, security),
417421
new LoseOpportunityRequestHandler(this, db, metadata, security),
422+
new QualifyLeadRequestHandler(this, db, metadata, security),
418423
new RetrieveAllOptionSetsRequestHandler(this, db, metadata, security),
419424
new RetrieveOptionSetRequestHandler(this, db, metadata, security),
420425
new RetrieveExchangeRateRequestHandler(this, db, metadata, security),

src/XrmMockup365/Internal/Utility.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1159,6 +1159,7 @@ internal static class LogicalNames
11591159
public const string Workflow = "workflow";
11601160
public const string Team = "team";
11611161
public const string Contact = "contact";
1162+
public const string Account = "account";
11621163
public const string Lead = "lead";
11631164
public const string Opportunity = "opportunity";
11641165
public const string TeamMembership = "teammembership";
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.ServiceModel;
5+
using Microsoft.Crm.Sdk.Messages;
6+
using Microsoft.Extensions.Logging;
7+
using Microsoft.Xrm.Sdk;
8+
using Microsoft.Xrm.Sdk.Messages;
9+
using Microsoft.Xrm.Sdk.Metadata;
10+
using DG.Tools.XrmMockup.Database;
11+
using DG.Tools.XrmMockup.Internal;
12+
13+
namespace DG.Tools.XrmMockup
14+
{
15+
internal class QualifyLeadRequestHandler : RequestHandler
16+
{
17+
// Lead state codes (statecode option values).
18+
private const int LeadStateOpen = 0;
19+
private const int LeadStateQualified = 1;
20+
21+
// Maps a source attribute on the lead to the destination attribute on the created record.
22+
// Only attributes present on both the lead and the target's metadata are copied.
23+
private static readonly Dictionary<string, string> LeadToAccountAttributeMap = new Dictionary<string, string>
24+
{
25+
{ "sic", "sic" },
26+
{ "emailaddress1", "emailaddress1" },
27+
{ "companyname", "name" },
28+
{ "fax", "fax" },
29+
{ "websiteurl", "websiteurl" },
30+
{ "address1_country", "address1_country" },
31+
{ "address1_city", "address1_city" },
32+
{ "address1_line1", "address1_line1" },
33+
{ "address1_line2", "address1_line2" },
34+
{ "address1_line3", "address1_line3" },
35+
{ "address1_postalcode", "address1_postalcode" },
36+
{ "address1_stateorprovince", "address1_stateorprovince" },
37+
{ "telephone2", "telephone2" },
38+
{ "donotpostalmail", "donotpostalmail" },
39+
{ "donotphone", "donotphone" },
40+
{ "donotfax", "donotfax" },
41+
{ "donotsendmm", "donotsendmm" },
42+
{ "description", "description" },
43+
{ "donotemail", "donotemail" },
44+
{ "yomicompanyname", "yominame" },
45+
{ "donotbulkemail", "donotbulkemail" },
46+
};
47+
48+
private static readonly Dictionary<string, string> LeadToContactAttributeMap = new Dictionary<string, string>
49+
{
50+
{ "mobilephone", "mobilephone" },
51+
{ "emailaddress1", "emailaddress1" },
52+
{ "emailaddress2", "emailaddress2" },
53+
{ "emailaddress3", "emailaddress3" },
54+
{ "websiteurl", "websiteurl" },
55+
{ "yomifirstname", "yomifirstname" },
56+
{ "yomimiddlename", "yomimiddlename" },
57+
{ "yomilastname", "yomilastname" },
58+
{ "firstname", "firstname" },
59+
{ "lastname", "lastname" },
60+
{ "jobtitle", "jobtitle" },
61+
{ "pager", "pager" },
62+
{ "fax", "fax" },
63+
{ "telephone2", "telephone2" },
64+
{ "telephone3", "telephone3" },
65+
{ "description", "description" },
66+
{ "donotpostalmail", "donotpostalmail" },
67+
{ "donotphone", "donotphone" },
68+
{ "donotfax", "donotfax" },
69+
{ "donotemail", "donotemail" },
70+
{ "donotsendmm", "donotsendmm" },
71+
{ "donotbulkemail", "donotbulkemail" },
72+
{ "address1_country", "address1_country" },
73+
{ "address1_city", "address1_city" },
74+
{ "address1_line1", "address1_line1" },
75+
{ "address1_line2", "address1_line2" },
76+
{ "address1_line3", "address1_line3" },
77+
{ "address1_postalcode", "address1_postalcode" },
78+
{ "address1_stateorprovince", "address1_stateorprovince" },
79+
};
80+
81+
private static readonly Dictionary<string, string> LeadToOpportunityAttributeMap = new Dictionary<string, string>
82+
{
83+
{ "subject", "name" },
84+
{ "qualificationcomments", "qualificationcomments" },
85+
{ "description", "description" },
86+
};
87+
88+
internal QualifyLeadRequestHandler(Core core, XrmDb db, MetadataSkeleton metadata, Security security)
89+
: base(core, db, metadata, security, "QualifyLead") { }
90+
91+
internal override OrganizationResponse Execute(OrganizationRequest orgRequest, EntityReference userRef)
92+
{
93+
var request = MakeRequest<QualifyLeadRequest>(orgRequest);
94+
95+
if (request.LeadId == null)
96+
{
97+
throw new FaultException("Required field 'LeadId' is missing");
98+
}
99+
100+
// GetMetadata throws a descriptive fault if the entity is unknown to the metadata cache.
101+
var leadMetadata = metadata.EntityMetadata.GetMetadata(request.LeadId.LogicalName);
102+
if (request.OpportunityCurrencyId != null &&
103+
request.OpportunityCurrencyId.LogicalName != LogicalNames.TransactionCurrency)
104+
{
105+
throw new FaultException($"OpportunityCurrencyId must reference a '{LogicalNames.TransactionCurrency}'.");
106+
}
107+
if (request.OpportunityCustomerId != null)
108+
{
109+
metadata.EntityMetadata.GetMetadata(request.OpportunityCustomerId.LogicalName);
110+
}
111+
112+
var leadRow = db.GetDbRowOrNull(request.LeadId);
113+
if (leadRow == null || request.LeadId.LogicalName != LogicalNames.Lead)
114+
{
115+
throw new FaultException($"{LogicalNames.Lead} With Id = {request.LeadId.Id} Does Not Exist");
116+
}
117+
118+
var lead = leadRow.ToEntity();
119+
120+
// A lead can only be qualified/disqualified while it is still open.
121+
var currentState = lead.GetAttributeValue<OptionSetValue>("statecode");
122+
if (currentState != null && currentState.Value != LeadStateOpen)
123+
{
124+
throw new FaultException($"The {LogicalNames.Lead} is already closed.");
125+
}
126+
127+
if (request.Status == null)
128+
{
129+
throw new FaultException("Required field 'Status' is missing");
130+
}
131+
var status = request.Status.Value;
132+
133+
var statusOption = Utility.GetStatusOptionMetadata(leadMetadata)
134+
.FirstOrDefault(s => s.Value == status) as StatusOptionMetadata;
135+
if (statusOption == null)
136+
{
137+
throw new FaultException($"{status} is not a valid status code on {LogicalNames.Lead} with Id {request.LeadId.Id}");
138+
}
139+
140+
// The requested status determines the resulting state (Qualified/Disqualified/...).
141+
var state = statusOption.State ?? LeadStateQualified;
142+
143+
var createdEntities = new EntityReferenceCollection();
144+
145+
if (request.CreateAccount)
146+
{
147+
createdEntities.Add(CreateFromLead(lead, LogicalNames.Account, LeadToAccountAttributeMap, userRef));
148+
}
149+
if (request.CreateContact)
150+
{
151+
createdEntities.Add(CreateFromLead(lead, LogicalNames.Contact, LeadToContactAttributeMap, userRef));
152+
}
153+
if (request.CreateOpportunity)
154+
{
155+
createdEntities.Add(CreateOpportunityFromLead(lead, request.OpportunityCustomerId, request.OpportunityCurrencyId, userRef));
156+
}
157+
158+
// Close the lead in the requested state (mirrors SetStateRequestHandler).
159+
if (Utility.IsValidAttribute("statecode", leadMetadata) && Utility.IsValidAttribute("statuscode", leadMetadata))
160+
{
161+
var previous = lead.CloneEntity();
162+
lead["statecode"] = new OptionSetValue(state);
163+
lead["statuscode"] = new OptionSetValue(status);
164+
Utility.CheckStatusTransitions(leadMetadata, lead, previous);
165+
Utility.HandleCurrencies(metadata, db, lead);
166+
Utility.Touch(lead, leadMetadata, core.TimeOffset, userRef);
167+
db.Update(lead);
168+
}
169+
170+
var response = new QualifyLeadResponse();
171+
response.Results["CreatedEntities"] = createdEntities;
172+
return response;
173+
}
174+
175+
private EntityReference CreateFromLead(Entity lead, string targetLogicalName, IDictionary<string, string> attributeMap, EntityReference userRef)
176+
{
177+
var target = new Entity(targetLogicalName);
178+
MapAttributesFromLead(lead, target, attributeMap);
179+
SetIfValid(target, "originatingleadid", lead.ToEntityReference());
180+
target.Id = CreateEntity(target, userRef);
181+
return target.ToEntityReference();
182+
}
183+
184+
private EntityReference CreateOpportunityFromLead(Entity lead, EntityReference customer, EntityReference currency, EntityReference userRef)
185+
{
186+
if (customer != null &&
187+
customer.LogicalName != LogicalNames.Account &&
188+
customer.LogicalName != LogicalNames.Contact)
189+
{
190+
throw new FaultException($"CustomerIdType for {LogicalNames.Opportunity} can either be an {LogicalNames.Account} or {LogicalNames.Contact}");
191+
}
192+
193+
var opportunity = new Entity(LogicalNames.Opportunity);
194+
MapAttributesFromLead(lead, opportunity, LeadToOpportunityAttributeMap);
195+
SetIfValid(opportunity, "originatingleadid", lead.ToEntityReference());
196+
if (customer != null)
197+
{
198+
SetIfValid(opportunity, "customerid", customer);
199+
}
200+
if (currency != null)
201+
{
202+
SetIfValid(opportunity, "transactioncurrencyid", currency);
203+
}
204+
opportunity.Id = CreateEntity(opportunity, userRef);
205+
return opportunity.ToEntityReference();
206+
}
207+
208+
private void MapAttributesFromLead(Entity lead, Entity target, IDictionary<string, string> attributeMap)
209+
{
210+
var targetMetadata = metadata.EntityMetadata.GetMetadata(target.LogicalName);
211+
foreach (var mapping in attributeMap)
212+
{
213+
if (!lead.Attributes.Contains(mapping.Key))
214+
{
215+
continue;
216+
}
217+
if (!Utility.IsValidAttribute(mapping.Value, targetMetadata))
218+
{
219+
core.Logger?.LogWarning(
220+
"QualifyLead: skipped copying lead.{Source} to {Target}.{Destination} because the target entity's metadata does not define that attribute.",
221+
mapping.Key, target.LogicalName, mapping.Value);
222+
continue;
223+
}
224+
target[mapping.Value] = lead[mapping.Key];
225+
}
226+
}
227+
228+
private void SetIfValid(Entity target, string attribute, object value)
229+
{
230+
var targetMetadata = metadata.EntityMetadata.GetMetadata(target.LogicalName);
231+
if (Utility.IsValidAttribute(attribute, targetMetadata))
232+
{
233+
target[attribute] = value;
234+
}
235+
else
236+
{
237+
core.Logger?.LogWarning(
238+
"QualifyLead: skipped setting {Target}.{Attribute} because the target entity's metadata does not define that attribute.",
239+
target.LogicalName, attribute);
240+
}
241+
}
242+
243+
private Guid CreateEntity(Entity entity, EntityReference userRef)
244+
{
245+
var response = core.Execute(new CreateRequest { Target = entity }, userRef) as CreateResponse;
246+
return response.id;
247+
}
248+
}
249+
}

tests/XrmMockup365Test/Metadata/LeadMetadata.xml

Lines changed: 212 additions & 0 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)