Utility Methods Library
The Utils class provides a comprehensive set of reusable methods for common plugin operations. These methods handle configuration retrieval, entity validation, web resource loading, and business logic orchestration across multiple plugins.
Assignment & Web Role Management
GetAssignmentConfiguration()
Retrieves the assignment-to-web-role configuration stored in the tt_configuration entity as JSON.
Purpose: Loads the "Assignments.json" configuration that maps Assignment names to Web Roles.
Parameters:
service- Organization service for CRM queriestracingService- Tracing service for logging
Returns: Dictionary<string>, Dictionary<string>, List<string> containing the parsed JSON configuration, or null if not found.
Used By:
Example:
var config = utils.GetAssignmentConfiguration(service, tracingService);
// Returns: { "Business Tax - Data Provider": { "Web Roles": ["Role1", "Role2"] } }
GetWebRoleByName()
Retrieves a web role entity by its name from the adx_webrole entity.
Purpose: Looks up a Web Role record by exact name match.
Parameters:
service- Organization servicewebRoleName- Name of the web role to find
Returns: Entity object for the web role, or null if not found.
Used By:
IsContactAssociatedWithWebRole()
Checks whether a contact is already associated with a specific web role.
Purpose: Prevents duplicate associations by checking the adx_webrole_contact intersection table.
Parameters:
service- Organization servicecontactId- GUID of the contactwebRoleId- GUID of the web role
Returns: True if association exists, false otherwise.
Used By:
ValidateContactAssignmentRelationship()
Validates that the plugin context contains a valid tt_Contact_tt_Assignment relationship and extracts the contact and assignment references.
Purpose: Ensures the Associate/Disassociate action is on the correct relationship with correct entity types.
Parameters:
context- Plugin execution contexttracingService- Tracing servicecontact(out) - Contact EntityReference extracted from Targetassignment(out) - Assignment EntityReference extracted from RelatedEntities
Returns: True if validation passes, false otherwise.
Used By:
GetWebRolesForAssignment()
Retrieves the list of web roles configured for a given assignment name from the configuration dictionary.
Purpose: Extracts the "Web Roles" array for a specific Assignment from the parsed configuration.
Parameters:
config- Configuration dictionary (from GetAssignmentConfiguration)assignmentName- Name of the assignment to look uptracingService- Tracing service
Returns: List<string> of web role names, or empty list if not found.
Used By:
Document Approval Utility Methods
GetOptionSetLabel()
Retrieves the label for an option set value. Handles both standard picklists and status attributes.
Purpose: Converts numeric option set values to their human-readable labels using metadata.
Parameters:
entityName- Logical name of the entityattributeName- Logical name of the attributeoptionSetValue- Numeric value to look up
Returns: Label string, or "Unknown (value)" if not found.
Used By:
- DocumentApprovalDocumentSignersPostOperationCreate
- DocumentApprovalDocumentSignersPostOperationUpdate
GetServiceLineLabel()
Retrieves the service line label from a document approval record.
Purpose: Convenience wrapper around GetOptionSetLabel specifically for the tt_serviceline field.
Parameters:
documentApproval- Document approval entity containing tt_serviceline
Returns: Service line label string (e.g., "Audit", "UK Tax").
Throws: InvalidPluginExecutionException if service line cannot be retrieved.
Used By:
- DocumentApprovalDocumentSignersPostOperationCreate
- DocumentApprovalDocumentSignersPostOperationUpdate
GetRoleFilterForServiceLine()
Determines the role name filter and operator type based on the service line.
Purpose: Maps service lines to their corresponding Data Approver role patterns for FetchXML queries.
Parameters:
serviceLineLabel- Service line label (e.g., "General", "Funds Tax")
Returns: Tuple containing:
roleNameFilter- Pattern to match (e.g., "%Data Approver%", "CTG Tax - Data Approver")operatorType- FetchXML operator ("like" or "eq")
Special Cases:
- "General" → "%Data Approver%" with "like" operator
- "Funds Tax" / "CTG Tax" → "CTG Tax - Data Approver" with "eq" operator
- "Research And Development" → "RandD - Data Approver" with "eq" operator
- "UK Tax" → "Data Approver" with "eq" operator
- Default → "
{ServiceLine}- Data Approver" with "eq" operator
Used By:
- DocumentApprovalDocumentSignersPostOperationCreate
- DocumentApprovalDocumentSignersPostOperationUpdate
RetrieveConnectedContacts()
Retrieves connected contacts with appropriate Data Approver roles using FetchXML.
Purpose: Finds contacts who have connections with the required Data Approver roles for the document approval.
Parameters:
contactId- Contact ID from the document approvaldocumentApprovalId- Document approval IDroleNameFilter- Role name pattern to matchoperatorType- FetchXML operator ("like" or "eq")
Returns: EntityCollection of contacts with fullname and contactid attributes.
Used By:
- DocumentApprovalDocumentSignersPostOperationCreate
- DocumentApprovalDocumentSignersPostOperationUpdate
DeleteExistingSigners()
Deletes all existing signer records for the document approval.
Purpose: Clears out old signers before creating new ones (e.g., when Service Line changes).
Parameters:
documentApprovalId- Document approval ID
Returns: Void
Behavior: Continues deleting even if some deletions fail. Logs counts of successful/failed deletions.
Used By:
- DocumentApprovalDocumentSignersPostOperationCreate
- DocumentApprovalDocumentSignersPostOperationUpdate
CreateSignerRecords()
Creates signer records for each connected contact.
Purpose: Generates tt_documentapprovaldocumentsigners records linking contacts to the document approval.
Parameters:
documentApprovalId- Document approval IDconnectedContacts- EntityCollection of contacts to create signers for
Returns: Void
Behavior: Continues creating even if some creations fail. Logs counts of successful/failed creations.
Used By:
- DocumentApprovalDocumentSignersPostOperationCreate
- DocumentApprovalDocumentSignersPostOperationUpdate
ValidateAndGetTarget()
Validates the plugin execution context and returns the target entity. Checks entity type, message type, and depth to prevent infinite loops.
Purpose: Standard validation for plugin entry point. Ensures correct entity, message, and prevents runaway plugins.
Parameters:
context- Plugin execution contextexpectedEntityName- Expected entity logical nameexpectedMessage- Expected message name (e.g., "Create", "Update")maxDepth- Maximum allowed plugin depth (default: 10)
Returns: Validated target entity.
Throws: InvalidPluginExecutionException if validation fails.
Used By:
- TaxFormSectionsPostOperationCreate
- DocumentApprovalDocumentSignersPostOperationCreate
- DocumentApprovalDocumentSignersPostOperationUpdate
RetrieveDocumentApproval()
Retrieves a document approval record with specified columns. Always retrieves core columns (tt_contactid, tt_serviceline) plus any additional columns specified.
Purpose: Standard retrieval pattern for document approval records with flexible column selection.
Parameters:
documentApprovalId- ID of the document approval recordadditionalColumns- Optional additional columns to retrieve (params array)
Returns: Document approval entity with requested attributes.
Used By:
- DocumentApprovalDocumentSignersPostOperationCreate
- DocumentApprovalDocumentSignersPostOperationUpdate
GetContactIdFromDocumentApproval()
Extracts and validates the contact ID from a document approval record.
Purpose: Safely retrieves the tt_contactid lookup field with null checking.
Parameters:
documentApproval- Document approval entity
Returns: Contact GUID.
Throws: InvalidPluginExecutionException if contact ID is missing or null.
Used By:
- DocumentApprovalDocumentSignersPostOperationCreate
- DocumentApprovalDocumentSignersPostOperationUpdate
ValidateServiceLine()
Validates that the document approval has a service line value.
Purpose: Checks if tt_serviceline field is present and has a valid value.
Parameters:
documentApproval- Document approval entity
Returns: True if service line is present and valid, false otherwise.
Used By:
- DocumentApprovalDocumentSignersPostOperationCreate
- DocumentApprovalDocumentSignersPostOperationUpdate
IsDocumentApprovalInDraft()
Checks if a document approval is in Draft status.
Purpose: Determines if signers can be modified (only allowed in Draft status).
Parameters:
documentApproval- Document approval entity (must contain statuscode attribute)draftStatusCode- The status code value representing Draft status (default: 206340002)
Returns: True if status is Draft, false otherwise.
Used By:
UpdateSignersForDocumentApproval()
Orchestrates the complete signer update process: retrieves contacts, deletes old signers (if requested), creates new signers. Provides transactional-like behavior with detailed error reporting.
Purpose: High-level orchestration method that combines multiple operations into a single workflow.
Parameters:
documentApprovalId- Document approval IDcontactId- Contact ID to find connections forserviceLineLabel- Service line label to determine role filterdeleteExisting- Whether to delete existing signers before creating new ones
Returns: Number of signers successfully created.
Used By:
- DocumentApprovalDocumentSignersPostOperationCreate
- DocumentApprovalDocumentSignersPostOperationUpdate
Workflow:
- Determine role filter based on service line
- Optionally delete existing signers
- Retrieve connected contacts
- Create signer records
- Return count of signers created
GetBooleanFieldValue()
Checks if a Yes/No (Two Options/Boolean) field is set to "No" (false).
Purpose: Safely retrieves boolean field values with null handling. Returns inverse of the boolean value (true = "No", false = "Yes").
Parameters:
entity- The entity containing the fieldattributeName- The logical name of the Yes/No field
Returns: True if the field is "No" (false), false if "Yes" (true) or not set.
Note: In Dynamics, Two Options fields: true = Yes, false = No. This method returns the inverted value for readability (checking if it's "No").
Used By:
- DocumentApprovalDocumentSignersPostOperationCreate
- DocumentApprovalDocumentSignersPostOperationUpdate
Tax Form Configuration Utility Methods
GetTaxFormConfiguration()
Retrieves a Tax Form configuration record based on configuration type and tax form type.
Purpose: Loads configuration records from tt_configuration entity that specify which sections to populate on tax forms.
Parameters:
service- Organization serviceconfigurationType- Configuration Type option set valuetaxFormType- Tax Form Type option set value
Returns: Configuration entity with tt_name, tt_webresource, and tt_sections attributes, or null if not found.
Used By:
Query Criteria:
- Active records only (statecode = 0)
- Matching configuration type
- Matching tax form type
ParseSectionIds()
Parses the sections string from a configuration entity into an array of section IDs. Handles comma and semicolon delimiters.
Purpose: Converts delimited section strings (e.g., "ukInterest, ukDividends, employment") into clean arrays.
Parameters:
configuration- Configuration entity containing tt_sections attributetracingService- Tracing service for logging
Returns: Array of trimmed section IDs, or empty array if sections field is empty.
Used By:
Example:
// Input: "ukInterest, ukDividends; employment"
// Output: ["ukInterest", "ukDividends", "employment"]
GetWebResourceName()
Retrieves and normalizes the web resource name from a configuration entity. Removes leading slashes and "WebResources/" prefix if present.
Purpose: Ensures web resource names are in the correct format for querying, regardless of how they're stored in configuration.
Parameters:
configuration- Configuration entity containing tt_webresource attributetracingService- Tracing service for logging
Returns: Normalized web resource name, or null if not found.
Used By:
Normalization Examples:
- "/tt_taxformsections.js" → "tt_taxformsections.js"
- "/WebResources/tt_taxformsections.js" → "tt_taxformsections.js"
- "tt_taxformsections.js" → "tt_taxformsections.js"
LoadWebResourceContent()
Loads web resource content directly from Dynamics using SDK (no HTTP calls required). Content is decoded from base64 encoding.
Purpose: Retrieves web resource files from the database and decodes them for processing.
Parameters:
webResourceName- Name of the web resourceservice- Organization servicetracingService- Tracing service for logging
Returns: Decoded web resource content as UTF-8 string.
Throws: InvalidPluginExecutionException if web resource not found or has no content.
Used By:
ParseJavaScriptToJson()
Parses JavaScript content to extract a JSON object assigned to a variable. Handles format: var VariableName = { ... }
Purpose: Extracts JSON data from JavaScript files that use variable assignment syntax.
Parameters:
jsContent- JavaScript content containing the variable assignmentvariableName- Name of the JavaScript variable (e.g., "TaxFormSections")tracingService- Tracing service for logging
Returns: Parsed JObject containing the JSON data.
Throws: InvalidPluginExecutionException if JSON cannot be extracted or parsed.
Used By:
Expected Format:
var TaxFormSections = {
sections: [...]
};
FindSectionById()
Finds a section by its ID in a JSON sections array.
Purpose: Searches for a specific section object within a JArray by matching the "id" property.
Parameters:
sections- JSON array of section objectssectionId- ID of the section to findtracingService- Tracing service for logging
Returns: Matching JObject section, or null if not found.
Used By:
PopulateTaxFormSections()
Populates Tax Form section fields with matching sections from the JSON array. Creates an update entity with all matched sections and performs a single update.
Purpose: Orchestrates the final step of populating tt_section1, tt_section2, etc. fields on the tax form.
Parameters:
taxFormId- ID of the Tax Form to updatesectionIds- Array of section IDs to populatesectionsArray- JSON array containing all available sectionsservice- Organization servicetracingService- Tracing service for logging
Returns: Number of sections successfully populated.
Used By:
Workflow:
- Iterate through section IDs
- Find matching section in JSON array
- Add section JSON to tt_section
{N}field - Perform single update with all sections
Other Utility Methods
The following methods are included in Utils.cs but are not currently used by the documented plugins:
GetEnvironmentVariableValueString()- Retrieves environment variable valuesTaxReturnChangeStatus()- Cascades tax return status to child recordsRemoveWebroleWhenConnectionDeleted()- Removes web roles when connections are deletedUpdateCompaniesHouseSkipStage()- Updates Companies House Data skip stageRetrieveSpecificRiskAssessmentOptionSetValueFromLabel()- Maps labels to option set valuesUpdateSpecificRiskAssessmentIncludeMLROStage()- Updates MLRO stage inclusionUpdateSpecificRiskAssessmentTriggerRiskRatingHandler()- Triggers risk rating handler
Code
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Query;
using Microsoft.Crm.Sdk.Messages;
using Entities;
using Newtonsoft.Json;
using Microsoft.Xrm.Sdk.Messages;
using Microsoft.Xrm.Sdk.Metadata;
using Newtonsoft.Json.Linq;
namespace Utils
{
public class Utils
{
//private member variables
private ITracingService tracingService;
private IOrganizationService crmService;
//Constructor
public Utils(IOrganizationService crmService, ITracingService tracingService)
{
this.crmService = crmService;
this.tracingService = tracingService;
}
//Public Methods
public string GetEnvironmentVariableValueString(string schemanameEnvVarDefinition)
{
tracingService.Trace("Calling GetEnvironmentVariableValueString");
// Retrieve Environment Variable Definition (i.e. default)
QueryExpression queryEnvVarDefinition = new QueryExpression("environmentvariabledefinition")
{
ColumnSet = new ColumnSet("defaultvalue", "environmentvariabledefinitionid")
};
queryEnvVarDefinition.Criteria.AddCondition("schemaname", ConditionOperator.Equal, schemanameEnvVarDefinition);
EntityCollection collectionEnvVarDefinition = crmService.RetrieveMultiple(queryEnvVarDefinition);
if (collectionEnvVarDefinition.Entities.Count != 1)
{
throw new Exception("Environment variable not found.");
}
Entity entityEnvVarDefinition = collectionEnvVarDefinition.Entities[0];
string defaultvalueEnvVarDefinition = entityEnvVarDefinition.GetAttributeValue<string>("defaultvalue");
// Retrieve Environment Variable Value (i.e. current)
QueryExpression queryEnvVarCurrentValue = new QueryExpression("environmentvariablevalue")
{
ColumnSet = new ColumnSet("value")
};
queryEnvVarCurrentValue.Criteria.AddCondition("environmentvariabledefinitionid", ConditionOperator.Equal, entityEnvVarDefinition.Id);
EntityCollection collectionEnvVarCurrentValue = crmService.RetrieveMultiple(queryEnvVarCurrentValue);
if (collectionEnvVarCurrentValue.Entities.Count != 1)
{
// Return current value if it exists, otherwise return default value
return defaultvalueEnvVarDefinition;
}
Entity entityEnvVarCurrentValue = collectionEnvVarCurrentValue.Entities[0];
string valueEnvVarCurrentValue = entityEnvVarCurrentValue.GetAttributeValue<string>("value");
return valueEnvVarCurrentValue;
}
public void TaxReturnChangeStatus(EntityReference taxreturn, OptionSetValue state)
{
//Cascade tax return status to it's child records
string[] TaxReturnSchedules =
{
"tt_bankinterest",
"tt_capitalgain",
"tt_employment",
"tt_giftaid",
"tt_pensionincome",
"tt_personalpension",
"tt_propertyincome",
"tt_businesstax",
"tt_ukdividend"
};
foreach(string TaxReturnSchedule in TaxReturnSchedules)
{
tracingService.Trace($"SetState for {TaxReturnSchedule} for TaxReturn {taxreturn.Id}");
QueryExpression qe = new QueryExpression()
{
EntityName = TaxReturnSchedule
};
qe.Criteria.AddCondition("tt_taxreturnid", ConditionOperator.Equal, taxreturn.Id);
EntityCollection ScheduleRecords = crmService.RetrieveMultiple(qe);
foreach (Entity ScheduleRecord in ScheduleRecords.Entities)
{
SetStateRequest req = new SetStateRequest()
{
EntityMoniker = ScheduleRecord.ToEntityReference(),
State = state,
Status = new OptionSetValue(state.Value == 0 ? 1 : 2)
};
crmService.Execute(req);
}
tracingService.Trace($"{ScheduleRecords.Entities.Count} record(s) updated.");
}
}
public void RemoveWebroleWhenConnectionDeleted(Entity image) //PGF-918
{
tracingService.Trace("Entering RemoveWebroleWhenConnectionDeleted()");
Connection c = image.ToEntity<Connection>();
tracingService.Trace($"c = {JsonConvert.SerializeObject(c, Formatting.Indented)}");
if ((bool)c.IsMaster)
{
tracingService.Trace("This is the Master connection");
string thisRole = c.Record2RoleId.Name;
tracingService.Trace($"ConnectionRole: {thisRole}");
if(new List<string> { "Accounts - Data Approver", "Accounts - Data Provider",
"Accounts - Documents Only", "Accounts - Read Only",
"Audit - Data Approver", "Audit - Data Provider", "Audit - Documents Only", "Audit - Primary Contact", "Audit - Read Only", "Business Advisory - Basic User",
"Business Advisory - Data Approver", "Business Advisory - Data Provider", "Business Advisory - Documents Only",
"Business Advisory - Primary Contact", "Business Advisory - Read Only", "Business Tax - Data Approver",
"Business Tax - Data Provider", "Business Tax - Documents Only", "Business Tax - Read Only", "Calendar - Data Approver", "Calendar - Data Provider",
"Calendar - Read Only", "Champion", "Child", "Colleague", "Component", "Connected IoT Device", "Corporate Finance - Basic User", "Corporate Finance - Corporate Contact",
"Corporate Finance - Data Approver", "Corporate Finance - Data Provider", "Corporate Finance - Documents Only", "Corporate Finance - Primary Contact",
"Corporate Finance - Read Only", "Dashboard - Corporates", "Dashboard - Corporation", "Dashboard - Employer Solutions", "Dashboard - R&D", "Data Approver", "Data Provider",
"Digital - Data Approver", "Digital - Data Provider",
"Digital - Documents Only", "Digital - Primary Contact", "Digital - Read Only", "Employment Tax - Data Approver",
"Employment Tax - Data Provider", "Employment Tax - Documents Only", "Employment Tax - Read Only",
"Funds Tax - Data Approver", "Funds Tax - Data Provider",
"Funds Tax - Documents Only", "Funds Tax - Read Only",
"Ireland Business Tax - Data Approver", "Ireland Business Tax - Data Provider", "Ireland Business Tax - Documents Only",
"Ireland Business Tax - Primary Contact", "Ireland Business Tax - Read Only", "Ireland Tax - Basic User", "Ireland Tax - Data Approver", "Ireland Tax - Data Provider",
"Ireland Tax - Documents Only", "Ireland Tax - Read Only", "Organisation - Data Approver",
"Organisation - Data Provider", "Organisation - Documents Only", "Organisation - Primary Contact", "Organisation - Read Only",
"Partnership - Basic User", "Partnership - Data Approver", "Partnership - Data Provider", "Partnership - Documents Only",
"Partnership - Primary Contact", "Partnership - Read Only", "Payroll - Basic User", "Payroll - Data Approver", "Payroll - Data Provider", "Payroll - Documents Only",
"Payroll - Primary Contact", "Payroll - Read Only", "RandD - Data Approver",
"RandD - Data Provider", "RandD - Documents Only", "RandD - Primary Contact", "RandD - Read Only", "Risk And Assurance - Basic User", "Risk And Assurance - Corporate Contact", "Risk And Assurance - Data Approver", "Risk And Assurance - Data Provider",
"Risk And Assurance - Documents Only", "Risk And Assurance - Primary Contact", "Risk And Assurance - Read Only",
"Trust - Data Approver", "Trust - Data Provider", "Trust - Documents Only", "Trust - Lead Trustee", "Trust - Read Only",
"US Tax - Data Approver", "US Tax - Data Provider", "US Tax - US Tax Contact",
"Virtual Finance Office - Data Approver", "Virtual Finance Office - Data Provider", "Virtual Finance Office - Documents Only",
"Virtual Finance Office - Read Only"}.Contains(thisRole))
{
//Does the contact have other connections with this role?
QueryExpression qryConnection = new QueryExpression("connection");
qryConnection.Criteria.AddCondition("record1id", ConditionOperator.Equal, c.Record1Id.Id);
qryConnection.Criteria.AddCondition("record2roleid", ConditionOperator.Equal, c.Record2RoleId.Id);
if(crmService.RetrieveMultiple(qryConnection).Entities.Count == 0)
{
//No other connections - we can disassociate the webrole
tracingService.Trace($"No other connections found from {c.Record1Id.Name} as role {c.Record2RoleId.Name}. Ok to Disassociate.");
//Find the webrole with same name as connection role
QueryExpression qryWebrole = new QueryExpression("adx_webrole");
qryWebrole.Criteria.AddCondition("adx_name", ConditionOperator.Equal, thisRole);
EntityCollection ec = crmService.RetrieveMultiple(qryWebrole);
if (ec.Entities.Count != 1)
{
tracingService.Trace($"Found {ec.Entities.Count} matching webroles - can't Disassociate");
return;
}
//Disassociate webrole from contact
tracingService.Trace($"Found webrole to Disassociate: contactid = {c.Record1Id.Id}, adx_webroleid = {ec.Entities[0].Id}");
EntityReferenceCollection relatedEntities = new EntityReferenceCollection();
relatedEntities.Add(new EntityReference("adx_webrole", ec.Entities[0].Id));
crmService.Disassociate("contact", c.Record1Id.Id,
new Relationship("adx_webrole_contact"),
relatedEntities);
tracingService.Trace("Disassociate complete");
}
}
}
}
public void UpdateCompaniesHouseSkipStage(Entity postImage)
{
// TODO: refactor to remove image and pass in params instead
// Ensure post-image entity is valid
if (postImage == null)
{
throw new ArgumentNullException("postImage cannot be null");
}
tracingService.Trace("Processing post-image for Specific Risk Assessment");
// Retrieve Contact record associated with Specific Risk Assessment
if (!postImage.Contains("tt_contact"))
{
tracingService.Trace("Post-image does not contain 'tt_contact'. Exiting method.");
return;
}
EntityReference contactRef = postImage.GetAttributeValue<EntityReference>("tt_contact");
if (contactRef == null)
{
tracingService.Trace("tt_contact lookup is null. Exiting method.");
return;
}
tracingService.Trace($"Found Contact reference: {contactRef.Id}");
// Retrieve most recently created Companies House Data record associated with Contact
QueryExpression companiesHouseQuery = new QueryExpression("tt_companieshousedata")
{
ColumnSet = new ColumnSet("tt_companieshousedataid", "tt_kycforentitiesskipukbusinesssearchstage"),
Orders = { new OrderExpression("createdon", OrderType.Descending) }
};
companiesHouseQuery.Criteria.AddCondition("tt_contact", ConditionOperator.Equal, contactRef.Id);
EntityCollection companiesHouseRecords = crmService.RetrieveMultiple(companiesHouseQuery);
if (!companiesHouseRecords.Entities.Any())
{
tracingService.Trace("No Companies House Data records found for this Contact.");
return;
}
Entity latestCompaniesHouseRecord = companiesHouseRecords.Entities.First();
tracingService.Trace($"Updating Companies House Data record {latestCompaniesHouseRecord.Id}");
// Retrieve "skip stage" flag from post-image entity
if (!postImage.Contains("tt_kycforentitiesskipukbusinesssearchstage"))
{
tracingService.Trace("Post-image does not contain 'tt_kycforentitiesskipukbusinesssearchstage'. Exiting method.");
return;
}
bool skipStage = postImage.GetAttributeValue<bool>("tt_kycforentitiesskipukbusinesssearchstage");
// Update "tt_kycforentitiesskipukbusinesssearchstage" in Companies House Data record
Entity updateEntity = new Entity("tt_companieshousedata", latestCompaniesHouseRecord.Id)
{
["tt_kycforentitiesskipukbusinesssearchstage"] = skipStage
};
crmService.Update(updateEntity);
tracingService.Trace("Companies House Data record updated successfully.");
}
public int RetrieveSpecificRiskAssessmentOptionSetValueFromLabel(string targetStageSetting)
{
//TODO: remove hardcoding. use a RetrieveAttribute request and get the mapping for an input option set, then rename method accordingly
tracingService.Trace("Calling RetrieveSpecificRiskAssessmentOptionSetValueFromLabel");
// Return if no change required
Dictionary<string, int> stageMapping = new Dictionary<string, int>
{
{ "Risk routing", 206340000 },
{ "Include", 206340001 },
{ "Exclude", 206340002 }
};
if (!stageMapping.TryGetValue(targetStageSetting, out int expectedValue))
{
throw new Exception("No match in option set mapping. Exiting method.");
}
tracingService.Trace("Completed RetrieveSpecificRiskAssessmentOptionSetValueFromLabel");
return expectedValue;
}
public bool UpdateSpecificRiskAssessmentIncludeMLROStage(EntityReference specificRiskAssessment, string messageName, int mLROStageInclusion, int riskRating)
{
tracingService.Trace("Calling UpdateSpecificRiskAssessmentIncludeMLROStage");
// Determine if MLRO stage should be shown in KYC business process flow
bool newIncludeMLROStage;
if (messageName == "Create")
{
newIncludeMLROStage = (mLROStageInclusion == 206340001 /*Include*/);
}
else if (messageName == "Update")
{
newIncludeMLROStage = (mLROStageInclusion == 206340000 /*Risk routing*/ && riskRating == 206340001 /*Enhanced*/) || (mLROStageInclusion == 206340001 /*Include*/);
// TODO: refactor to a PreOperation step so we don't need to do a separate Update
Entity updateEntity = new Entity("tt_kyccheck", specificRiskAssessment.Id)
{
["tt_kycincludemlrostage"] = newIncludeMLROStage
};
crmService.Update(updateEntity);
}
else
{
throw new Exception("Unhandled message type.");
}
tracingService.Trace("Completed UpdateSpecificRiskAssessmentIncludeMLROStage");
return newIncludeMLROStage;
}
public void UpdateSpecificRiskAssessmentTriggerRiskRatingHandler(Entity postMessageImage)
{
tracingService.Trace("Calling UpdateSpecificRiskAssessmentTriggerRiskRatingHandler");
// Calling tables have an N:1 relationship with Specific Risk Assessment
EntityReference sraEntityReference = postMessageImage.GetAttributeValue<EntityReference>("tt_kyccheck");
if (sraEntityReference == null)
{
tracingService.Trace("tt_kyccheck lookup is null. Exiting method.");
return;
}
// Flag is detected during form OnLoad and triggers handler for tt_riskrating determination, which also unsets this flag
Entity updateEntity = new Entity("tt_kyccheck", sraEntityReference.Id)
{
["tt_triggerriskratinghandler"] = true
};
crmService.Update(updateEntity);
tracingService.Trace("Specific Risk Assessment updated.");
}
/// <summary>
/// Retrieves the assignment-to-web-role configuration stored in the tt_configuration entity.
/// The configuration is expected to be a JSON string named "Assignments.json".
/// </summary>
public Dictionary<string, Dictionary<string, List<string>>> GetAssignmentConfiguration(IOrganizationService service, ITracingService tracingService)
{
// Build query to retrieve the configuration record
QueryExpression configQuery = new QueryExpression("tt_configuration")
{
ColumnSet = new ColumnSet("tt_content"),
Criteria = new FilterExpression
{
Conditions = { new ConditionExpression("tt_name", ConditionOperator.Equal, "Assignments.json") }
}
};
// Execute query
EntityCollection configResults = service.RetrieveMultiple(configQuery);
if (configResults.Entities.Count == 0)
{
tracingService.Trace("Assignments.json configuration not found.");
return null;
}
try
{
// Parse JSON content into a nested dictionary structure
string jsonContent = configResults.Entities[0].GetAttributeValue<string>("tt_content");
return JsonConvert.DeserializeObject<Dictionary<string, Dictionary<string, List<string>>>>(jsonContent);
}
catch (Exception ex)
{
tracingService.Trace($"Failed to parse configuration JSON: {ex}");
throw new InvalidPluginExecutionException("Configuration parsing failed.", ex);
}
}
/// <summary>
/// Retrieves a web role entity by its name from the adx_webrole entity.
/// </summary>
public Entity GetWebRoleByName(IOrganizationService service, string webRoleName)
{
QueryExpression webRoleQuery = new QueryExpression("adx_webrole")
{
ColumnSet = new ColumnSet("adx_name"),
Criteria = new FilterExpression
{
Conditions = { new ConditionExpression("adx_name", ConditionOperator.Equal, webRoleName) }
}
};
EntityCollection results = service.RetrieveMultiple(webRoleQuery);
return results.Entities.FirstOrDefault(); // Return the first match or null
}
/// <summary>
/// Checks whether a contact is already associated with a specific web role.
/// </summary>
public bool IsContactAssociatedWithWebRole(IOrganizationService service, Guid contactId, Guid webRoleId)
{
QueryExpression associationCheck = new QueryExpression("adx_webrole_contact")
{
ColumnSet = new ColumnSet(false), // No need to retrieve columns
Criteria = new FilterExpression
{
Conditions =
{
new ConditionExpression("contactid", ConditionOperator.Equal, contactId),
new ConditionExpression("adx_webroleid", ConditionOperator.Equal, webRoleId)
}
}
};
return service.RetrieveMultiple(associationCheck).Entities.Count > 0;
}
/// <summary>
/// Validates that the plugin context contains a valid tt_Contact_tt_Assignment relationship,
/// and extracts the contact and assignment references.
/// </summary>
public bool ValidateContactAssignmentRelationship(IPluginExecutionContext context, ITracingService tracingService, out EntityReference contact, out EntityReference assignment)
{
contact = null;
assignment = null;
// Check if the relationship is correct
if (!(context.InputParameters["Relationship"] is Relationship rel) ||
!string.Equals(rel.SchemaName, "tt_Contact_tt_Assignment", StringComparison.OrdinalIgnoreCase))
{
return false;
}
// Validate presence of required parameters
if (!(context.InputParameters["Target"] is EntityReference target) ||
!(context.InputParameters["RelatedEntities"] is EntityReferenceCollection related) ||
related.Count == 0)
{
tracingService.Trace("Missing Target or RelatedEntities parameters");
return false;
}
// Ensure correct entity types
if (target.LogicalName != "contact" || related[0].LogicalName != "tt_assignment")
{
tracingService.Trace("Entity type mismatch");
return false;
}
// Assign output values
contact = target;
assignment = related[0];
return true;
}
/// <summary>
/// Retrieves the list of web roles configured for a given assignment name from the configuration dictionary.
/// </summary>
public List<string> GetWebRolesForAssignment(Dictionary<string, Dictionary<string, List<string>>> config, string assignmentName, ITracingService tracingService)
{
if (config.TryGetValue(assignmentName, out var assignmentConfig) &&
assignmentConfig.TryGetValue("Web Roles", out var webRoles))
{
tracingService.Trace($"Found {webRoles.Count} web roles for assignment '{assignmentName}'");
return webRoles;
}
tracingService.Trace($"No web roles found for assignment '{assignmentName}'");
return new List<string>();
}
// ============================================================================
// Document Approval Utility Methods
// ============================================================================
/// <summary>
/// Retrieves the label for an option set value. Handles both standard picklists and status attributes.
/// </summary>
public string GetOptionSetLabel(string entityName, string attributeName, int optionSetValue)
{
if (optionSetValue == -1)
{
return null;
}
try
{
var request = new RetrieveAttributeRequest
{
EntityLogicalName = entityName,
LogicalName = attributeName,
RetrieveAsIfPublished = true
};
var response = (RetrieveAttributeResponse)crmService.Execute(request);
if (response?.AttributeMetadata == null)
{
return $"Unknown ({optionSetValue})";
}
OptionMetadata matchedOption = null;
// Handle status attributes
if (response.AttributeMetadata is StatusAttributeMetadata statusMetadata)
{
foreach (var option in statusMetadata.OptionSet.Options)
{
if (option.Value == optionSetValue)
{
matchedOption = option;
break;
}
}
}
// Handle standard picklists
else if (response.AttributeMetadata is PicklistAttributeMetadata picklistMetadata)
{
foreach (var option in picklistMetadata.OptionSet.Options)
{
if (option.Value == optionSetValue)
{
matchedOption = option;
break;
}
}
}
if (matchedOption != null)
{
return matchedOption.Label?.UserLocalizedLabel?.Label ?? $"Unknown ({optionSetValue})";
}
return $"Unknown ({optionSetValue})";
}
catch (Exception ex)
{
tracingService.Trace($"GetOptionSetLabel failed for {entityName}.{attributeName}: {ex.Message}");
return $"Error ({optionSetValue})";
}
}
/// <summary>
/// Retrieves the service line label from a document approval record.
/// </summary>
public string GetServiceLineLabel(Entity documentApproval)
{
int serviceLineValue = documentApproval.GetAttributeValue<OptionSetValue>("tt_serviceline")?.Value ?? -1;
string serviceLineLabel;
try
{
serviceLineLabel = GetOptionSetLabel("tt_documentapproval", "tt_serviceline", serviceLineValue);
}
catch (Exception ex)
{
throw new InvalidPluginExecutionException($"Failed to retrieve service line label: {ex.Message}", ex);
}
if (string.IsNullOrEmpty(serviceLineLabel))
{
throw new InvalidPluginExecutionException($"Service Line label could not be retrieved for value {serviceLineValue}.");
}
return serviceLineLabel;
}
/// <summary>
/// Determines the role name filter and operator type based on the service line.
/// </summary>
public (string roleNameFilter, string operatorType) GetRoleFilterForServiceLine(string serviceLineLabel)
{
switch (serviceLineLabel)
{
case "General":
return ("%Data Approver%", "like");
case "Funds Tax":
case "CTG Tax":
return ("CTG Tax - Data Approver", "eq");
case "Research And Development":
return ("RandD - Data Approver", "eq");
case "UK Tax":
return ("Data Approver", "eq");
default:
return ($"{serviceLineLabel} - Data Approver", "eq");
}
}
/// <summary>
/// Retrieves connected contacts with appropriate Data Approver roles using FetchXML.
/// </summary>
public EntityCollection RetrieveConnectedContacts(Guid contactId, Guid documentApprovalId, string roleNameFilter, string operatorType)
{
string fetchXml = $@"
<fetch version='1.0' output-format='xml-platform' mapping='logical' distinct='true'>
<entity name='contact'>
<attribute name='fullname' />
<attribute name='contactid' />
<link-entity name='connection' from='record2id' to='contactid' link-type='inner'>
<filter>
<condition attribute='record1roleidname' operator='{operatorType}' value='{roleNameFilter}' />
</filter>
<link-entity name='contact' from='contactid' to='record1id' link-type='inner'>
<link-entity name='tt_documentapproval' from='tt_contactid' to='contactid' link-type='inner'>
<filter>
<condition attribute='tt_contactid' operator='eq' value='{contactId}' />
<condition attribute='tt_documentapprovalid' operator='eq' value='{documentApprovalId}' />
</filter>
</link-entity>
</link-entity>
</link-entity>
</entity>
</fetch>";
try
{
EntityCollection connectedContacts = crmService.RetrieveMultiple(new FetchExpression(fetchXml));
tracingService.Trace($"Connected contacts found: {connectedContacts.Entities.Count}");
return connectedContacts;
}
catch (Exception ex)
{
tracingService.Trace($"FetchXML query failed: {ex.Message}");
throw new InvalidPluginExecutionException($"Failed to retrieve connected contacts: {ex.Message}", ex);
}
}
/// <summary>
/// Deletes all existing signer records for the document approval.
/// </summary>
public void DeleteExistingSigners(Guid documentApprovalId)
{
try
{
QueryExpression query = new QueryExpression("tt_documentapprovaldocumentsigners")
{
ColumnSet = new ColumnSet("tt_documentapprovaldocumentsignersid"),
Criteria = new FilterExpression
{
Conditions =
{
new ConditionExpression("tt_documentapproval", ConditionOperator.Equal, documentApprovalId)
}
}
};
EntityCollection existingSigners = crmService.RetrieveMultiple(query);
tracingService.Trace($"Deleting {existingSigners.Entities.Count} existing signer(s).");
int deletedCount = 0;
int deleteFailureCount = 0;
foreach (var signer in existingSigners.Entities)
{
try
{
crmService.Delete("tt_documentapprovaldocumentsigners", signer.Id);
deletedCount++;
}
catch (Exception ex)
{
deleteFailureCount++;
tracingService.Trace($"Failed to delete signer {signer.Id}: {ex.Message}");
// Continue deleting other records
}
}
if (deleteFailureCount > 0)
{
tracingService.Trace($"Deletion completed: {deletedCount} deleted, {deleteFailureCount} failed.");
}
}
catch (Exception ex)
{
throw new InvalidPluginExecutionException($"Failed to delete existing signers: {ex.Message}", ex);
}
}
/// <summary>
/// Creates signer records for each connected contact.
/// </summary>
public void CreateSignerRecords(Guid documentApprovalId, EntityCollection connectedContacts)
{
int successCount = 0;
int failureCount = 0;
foreach (var contact in connectedContacts.Entities)
{
string contactName = contact.GetAttributeValue<string>("fullname") ?? "Unknown";
Guid connectedContactId = contact.Id;
try
{
Entity signer = new Entity("tt_documentapprovaldocumentsigners");
signer["tt_documentapproval"] = new EntityReference("tt_documentapproval", documentApprovalId);
signer["tt_contact"] = new EntityReference("contact", connectedContactId);
crmService.Create(signer);
successCount++;
}
catch (Exception ex)
{
failureCount++;
tracingService.Trace($"Failed to create signer for Contact: {contactName} ({connectedContactId}): {ex.Message}");
// Continue processing remaining contacts
}
}
tracingService.Trace($"Signers created: {successCount}/{connectedContacts.Entities.Count} successful, {failureCount} failed");
if (failureCount > 0)
{
tracingService.Trace("WARNING: Some signers failed to create. See errors above.");
}
}
/// <summary>
/// Validates the plugin execution context and returns the target entity.
/// Checks entity type, message type, and depth to prevent infinite loops.
/// </summary>
/// <param name="context">Plugin execution context</param>
/// <param name="expectedEntityName">Expected entity logical name</param>
/// <param name="expectedMessage">Expected message name (e.g., "Create", "Update")</param>
/// <param name="maxDepth">Maximum allowed plugin depth (default: 10)</param>
/// <returns>Validated target entity</returns>
public Entity ValidateAndGetTarget(IPluginExecutionContext context, string expectedEntityName, string expectedMessage, int maxDepth = 10)
{
tracingService.Trace($"[ValidateAndGetTarget] Validating context for {expectedEntityName}/{expectedMessage}");
// Check depth to prevent infinite loops
if (context.Depth > maxDepth)
{
throw new InvalidPluginExecutionException($"Plugin depth ({context.Depth}) exceeds maximum allowed depth ({maxDepth}). Possible infinite loop detected.");
}
// Validate Target parameter exists
if (!context.InputParameters.Contains("Target") || !(context.InputParameters["Target"] is Entity))
{
throw new InvalidPluginExecutionException("Target entity is missing or invalid.");
}
Entity target = (Entity)context.InputParameters["Target"];
// Validate entity and message types
if (target.LogicalName != expectedEntityName || context.MessageName != expectedMessage)
{
throw new InvalidPluginExecutionException(
$"Plugin triggered on incorrect entity or message. Expected: {expectedEntityName}/{expectedMessage}, Got: {target.LogicalName}/{context.MessageName}");
}
tracingService.Trace($"[ValidateAndGetTarget] Validation successful. Target ID: {target.Id}");
return target;
}
/// <summary>
/// Retrieves a document approval record with specified columns.
/// Always retrieves core columns (tt_contactid, tt_serviceline) plus any additional columns specified.
/// </summary>
/// <param name="documentApprovalId">ID of the document approval record</param>
/// <param name="additionalColumns">Optional additional columns to retrieve</param>
/// <returns>Document approval entity with requested attributes</returns>
public Entity RetrieveDocumentApproval(Guid documentApprovalId, params string[] additionalColumns)
{
tracingService.Trace($"[RetrieveDocumentApproval] Retrieving document approval: {documentApprovalId}");
try
{
// Build column set with core columns
List<string> columns = new List<string> { "tt_contactid", "tt_serviceline" };
// Add any additional columns requested
if (additionalColumns != null && additionalColumns.Length > 0)
{
columns.AddRange(additionalColumns);
tracingService.Trace($"[RetrieveDocumentApproval] Additional columns: {string.Join(", ", additionalColumns)}");
}
Entity documentApproval = crmService.Retrieve("tt_documentapproval", documentApprovalId, new ColumnSet(columns.ToArray()));
tracingService.Trace($"[RetrieveDocumentApproval] Successfully retrieved document approval");
return documentApproval;
}
catch (Exception ex)
{
tracingService.Trace($"[RetrieveDocumentApproval] Error: {ex.Message}");
throw new InvalidPluginExecutionException($"Failed to retrieve document approval record {documentApprovalId}: {ex.Message}", ex);
}
}
/// <summary>
/// Extracts and validates the contact ID from a document approval record.
/// </summary>
/// <param name="documentApproval">Document approval entity</param>
/// <returns>Contact GUID</returns>
public Guid GetContactIdFromDocumentApproval(Entity documentApproval)
{
tracingService.Trace($"[GetContactIdFromDocumentApproval] Extracting contact ID");
if (documentApproval == null)
{
throw new ArgumentNullException(nameof(documentApproval), "Document Approval entity cannot be null.");
}
if (!documentApproval.Contains("tt_contactid") || documentApproval["tt_contactid"] == null)
{
throw new InvalidPluginExecutionException(
$"Document Approval record {documentApproval.Id} does not contain a valid tt_contactid reference.");
}
EntityReference contactRef = (EntityReference)documentApproval["tt_contactid"];
tracingService.Trace($"[GetContactIdFromDocumentApproval] Contact ID: {contactRef.Id}");
return contactRef.Id;
}
/// <summary>
/// Validates that the document approval has a service line value.
/// </summary>
/// <param name="documentApproval">Document approval entity</param>
/// <returns>True if service line is present and valid</returns>
public bool ValidateServiceLine(Entity documentApproval)
{
tracingService.Trace($"[ValidateServiceLine] Validating service line");
if (documentApproval == null)
{
throw new ArgumentNullException(nameof(documentApproval), "Document Approval entity cannot be null.");
}
if (!documentApproval.Contains("tt_serviceline") || documentApproval["tt_serviceline"] == null)
{
tracingService.Trace($"[ValidateServiceLine] Service line is missing or null");
return false;
}
OptionSetValue serviceLineValue = documentApproval.GetAttributeValue<OptionSetValue>("tt_serviceline");
if (serviceLineValue == null || serviceLineValue.Value <= 0)
{
tracingService.Trace($"[ValidateServiceLine] Service line has invalid value");
return false;
}
tracingService.Trace($"[ValidateServiceLine] Service line is valid: {serviceLineValue.Value}");
return true;
}
/// <summary>
/// Checks if a document approval is in Draft status.
/// </summary>
/// <param name="documentApproval">Document approval entity (must contain statuscode attribute)</param>
/// <param name="draftStatusCode">The status code value representing Draft status (default: 206340002)</param>
/// <returns>True if status is Draft, false otherwise</returns>
public bool IsDocumentApprovalInDraft(Entity documentApproval, int draftStatusCode = 206340002)
{
tracingService.Trace($"[IsDocumentApprovalInDraft] Checking draft status");
if (documentApproval == null)
{
throw new ArgumentNullException(nameof(documentApproval), "Document Approval entity cannot be null.");
}
if (!documentApproval.Contains("statuscode"))
{
throw new InvalidPluginExecutionException(
$"Document Approval record {documentApproval.Id} does not contain statuscode attribute.");
}
int statusCode = documentApproval.GetAttributeValue<OptionSetValue>("statuscode")?.Value ?? -1;
string statusLabel = GetOptionSetLabel("tt_documentapproval", "statuscode", statusCode);
bool isDraft = statusLabel.Equals("Draft", StringComparison.OrdinalIgnoreCase);
tracingService.Trace($"[IsDocumentApprovalInDraft] Status: {statusLabel} ({statusCode}), Is Draft: {isDraft}");
return isDraft;
}
/// <summary>
/// Orchestrates the complete signer update process: retrieves contacts, deletes old signers, creates new signers.
/// Provides transactional-like behavior with detailed error reporting.
/// </summary>
/// <param name="documentApprovalId">Document approval ID</param>
/// <param name="contactId">Contact ID to find connections for</param>
/// <param name="serviceLineLabel">Service line label to determine role filter</param>
/// <param name="deleteExisting">Whether to delete existing signers before creating new ones</param>
/// <returns>Number of signers successfully created</returns>
public int UpdateSignersForDocumentApproval(Guid documentApprovalId, Guid contactId, string serviceLineLabel, bool deleteExisting = false)
{
tracingService.Trace($"[UpdateSignersForDocumentApproval] Starting signer update process");
tracingService.Trace($"[UpdateSignersForDocumentApproval] Document Approval: {documentApprovalId}, Contact: {contactId}, Service Line: {serviceLineLabel}");
try
{
// Get role filter based on service line
var (roleNameFilter, operatorType) = GetRoleFilterForServiceLine(serviceLineLabel);
tracingService.Trace($"[UpdateSignersForDocumentApproval] Role Filter: {roleNameFilter}, Operator: {operatorType}");
// Delete existing signers if requested
if (deleteExisting)
{
tracingService.Trace($"[UpdateSignersForDocumentApproval] Deleting existing signers");
DeleteExistingSigners(documentApprovalId);
}
// Retrieve connected contacts
EntityCollection connectedContacts = RetrieveConnectedContacts(contactId, documentApprovalId, roleNameFilter, operatorType);
if (connectedContacts.Entities.Count == 0)
{
tracingService.Trace($"[UpdateSignersForDocumentApproval] No connected contacts found matching criteria");
return 0;
}
// Create signer records
tracingService.Trace($"[UpdateSignersForDocumentApproval] Creating {connectedContacts.Entities.Count} signer record(s)");
CreateSignerRecords(documentApprovalId, connectedContacts);
tracingService.Trace($"[UpdateSignersForDocumentApproval] Signer update process completed successfully");
return connectedContacts.Entities.Count;
}
catch (Exception ex)
{
tracingService.Trace($"[UpdateSignersForDocumentApproval] Error during signer update: {ex.Message}");
throw new InvalidPluginExecutionException(
$"Failed to update signers for Document Approval {documentApprovalId}: {ex.Message}", ex);
}
}
/// <summary>
/// Checks if a Yes/No (Two Options/Boolean) field is set to "No" (false).
/// </summary>
/// <param name="entity">The entity containing the field</param>
/// <param name="attributeName">The logical name of the Yes/No field</param>
/// <returns>True if the field is "No" (false), false if "Yes" (true) or not set</returns>
public bool GetBooleanFieldValue(Entity entity, string attributeName)
{
tracingService.Trace($"[GetBooleanFieldValue] Checking {attributeName}");
if (!entity.Contains(attributeName) || entity[attributeName] == null)
{
tracingService.Trace($"[GetBooleanFieldValue] Field not present or null, defaulting to false");
return false;
}
bool booleanValue = entity.GetAttributeValue<bool>(attributeName);
// In Dynamics, Two Options fields: true = Yes, false = No
bool isNo = !booleanValue;
tracingService.Trace($"[GetBooleanFieldValue] {attributeName} = {booleanValue} (Yes/No), Is No: {isNo}");
return isNo;
}
// ============================================================================
// Tax Form Configuration Utility Methods
// ============================================================================
/// <summary>
/// Retrieves a Tax Form configuration record based on configuration type and tax form type.
/// </summary>
/// <param name="service">Organization service</param>
/// <param name="configurationType">Configuration Type option set value</param>
/// <param name="taxFormType">Tax Form Type option set value</param>
/// <returns>Configuration entity or null if not found</returns>
public Entity GetTaxFormConfiguration(IOrganizationService service, int configurationType, int taxFormType)
{
tracingService.Trace($"[GetTaxFormConfiguration] Retrieving configuration for ConfigType: {configurationType}, TaxFormType: {taxFormType}");
try
{
string fetchXml = $@"
<fetch version='1.0' output-format='xml-platform' mapping='logical' distinct='false'>
<entity name='tt_configuration'>
<attribute name='tt_configurationid' />
<attribute name='tt_name' />
<attribute name='tt_webresource' />
<attribute name='tt_sections' />
<order attribute='tt_name' descending='false' />
<filter type='and'>
<condition attribute='tt_configurationtype' operator='eq' value='{configurationType}' />
<condition attribute='tt_taxformtype' operator='eq' value='{taxFormType}' />
<condition attribute='statecode' operator='eq' value='0' />
</filter>
</entity>
</fetch>";
EntityCollection results = service.RetrieveMultiple(new FetchExpression(fetchXml));
if (results.Entities.Count == 0)
{
tracingService.Trace("[GetTaxFormConfiguration] No configuration found");
return null;
}
Entity configuration = results.Entities[0];
tracingService.Trace($"[GetTaxFormConfiguration] Configuration retrieved: {configuration.Id}");
return configuration;
}
catch (Exception ex)
{
tracingService.Trace($"[GetTaxFormConfiguration] Error: {ex.Message}");
throw new InvalidPluginExecutionException($"Failed to retrieve tax form configuration: {ex.Message}", ex);
}
}
/// <summary>
/// Parses the sections string from a configuration entity into an array of section IDs.
/// Handles comma and semicolon delimiters.
/// </summary>
/// <param name="configuration">Configuration entity containing tt_sections attribute</param>
/// <param name="tracingService">Tracing service for logging</param>
/// <returns>Array of trimmed section IDs</returns>
public string[] ParseSectionIds(Entity configuration, ITracingService tracingService)
{
tracingService.Trace("[ParseSectionIds] Parsing section IDs from configuration");
if (configuration == null)
{
throw new ArgumentNullException(nameof(configuration), "Configuration entity cannot be null");
}
string sectionsString = configuration.GetAttributeValue<string>("tt_sections");
if (string.IsNullOrWhiteSpace(sectionsString))
{
tracingService.Trace("[ParseSectionIds] No sections string found");
return new string[0];
}
// Parse sections (e.g., "ukInterest, ukDividends" -> ["ukInterest", "ukDividends"])
string[] sectionIds = sectionsString.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries);
for (int i = 0; i < sectionIds.Length; i++)
{
sectionIds[i] = sectionIds[i].Trim();
}
tracingService.Trace($"[ParseSectionIds] Parsed {sectionIds.Length} section IDs");
return sectionIds;
}
/// <summary>
/// Retrieves and normalizes the web resource name from a configuration entity.
/// Removes leading slashes and "WebResources/" prefix if present.
/// </summary>
/// <param name="configuration">Configuration entity containing tt_webresource attribute</param>
/// <param name="tracingService">Tracing service for logging</param>
/// <returns>Normalized web resource name</returns>
public string GetWebResourceName(Entity configuration, ITracingService tracingService)
{
tracingService.Trace("[GetWebResourceName] Retrieving web resource name");
if (configuration == null)
{
throw new ArgumentNullException(nameof(configuration), "Configuration entity cannot be null");
}
string webResourceName = configuration.GetAttributeValue<string>("tt_webresource");
if (string.IsNullOrWhiteSpace(webResourceName))
{
tracingService.Trace("[GetWebResourceName] No web resource name found");
return null;
}
// Remove leading slash or "/WebResources/" if present
webResourceName = webResourceName.TrimStart('/');
if (webResourceName.StartsWith("WebResources/", StringComparison.OrdinalIgnoreCase))
{
webResourceName = webResourceName.Substring("WebResources/".Length);
}
tracingService.Trace($"[GetWebResourceName] Normalized name: {webResourceName}");
return webResourceName;
}
/// <summary>
/// Loads web resource content directly from Dynamics using SDK (no HTTP calls required).
/// Content is decoded from base64 encoding.
/// </summary>
/// <param name="webResourceName">Name of the web resource</param>
/// <param name="service">Organization service</param>
/// <param name="tracingService">Tracing service for logging</param>
/// <returns>Decoded web resource content as string</returns>
public string LoadWebResourceContent(string webResourceName, IOrganizationService service, ITracingService tracingService)
{
tracingService.Trace($"[LoadWebResourceContent] Loading web resource: {webResourceName}");
try
{
// Query for the web resource by name
QueryExpression query = new QueryExpression("webresource")
{
ColumnSet = new ColumnSet("content", "name"),
Criteria = new FilterExpression()
};
query.Criteria.AddCondition("name", ConditionOperator.Equal, webResourceName);
EntityCollection results = service.RetrieveMultiple(query);
if (results.Entities.Count == 0)
{
throw new InvalidPluginExecutionException($"Web resource '{webResourceName}' not found.");
}
Entity webResource = results.Entities[0];
if (!webResource.Contains("content"))
{
throw new InvalidPluginExecutionException($"Web resource '{webResourceName}' has no content.");
}
// Content is stored as base64 encoded string
string base64Content = webResource.GetAttributeValue<string>("content");
byte[] contentBytes = Convert.FromBase64String(base64Content);
string content = System.Text.Encoding.UTF8.GetString(contentBytes);
tracingService.Trace($"[LoadWebResourceContent] Web resource loaded successfully, length: {content.Length}");
return content;
}
catch (InvalidPluginExecutionException)
{
// Re-throw plugin exceptions as-is
throw;
}
catch (Exception ex)
{
tracingService.Trace($"[LoadWebResourceContent] Error: {ex.Message}");
throw new InvalidPluginExecutionException($"Failed to load web resource '{webResourceName}': {ex.Message}", ex);
}
}
/// <summary>
/// Parses JavaScript content to extract a JSON object assigned to a variable.
/// Handles format: var VariableName = { ... }
/// </summary>
/// <param name="jsContent">JavaScript content containing the variable assignment</param>
/// <param name="variableName">Name of the JavaScript variable (e.g., "TaxFormSections")</param>
/// <param name="tracingService">Tracing service for logging</param>
/// <returns>Parsed JSON object</returns>
public JObject ParseJavaScriptToJson(string jsContent, string variableName, ITracingService tracingService)
{
tracingService.Trace($"[ParseJavaScriptToJson] Parsing JavaScript for variable: {variableName}");
try
{
// Pattern: var VariableName = { ... } or var VariableName={...}
string pattern = $@"var\s+{variableName}\s*=\s*(\{{.*\}});?\s*$";
System.Text.RegularExpressions.Match match = System.Text.RegularExpressions.Regex.Match(
jsContent,
pattern,
System.Text.RegularExpressions.RegexOptions.Singleline);
if (match.Success && match.Groups.Count > 1)
{
string jsonString = match.Groups[1].Value.Trim();
// Remove trailing semicolon if present
if (jsonString.EndsWith(";"))
{
jsonString = jsonString.Substring(0, jsonString.Length - 1);
}
tracingService.Trace($"[ParseJavaScriptToJson] Extracted JSON, length: {jsonString.Length}");
// Parse the JSON
JObject jsonObject = JObject.Parse(jsonString);
tracingService.Trace("[ParseJavaScriptToJson] JSON parsed successfully");
return jsonObject;
}
else
{
throw new InvalidPluginExecutionException(
$"Could not extract JSON from JavaScript content. Expected format: var {variableName} = {{ ... }}");
}
}
catch (InvalidPluginExecutionException)
{
throw;
}
catch (Exception ex)
{
tracingService.Trace($"[ParseJavaScriptToJson] Error: {ex.Message}");
throw new InvalidPluginExecutionException($"Failed to parse JavaScript to JSON: {ex.Message}", ex);
}
}
/// <summary>
/// Finds a section by its ID in a JSON sections array.
/// </summary>
/// <param name="sections">JSON array of section objects</param>
/// <param name="sectionId">ID of the section to find</param>
/// <param name="tracingService">Tracing service for logging</param>
/// <returns>Matching section object or null if not found</returns>
public JObject FindSectionById(JArray sections, string sectionId, ITracingService tracingService)
{
tracingService.Trace($"[FindSectionById] Searching for section: {sectionId}");
if (sections == null)
{
tracingService.Trace("[FindSectionById] Sections array is null");
return null;
}
foreach (JToken section in sections)
{
if (section["id"] != null && section["id"].ToString() == sectionId)
{
tracingService.Trace($"[FindSectionById] Found section: {sectionId}");
return (JObject)section;
}
}
tracingService.Trace($"[FindSectionById] Section not found: {sectionId}");
return null;
}
/// <summary>
/// Populates Tax Form section fields with matching sections from the JSON array.
/// Creates an update entity with all matched sections and performs a single update.
/// </summary>
/// <param name="taxFormId">ID of the Tax Form to update</param>
/// <param name="sectionIds">Array of section IDs to populate</param>
/// <param name="sectionsArray">JSON array containing all available sections</param>
/// <param name="service">Organization service</param>
/// <param name="tracingService">Tracing service for logging</param>
/// <returns>Number of sections successfully populated</returns>
public int PopulateTaxFormSections(Guid taxFormId, string[] sectionIds, JArray sectionsArray, IOrganizationService service, ITracingService tracingService)
{
tracingService.Trace($"[PopulateTaxFormSections] Populating sections for Tax Form: {taxFormId}");
try
{
Entity taxFormUpdate = new Entity("tt_taxform", taxFormId);
int sectionFieldIndex = 1;
foreach (string sectionId in sectionIds)
{
tracingService.Trace($"[PopulateTaxFormSections] Processing section: {sectionId}");
// Find the matching section in the sections array
JObject matchingSection = FindSectionById(sectionsArray, sectionId, tracingService);
if (matchingSection != null)
{
// Store the entire section JSON in the appropriate field
string fieldName = $"tt_section{sectionFieldIndex}";
string sectionJson = matchingSection.ToString(Newtonsoft.Json.Formatting.None);
taxFormUpdate[fieldName] = sectionJson;
tracingService.Trace($"[PopulateTaxFormSections] Set {fieldName} with section '{sectionId}' ({sectionJson.Length} chars)");
sectionFieldIndex++;
}
else
{
tracingService.Trace($"[PopulateTaxFormSections] Section '{sectionId}' not found in web resource");
}
}
// Update the Tax Form record with all sections
if (taxFormUpdate.Attributes.Count > 0)
{
service.Update(taxFormUpdate);
tracingService.Trace($"[PopulateTaxFormSections] Updated Tax Form with {taxFormUpdate.Attributes.Count} section(s)");
return taxFormUpdate.Attributes.Count;
}
else
{
tracingService.Trace("[PopulateTaxFormSections] No sections were populated");
return 0;
}
}
catch (Exception ex)
{
tracingService.Trace($"[PopulateTaxFormSections] Error: {ex.Message}");
throw new InvalidPluginExecutionException($"Failed to populate tax form sections: {ex.Message}", ex);
}
}
}
}