Loading...

Blocking Duplicate Quotes in Dynamics 365 Sales Professional / Enterprise (C#)

Blocking Duplicate Quotes in Dynamics 365 Sales Professional / Enterprise (C#)
Featured image of post Blocking Duplicate Quotes in Dynamics 365 Sales Professional / Enterprise (C#)

As you might be able to tell from other recent blog posts, I’ve been doing lots of work lately with Dynamics 365 Sales and, specifically, the Professional variant of the application. Sales Professional can be best thought of as a “lite” CRM system, with much of the same type of functionality as we’d expect from the full-blown Enterprise application. I’ve blogged previously on the subject of differences between the two versions. The only major thing you lose with the Professional application is access to things like Competitors and restrictions on the number of custom tables that you can include as part of your solution. It’s worth consulting the licensing guide to break down the differences in detail before making a decision. Still, if you are in the market for your very first CRM system, you can’t go far wrong with considering Dynamics 365 Sales Professional.

As was the case with last week’s jolly jaunt into Dynamics 365 Sales, I was dealing yet again with another unusual requirement. In this case, the organisation in question wanted to have it so that only a single Draft Quote could ever exist for an Opportunity in the system. As part of the solution, some additional automation and reporting requirements relied upon information present within the most current Quote issued to Customers and salespeople in the organisation were, very often, creating multiple Quotes without realising. So we needed an approach that would prevent the duplicates from ever getting made. Duplicate Detection Rules provide a mechanism to discourage users from creating duplicate rows, but users could still override this if they felt mischievous. Therefore, we decided that a server-side solution and, specifically, a C# plug-in would be required to prevent duplicates from being created altogether. As far as I know, this is the only way we can meet such a requirement; answers on a postcard, though, if you think there’s a better way. 😉 With all of this in mind then, below is the code that was implemented to achieve the requirement:

namespace JJG.MyPlugins
{
   using System;
   using Microsoft.Xrm.Sdk;
   using Microsoft.Xrm.Sdk.Query;

   /// <summary>
   /// Blocks the Create or Update action for a Quote, if it's detected that another Draft Quote exists linked to the same Opportunity.
   /// </summary>
   public class BlockDuplicateQuoteForOpportunity : IPlugin
   {
      public void Execute(IServiceProvider serviceProvider)
      {
         IPluginExecutionContext context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
         ITracingService tracer = (ITracingService)serviceProvider.GetService(typeof(ITracingService));

         // Check for EntityType and Message supported by your Plug-In
         if (!(context.MessageName == "Create" || context.MessageName == "Update") || context.PrimaryEntityName != "quote")
         {
            throw new InvalidPluginExecutionException($"Plug-In {this.GetType()} is not supported for message {context.MessageName} of {context.PrimaryEntityName}");
         }

         tracer.Trace($"Starting execution of {nameof(BlockDuplicateQuoteForOpportunity)}");

         // Get the newly create Quote (Create) or the Post Image for the Quote (Update), and the Opportunity lookup value
         Entity quote = (Entity)context.InputParameters["Target"];
         EntityReference opportunity = quote.GetAttributeValue<EntityReference>("opportunityid");

         if (opportunity == null)
         {
            tracer.Trace($"No Opportunity is present for Quote ID {quote.Id}. Cancelling plug-in execution");
            return;
         }

         // Attempt to retrieve other Draft Quotes that are linked to the same Opportunity ID we have here
         tracer.Trace($"Attempting to retrieve other Quote rows linked to Opportunity ID {opportunity.Id}...");

         IOrganizationServiceFactory serviceFactory = (IOrganizationServiceFactory)serviceProvider.GetService(typeof(IOrganizationServiceFactory));
         IOrganizationService service = serviceFactory.CreateOrganizationService(context.UserId);

         QueryExpression qe = new QueryExpression()
         {
            EntityName = "quote",
            ColumnSet = new ColumnSet("quoteid"),
            Criteria =
            {
               Conditions =
               {
                  new ConditionExpression("opportunityid", ConditionOperator.Equal, opportunity.Id),
                  new ConditionExpression("statecode", ConditionOperator.Equal, 0),
                  new ConditionExpression("quoteid", ConditionOperator.NotEqual, quote.Id),
               },
            },
         };

         EntityCollection quotes = service.RetrieveMultiple(qe);
         tracer.Trace($"Got {quotes.Entities.Count} Quotes!");

         // If one or more exist, then we throw an error to block the Create / Association
         if (quotes.Entities.Count >= 1)
         {
            tracer.Trace($"Multiple Draft Quotes exist for Opportunity ID {opportunity.Id}. Throwing error to cancel operation...");
            throw new InvalidPluginExecutionException("Draft Quote(s) already exist for the selected Opportunity. Only a single Draft Quote is allowed. Please edit or delete the other Draft Quote(s) before proceeding.");
         }
         else
         {
            tracer.Trace($"No other Draft Quotes are linked to Opportunity ID {opportunity.Id}. No action required. Cancelling plug-in execution");
            return;
         }
      }
   }
}

When registering this plug-in into the application, ensure that it’s aligned to the Post-Operation step on the following messages indicated below:

For the Update step, we also specifically filter on just the opportunityid row, to ensure the plug-in doesn’t fire unnecessarily:

When the user then attempts to create or associate more than one Quote to a single Opportunity, this will be the error message they will receive:

Because the plug-in throws the InvalidPluginExecutionException error message, the platform will roll back the entire transaction. We can then inject our own custom message into the dialog that appears.

As alluded to earlier, having a low/no-code solution to achieve this requirement would be preferred. But, unless I’m missing something obvious, doing something similar via a real-time workflow would be impossible due to the RetrieveMultiple request we have to perform to get other pre-existing Quotes. As much as I make a living out of implementing these types of solutions, we should always be cautious of adopting a code-first mindset if other routes are available to us within Dynamics 365 Sales and the Power Platform. Take care to understand the “baggage” involved with a solution like this so that you don’t get caught out in future as part of an upgrade or when you later incorporate additional functionality into the equation.

Published on:

Learn more
The CRM Chap
The CRM Chap

Anything and everything to do with the #PowerPlatform, #MSDYN365, #Azure and more!

Share post:

Related posts

Dynamics 365 Sales – Close simple deals using Deal Close Agent to generate quotes and complete orders

We are announcing the ability to close simple deals using Deal Close Agent to generate quotes and complete orders in Dynamics 365 Sales. This ...

15 hours ago

Dynamics 365 Sales: Get sales operations insights in Sales Research Agent

As a business leader or sales operations team member, you’re responsible for answering complex questions about performance, coverage, at...

7 days ago

Dynamics 365 Sales: Connect Fabric Lakehouse data to AI‑powered sales research

Many organizations store critical operational and financial data such as revenue actuals, targets, and budgets in Microsoft Fabric Lakehouse r...

7 days ago

Dynamics 365 Sales: Improve opportunity context with AI-based data enrichment

When your opportunity data is incomplete or outdated, you may find it challenging to understand deal health and take the right actions. AI-pow...

7 days ago

From manual work to meaningful selling: How Agentic AI is transforming Dynamics 365 Sales 

Agentic AI in Dynamics 365 Sales reduces manual CRM work by turning unstructured information into actionable insights, helping sellers capture...

1 month ago

Architecting Scalable Business Logic in Dynamics CRM Using Plugin Life Cycle

Dynamics CRM Plugin Life Cycle: Optimizing for Scalability means designing plugins in a way that keeps the system fast, stable, and easy to ma...

2 months ago

We need to talk about... Dynamics 365 Sales... Release Wave 2 for 2025

Next in my blog, I will launch a series on the changes we can expect to see as part of Release Wave 2 for 2025. Microsoft’s 2025 Release Wave ...

3 months ago

Fixed – “Action cannot be performed. This quote is not owned by Dynamics 365 Sales” in Dataverse / Dynamics 365

Recently, while working with Quotes in Dynamics 365 Sales integrated with Supply Chain Management (SCM) through Dual-write, we encountered an ...

3 months ago

Dynamics 365 Sales – Enhance customer interactions with auto-linked CRM data

We are announcing the ability to enhance customer interactions with auto-linked CRM data in Dynamics 365 Sales. How does this affect me? With ...

3 months ago
Stay up to date with latest Microsoft Dynamics 365 and Power Platform news!
* Yes, I agree to the privacy policy