Skip to main content

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 queries
  • tracingService - 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 service
  • webRoleName - 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 service
  • contactId - GUID of the contact
  • webRoleId - 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 context
  • tracingService - Tracing service
  • contact (out) - Contact EntityReference extracted from Target
  • assignment (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 up
  • tracingService - 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 entity
  • attributeName - Logical name of the attribute
  • optionSetValue - Numeric value to look up

Returns: Label string, or "Unknown (value)" if not found.

Used By:


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:


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:


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 approval
  • documentApprovalId - Document approval ID
  • roleNameFilter - Role name pattern to match
  • operatorType - FetchXML operator ("like" or "eq")

Returns: EntityCollection of contacts with fullname and contactid attributes.

Used By:


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:


CreateSignerRecords()

Creates signer records for each connected contact.

Purpose: Generates tt_documentapprovaldocumentsigners records linking contacts to the document approval.

Parameters:

  • documentApprovalId - Document approval ID
  • connectedContacts - 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:


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 context
  • expectedEntityName - Expected entity logical name
  • expectedMessage - 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:


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 record
  • additionalColumns - Optional additional columns to retrieve (params array)

Returns: Document approval entity with requested attributes.

Used By:


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:


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:


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 ID
  • contactId - Contact ID to find connections for
  • serviceLineLabel - Service line label to determine role filter
  • deleteExisting - Whether to delete existing signers before creating new ones

Returns: Number of signers successfully created.

Used By:

Workflow:

  1. Determine role filter based on service line
  2. Optionally delete existing signers
  3. Retrieve connected contacts
  4. Create signer records
  5. 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 field
  • attributeName - 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:


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 service
  • configurationType - Configuration Type option set value
  • taxFormType - 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 attribute
  • tracingService - 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 attribute
  • tracingService - 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 resource
  • service - Organization service
  • tracingService - 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 assignment
  • variableName - 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 objects
  • sectionId - ID of the section to find
  • tracingService - 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 update
  • sectionIds - Array of section IDs to populate
  • sectionsArray - JSON array containing all available sections
  • service - Organization service
  • tracingService - Tracing service for logging

Returns: Number of sections successfully populated.

Used By:

Workflow:

  1. Iterate through section IDs
  2. Find matching section in JSON array
  3. Add section JSON to tt_section{N} field
  4. 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 values
  • TaxReturnChangeStatus() - Cascades tax return status to child records
  • RemoveWebroleWhenConnectionDeleted() - Removes web roles when connections are deleted
  • UpdateCompaniesHouseSkipStage() - Updates Companies House Data skip stage
  • RetrieveSpecificRiskAssessmentOptionSetValueFromLabel() - Maps labels to option set values
  • UpdateSpecificRiskAssessmentIncludeMLROStage() - Updates MLRO stage inclusion
  • UpdateSpecificRiskAssessmentTriggerRiskRatingHandler() - 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);
}
}
}
}