Dynamics 365 Sales: Implementing Volume Discounts and Complex Pricing
What price lists and discount lists really do, the exact point at which they stop, and how to build tiered, promotional, and customer specific pricing past that point without a costly customization.
Dynamics 365 Sales native discount lists have limitations, especially for aggregated volume pricing across an entire order. A discount list is attached to a single price list item and reads the quantity on one line, so it cannot see the order total, cannot combine two lines of the same product, and cannot tier on spend. This guide sets out the out of the box capabilities, their exact constraints, and a step by step implementation for complex discount scenarios using price lists, server side logic, and Power Automate, covering tiered pricing, promotional discounts, and customer specific agreements.
Microsoft documents the pricing objects themselves well, and this page does not try to replace that reference. What is missing everywhere else is the boundary: the exact point at which the built in discount list stops answering the question, and what a supported build looks like on the other side of that line. That is what this page is about. Solzet is a Microsoft Dynamics 365 Customer Engagement and Power Platform consultancy in Yerevan, Armenia, and pricing logic is one of the most common reasons we are asked to write server side code on a Sales implementation, so the constraints below are the ones we hit in delivery rather than a reading of the documentation. Everything here is Dynamics 365 Sales on Dataverse. We do not implement the Microsoft finance or supply chain products, so where an agreed price already lives in your finance system, the design question is how it reaches Dataverse, not which product should own it.
What the out of the box pricing engine actually does
Six objects carry the whole native model. Knowing which one owns which decision is what makes the constraints in the next section obvious rather than surprising.
Unit group and unit
Before a product can be priced it needs a unit group and a base unit, and the units inside a group carry a quantity relative to the base. This is the layer that lets you sell a box of twelve and a single item from the same product, and it is also the first place a volume discount design goes wrong: a discount list reads the quantity on the line in the unit on that line, so ten boxes and one hundred and twenty singles are not the same number to the discount engine even though they are the same goods.
Price list (pricelevel)
A price list is a named, currency scoped container of prices. It carries a start and end date, and it is chosen on the transaction: an opportunity, quote, order, or invoice has exactly one price list at a time. Accounts and contacts have a default price list lookup, which is how a customer specific price sheet gets onto the record without anybody choosing it. One price list holds one currency, so multiple currencies means multiple price lists holding the same commercial logic in parallel.
Price list item (productpricelevel)
A price list item is one row joining a product and a unit to a price inside one price list. The pricing method decides where the number comes from: a fixed currency amount, a percentage of the product list price, or a markup or margin calculated from the product current cost or standard cost. It also carries the quantity selling option, which controls whether the line accepts whole numbers only or fractions, and a rounding policy with a rounding option and amount, so a calculated price can be forced to end in a chosen value or land on a multiple.
Discount list (discounttype) and discount (discount)
A discount list is a named set of quantity ranges, and it is typed once, as either percentage or amount, for all of its rows. Each discount row inside it holds a begin quantity, an end quantity, and the percentage or the amount that applies inside that range. The list is then attached to a price list item, which is the critical structural fact: the discount belongs to one product at one price in one price list, and the quantity it reads is the quantity on that one line.
The transaction line and where the numbers land
On an opportunity product, quote line, order line, or invoice line, the engine sets price per unit from the price list item, computes amount as price per unit multiplied by quantity, and writes the discount from the discount list into the volume discount field. Manual discount is a separate, user entered field. Extended amount is amount less volume discount, less manual discount, plus tax. So the line already has two discount buckets, one system owned and one human owned, and a custom pricing design has to decide which one it is allowed to use.
The header totals and the order level discount
A quote or an order rolls its lines into a total detail amount and a total line item discount, then applies an order level discount that exists as both a percentage and an amount field, before freight and tax produce the total. That order level discount is the field people reach for when they want a whole order tier, and it is worth being clear about what it is: a manually entered number. Nothing in the platform populates it from a volume rule, which is precisely the gap this page exists to close.
The line arithmetic, in one place
Amount equals price per unit multiplied by quantity. Extended amount equals amount, less the volume discount the engine calculated from the discount list, less the manual discount a person entered, plus tax. On the header, the lines roll up into a total detail amount and a total line item discount, then an order level discount, entered by hand as either a percentage or an amount, is applied before freight and tax. Every custom pricing design ends up being a decision about which of those numbers your code is allowed to own.
Where the native discount list stops
None of these are bugs and none of them have a setting. They are the shape of the model, and reading them before design is what stops a pricing project being rescoped in month three.
The discount reads one line, never the order
This is the constraint that sends most projects here. Because the discount list hangs off a price list item, the only quantity in scope is the quantity on the line being priced. An order for four hundred units spread over eight product lines gets each line tiered on its own quantity, so a customer who has clearly bought at a volume threshold is charged as eight small buyers. There is no setting that changes this and no aggregation option to switch on.
Two lines of the same product do not combine
The same product added twice, which happens constantly with different delivery dates, different sites, or simply a seller adding a line rather than editing one, is priced as two independent quantities. Sixty and sixty do not reach the hundred unit tier. Nothing warns anyone, and the quote looks perfectly normal, which is why this one usually surfaces as a margin question months later rather than as a bug report.
Tiers can only be quantity, never value
Begin quantity and end quantity are counts. A commercial agreement written as a spend threshold, five percent above a given order value, has no native expression at all, and neither does a tier on weight, volume, contracted annual value, or number of distinct product families. Any rule whose input is money rather than a count is outside the engine by definition.
One discount list per price list item, and no stacking
A price list item holds a single discount list lookup, so a product cannot carry a volume tier and a seasonal promotion and a partner tier at the same time and have the platform resolve between them. Where a business genuinely has several concurrent discount programmes, the native model forces the commercial team to pre-merge them into one set of ranges per product per price list, which is unmaintainable the moment one programme changes independently of the others.
A discount list has no dates
Discount rows carry quantities and a value, and nothing else. There is no valid from and no valid to, so a promotion that runs for three weeks cannot be expressed as a discount list that starts and stops. The price list does carry start and end dates, but selecting the right price list on the right record at the right moment is a process problem you have to solve, not something the pricing engine does for you when a date passes.
Currency multiplies everything
A price list is scoped to one currency, so every additional selling currency duplicates the entire structure: another price list, another row per product, and another discount list wherever the discount is expressed as an amount rather than a percentage. Percentage discount lists survive this reasonably well because a percentage is currency free. Amount based ones do not, and a business running five currencies is now maintaining five parallel copies of the same commercial intent.
Nothing recalculates itself retroactively
Pricing is applied when a line is written and when somebody presses recalculate on the header. Change the price list item, change the discount ranges, or change the header price list, and existing lines keep the numbers they were given until something triggers a recalculation. That is defensible behaviour for a quote already sent to a customer, and it is a trap for anyone who assumes a corrected discount list will heal the pipeline behind it.
Overridden prices opt out entirely
A line whose pricing is set to override keeps the price a person typed and the engine leaves it alone, which is the correct design and also a hole in every reporting question about realised discount. Locked records behave the same way for a different reason: once an order is invoiced or a record is closed, pricing no longer recalculates. Any custom logic you add has to respect both states rather than fighting them.
Margin floors, approvals, and audit are not part of the engine
The pricing engine calculates. It does not stop a seller putting a ninety percent manual discount on a line, it does not route anything for approval, and it does not record who authorised what. Those are real requirements on almost every complex pricing project and every one of them is yours to build, which is worth knowing before the design is scoped as configuration only.
Write-in products bypass the whole structure
A line can carry a free text product with a typed price and no price list item behind it. That is genuinely useful for one-off items and it means any pricing rule you write has to handle a line with no product reference at all, rather than assuming every line resolves to a catalogue entry. It is also the most common way a carefully designed discount scheme quietly gets bypassed in the field.
Pricing and discount rules are one of the four areas where a configuration only CRM runs out of room, which we set out in more detail when comparing Dynamics 365 against Pipedrive and HubSpot for small commercial teams. The reason it matters more here than elsewhere is that once pricing moves into a spreadsheet beside the CRM, the truth about margin moves with it, and the forecast becomes a report about what people typed rather than what they sold.
Decide where the logic goes before you build anything
Four places the rule can live. Picking the wrong layer costs far more than implementing the right one imperfectly, and it is the decision we are most often called in to unwind.
Option A: configuration only, price lists and discount lists
- Choose it when
- Tiers are per product and per line, quantities are the only input, the ranges change rarely, and the number of price lists is small enough for a person to maintain.
- How it works
- Unit groups, one price list per currency and per commercial agreement, price list items with the right pricing method and rounding policy, and a percentage discount list per product where volume tiers apply. No code, no solution layering risk, nothing to retest on a release wave.
- What it costs you
- It stops exactly at the constraints above. Do this part first regardless of what else you build, because every option below still needs a correct catalogue underneath it, and because the amount of complex pricing that turns out to be expressible this way is always higher than the first workshop suggests.
Option B: a synchronous plugin on the transaction lines
- Choose it when
- The default answer for aggregated volume discounts. Tiers depend on more than one line, on the order total, on the customer agreement, or on a value rather than a count, and the result has to be right no matter how the record was created.
- How it works
- A registered plugin on create, update, and delete of the line entity, plus the header where the price list or currency can change, that reads the tier configuration from Dataverse, computes the discount across the whole set of lines, and writes the result. The system pricing engine stays on and keeps doing price per unit, units, and rounding.
- What it costs you
- C# and application lifecycle management discipline, a recursion guard, and a real test matrix. In exchange the rule holds for a form, a bulk import, an integration, and a flow alike, which is the only property that actually matters for pricing.
Option C: full custom pricing through the CalculatePrice message
- Choose it when
- The pricing model is genuinely not a discount on top of a list price: prices come from an external rating engine or a contract system, or the arithmetic is unlike anything the built in methods express.
- How it works
- Turn off system pricing calculation in the Sales system settings and register a synchronous plugin on the CalculatePrice message. The platform then hands the record to your code and takes no pricing decisions of its own.
- What it costs you
- Everything becomes yours, including the parts that worked: price per unit lookup, unit conversion, rounding, and the pricing error handling that tells a user why a line could not be priced. This is the right answer far less often than it is chosen, and choosing it to solve a volume tier problem is over-customizing in the sense described in our guide to customization mistakes.
Option D: Power Automate, for everything that is not per keystroke
- Choose it when
- Work that is allowed to take seconds rather than milliseconds, or that involves a human: discount approvals above a threshold, activating a promotional price list on a date, assigning a customer agreement price list to new accounts, and the nightly audit that catches what people typed.
- How it works
- Cloud flows on Dataverse triggers and schedules, approvals into Teams or Outlook writing the outcome back to the record, and a scheduled flow that reports lines whose realised discount does not match the scheme that should have produced it.
- What it costs you
- A flow is asynchronous, so it must never be the thing that guarantees a number is correct on a record somebody is looking at. Use it for orchestration around the pricing decision and keep the decision itself in the pricing layer.
Option C is where pricing projects become expensive, and it is the same failure pattern as every other case of replacing platform behaviour that already worked. The reasoning, and the way out when it has already happened, is in common Dynamics 365 customization mistakes and how to fix them, which covers over-customizing out-of-the-box features, putting logic in the wrong tool, and the solution layering discipline that a pricing build has to follow if it is ever going to be deployable twice.
Step by step: implementing tiered and complex pricing
Work through these in order. The early steps shrink the custom scope, the middle steps build the aggregation that the native model cannot express, and the last steps are what keep the scheme correct a year later.
Write the pricing rules down as sentences before touching the system
For every discount programme, state four things: what the input is (units, order value, contracted annual value, product family), what the scope is (one line, one product across the order, the whole order, the whole customer), when it applies (always, between two dates, only on one agreement), and how it interacts with the others (replaces, stacks, takes the best of). Most complex pricing projects that go wrong were never written down at this resolution, and the argument about whether two programmes stack surfaces during user acceptance testing instead of during design.
Fix the product catalogue, units, and price lists first
Set up unit groups so the unit you sell in is the unit the rule is written in, and decide deliberately whether tiers count base units or selling units. Build one price list per currency and per genuinely distinct commercial agreement, not one per customer, and set the default price list on accounts so the right sheet arrives on the record without a manual choice. Use the pricing methods properly: a percentage of list price or a margin from cost is maintained in one place, where a hardcoded currency amount per product has to be reissued every time a cost moves.
Exhaust the native discount list before writing any code
Configure percentage discount lists with clean, non overlapping quantity ranges for every rule that genuinely is per line and per product, and attach them to the price list items. Prefer percentage over amount so the same list survives a currency being added. Then run your real historical orders through it on paper and see what is left. What remains after this pass is the actual custom scope, and it is usually much smaller than the request that started the project.
Model the tiers as Dataverse configuration, not as code
Create two custom tables: a discount scheme, carrying the input type, the scope, the valid from and valid to dates, an optional customer or customer group, an optional product family, and a priority; and a discount tier, carrying the parent scheme, a from and to threshold, and a percentage or amount. Add an alternate key so an integration can update a tier idempotently, and put the tables in your own managed solution. Every threshold that lives in a source file becomes a deployment request, and every threshold that lives in a table becomes a change the commercial team can make on a Tuesday.
Decide which field your calculated discount is allowed to write to
The volume discount field on a line belongs to the system pricing engine while system pricing is on, so a custom aggregated discount is normally written to the manual discount field. That creates one problem you have to solve on purpose: a seller may also want to type a manual discount. Keep your own currency column for the scheme calculated amount and a second for the seller entered exception, then write the total of the two into manual discount, so the line still adds up and you can always report on which part of a discount was earned and which part was given away.
Build the aggregation in a synchronous plugin on the line entity
Register the plugin on create, update, and delete of the quote line, order line, and opportunity product, filtering the update registration to the columns that actually matter, which are quantity, product, unit, price per unit, and the pricing override flag. On each firing, retrieve the sibling lines of the parent record in one query, aggregate according to the scheme scope, select the tier, and write the discount back to every affected line rather than only the one that changed. This is the whole trick: a volume tier is a property of the set, so any change to any member has to reprice the set.
Cover the triggers that are not a line edit
Reprice on the header as well: a change of price list, of currency, or of customer changes the answer for every line beneath it. Handle the recalculate action on orders and invoices, and handle line deletion, which is the case most implementations forget and the one that silently leaves an inflated discount behind after a big line is removed. Respect the pricing override flag and locked records rather than overwriting them, and set the pricing error state on a line you deliberately could not price so a user can see why.
Make the logic recursion safe and idempotent
Writing to sibling lines from a line plugin re-enters the same registration, so check the execution depth and exit rather than relying on luck. Compute from the stored inputs every time rather than adjusting the previous answer, so running the logic twice produces the same result as running it once. Skip the update entirely when the computed value equals the value already on the record, which removes both the loop and a large amount of pointless audit noise.
Handle rounding and the residue explicitly
An order level discount split back across lines will not divide evenly, and a fraction of a currency unit lost on every line becomes a total that does not match what the customer was told. Decide where the residue lands, normally the largest line, apply it deterministically, and assert in a test that the sum of the line discounts equals the intended order discount exactly. Use the currency precision the record actually carries, not a hardcoded two decimal places, because price precision and currency precision are not the same setting.
Put promotions and customer agreements on the right mechanism
A time boxed promotion is a scheme with valid from and valid to dates evaluated at pricing time, not a discount list somebody has to remember to detach on the last day of the month. A customer specific agreement is either its own price list assigned as the account default, when the whole sheet differs, or a scheme scoped to that customer, when only the tiers differ. Use Power Automate to activate and deactivate schemes on schedule and to assign price lists to new accounts, and keep the evaluation itself in the pricing layer so an integration created order gets the same answer as a typed one.
Add the approval and margin floor as a separate concern
Once discount is calculated correctly, the commercial control on top of it is a threshold, an approval, and an audit trail. Enforce the hard floor server side, because a rule that only exists in a form script is bypassed by any import, and route the soft cases through a Power Automate approval that writes the decision and the approver back onto the record. That gives finance the answer to who authorised what, which the pricing engine on its own never records.
Build a pricing test matrix and keep it
Write the cases down as data: single line under a threshold, single line exactly on a boundary, two lines of the same product crossing a threshold together, mixed units, a deleted line dropping the order back a tier, an overridden price, a write-in product, a currency change on the header, and a promotion that expires between quote and order. Run them on every release wave and after every change to the schemes. Pricing is the one area where a silent regression is invisible to users and expensive on the invoice, so it is worth the fixture.
Ship it through managed solutions and document the scheme
Keep the tables, the plugin, the flows, and the configuration in a properly named solution with your own publisher prefix, promote it through development, test, and production rather than editing production, and export the scheme configuration as data so an environment can be rebuilt. Then write one page that says which programme is implemented where, because the next person to be asked why a customer got twelve percent needs to find the answer without reading the assembly.
The order level volume discount, in detail
This is the requirement that brings most teams to this page, so it is worth spelling out past the level of a numbered step. The rule to hold on to is that a volume tier is a property of the set of lines, not of any one line, which means every write has to treat the whole transaction as the unit of work.
Aggregate the set, then write to the set
The query is one retrieve of all lines on the parent transaction, not one per line. Sum the quantity or the extended value according to the scheme scope, converting units to the base unit where the tier is written in base units, then select the matching tier and compute the discount for every line that participates. Writing back to only the changed line is the single most common defect in a homegrown implementation, because it produces an order where the first six lines are tiered and the seventh is not.
Choose the aggregation scope from the scheme, not from the code path
The same plugin should serve a scheme scoped to one product across the order, to a product family, and to the order total, because those are configuration differences rather than different features. Read the scope from the scheme record and build the participating set from it. Schemes then get added by the commercial team without a deployment, which is the entire reason for putting the tiers in tables in the first place.
Resolve competing schemes with an explicit rule
Where more than one scheme matches, the platform gives you no answer, so state one: highest priority wins, or best for the customer wins, or they stack in priority order against the running net. Whichever you choose, record on the line which scheme produced the number. Without that column, nobody can ever explain a historical price, and explaining historical prices is most of what a pricing implementation is asked to do after go live.
Keep the pre-image and compare before writing
Register a pre-image with the columns your logic reads so the update path does not need a second retrieve, and compare the computed discount against the current value before calling update. Skipping the no-op write is what keeps a plugin on a fifty line order from turning one user edit into fifty database writes, fifty audit rows, and fifty more plugin executions.
Fail visibly, not silently
If a scheme is misconfigured, a currency is missing, or a tier has a gap the quantity falls into, do not quietly leave the discount at zero. Set the pricing error state on the line or write the reason to a column that a view can filter on, and alert on it. A pricing rule that fails open costs money on every order until somebody notices, and the whole point of moving the logic server side was that nobody has to notice.
// Order level volume tier, applied across every line of the parent quote.
// Registered synchronously on Create, Update and Delete of quotedetail,
// with an update filter on quantity, productid, uomid, priceperunit and
// ispriceoverridden, and a pre-image named "PreImage".
public void Execute(IServiceProvider serviceProvider)
{
var context = (IPluginExecutionContext)serviceProvider
.GetService(typeof(IPluginExecutionContext));
// Sibling writes re-enter this same registration. Exit instead of looping.
if (context.Depth > 1) { return; }
var factory = (IOrganizationServiceFactory)serviceProvider
.GetService(typeof(IOrganizationServiceFactory));
var service = factory.CreateOrganizationService(context.UserId);
var quoteId = ResolveParentQuoteId(context, service);
if (quoteId == Guid.Empty) { return; }
// One retrieve for the whole set. A volume tier is a property of the set,
// so every line has to be repriced whenever any line changes.
var lines = service.RetrieveMultiple(new QueryExpression("quotedetail")
{
ColumnSet = new ColumnSet("quantity", "priceperunit", "productid",
"uomid", "ispriceoverridden", "manualdiscountamount",
"new_schemediscountamount", "new_sellerdiscountamount"),
Criteria = new FilterExpression
{
Conditions = { new ConditionExpression("quoteid", ConditionOperator.Equal, quoteId) }
}
}).Entities;
var scheme = _schemes.SelectFor(quoteId, lines); // configuration, not code
if (scheme == null) { return; }
var basis = scheme.InputIsValue
? lines.Sum(l => LineNet(l))
: lines.Sum(l => BaseUnits(l));
var tier = scheme.TierFor(basis); // null means no tier reached
var precision = CurrencyPrecisionFor(quoteId, service);
// Split, round, and give the residue to the largest line so the sum of the
// line discounts equals the intended order discount exactly.
foreach (var allocation in Allocate(lines, scheme, tier, precision))
{
var line = allocation.Line;
// Never touch a line a human has taken control of.
if (line.GetAttributeValue<bool>("ispriceoverridden")) { continue; }
var seller = line.GetAttributeValue<Money>("new_sellerdiscountamount")?.Value ?? 0m;
var total = allocation.Amount + seller;
// No-op writes are the difference between one update and fifty.
if (line.GetAttributeValue<Money>("manualdiscountamount")?.Value == total) { continue; }
service.Update(new Entity("quotedetail", line.Id)
{
["new_schemediscountamount"] = new Money(allocation.Amount),
["new_appliedschemeid"] = scheme.ToEntityReference(), // explain it later
["manualdiscountamount"] = new Money(total)
});
}
}The shape above keeps the system pricing engine switched on, so price per unit, unit conversion, rounding policies, and the native per line discount lists all keep working and only the aggregation is yours. That is the difference between a few hundred lines of tested code and owning the whole pricing surface. Building and maintaining exactly this kind of server side logic, registered against the Dataverse event pipeline so it holds for a form, an import, and an integration alike, is what our Dynamics 365 and CRM developers do: C# plugins and custom API, the Dataverse data model behind them, and the managed solution pipeline that gets them into production safely.
When full custom pricing is genuinely the answer
There is a real case for taking the engine off, and it is narrow: the price itself comes from somewhere that is not a price list, such as an external rating service, a contract management system, or an in-house calculation whose inputs are not a product and a quantity. If that is your situation, the mechanism is the CalculatePrice message, and the thing to be clear about is how much you are taking on.
// Option C only. Register synchronously on the CalculatePrice message, and
// set System Settings > Sales > "Use system pricing calculation" to No.
// From that moment the platform stops pricing anything: price per unit, unit
// conversion, rounding and the pricing error codes are all yours.
public void Execute(IServiceProvider serviceProvider)
{
var context = (IPluginExecutionContext)serviceProvider
.GetService(typeof(IPluginExecutionContext));
// The message hands you the record being priced. Line entities and their
// parent transaction both arrive here, so branch on the logical name.
if (!context.InputParameters.Contains("Target")) { return; }
var target = (EntityReference)context.InputParameters["Target"];
switch (target.LogicalName)
{
case "quotedetail":
case "salesorderdetail":
case "invoicedetail":
case "opportunityproduct":
PriceLine(serviceProvider, target); // priceperunit, tax, extendedamount
break;
case "quote":
case "salesorder":
case "invoice":
case "opportunity":
PriceHeader(serviceProvider, target); // roll up, then the order discount
break;
}
}
// Before choosing this option, be honest about the reason. If the requirement
// is "our volume tiers look at the whole order", Option B keeps the working
// half of the engine and is a fraction of the surface area. Turn system
// pricing off only when the price itself comes from somewhere else.Promotions, customer agreements, and where Power Automate belongs
A promotion is a scheme with dates, not a discount list somebody detaches on the last day of the month. A customer specific agreement is either its own price list, when the whole sheet differs and it can be set as the account default, or a scheme scoped to that customer, when only the tiers differ. Both of those evaluations belong in the pricing layer, because a rule that only runs when a user is looking at a form is not a rule, it is a suggestion that an integration will ignore.
What genuinely belongs in a cloud flow is everything around the decision. Activating and retiring promotional schemes on a schedule. Assigning the right agreement price list to a new account when it is created or when its segment changes. Routing any line whose discount crosses a threshold into an approval that writes the approver and the reason back onto the record, so finance has the answer to who authorised what. And a scheduled audit that compares the realised discount on recent orders against the scheme that should have produced it, so a misconfigured tier is caught by a report rather than by a customer.
The dividing line is latency and guarantee. A flow is asynchronous, so there is always a window in which the record shows one number and the rule says another, and anything that writes the record faster than the flow runs will win. That is fine for an approval and fatal for a price. We go through the orchestration side of this, including approval chains, error handling, and monitoring for flows that have to be trusted in production, on our Power Automate consulting page, which also covers the business process automation pattern this uses: the state of the process lives in Dataverse rows, flows move it along, and the rules that must always hold sit in server side code rather than inside a running flow.
One more design note worth making early. Where an agreed price or a rebate already exists in your finance system, resist the instinct to reimplement the arithmetic in the CRM. Get the agreed prices into Dataverse on a schedule, as price list items or as scheme records with an alternate key so the sync is idempotent, and let the CRM apply them rather than derive them. Two systems calculating the same price from the same inputs will disagree eventually, and the day they do is the day somebody sends a customer the wrong number.
Frequently Asked Questions
How do volume discounts work in Dynamics 365 Sales?
You create a discount list, which is typed once as either percentage or amount, and add discount rows to it that each carry a begin quantity, an end quantity, and the value that applies inside that range. You then attach that discount list to a price list item, which is the row joining one product and one unit to a price inside one price list. When a line is priced, the engine reads the quantity on that line, finds the matching range, and writes the result into the volume discount field on the line. Extended amount is then amount less volume discount, less manual discount, plus tax.
Can Dynamics 365 apply a volume discount across the whole order?
Not out of the box. Because a discount list is attached to a price list item, the only quantity in scope is the quantity on the single line being priced. Lines are never aggregated, not even two lines carrying the same product, and there is no setting that changes this. The order level discount fields on a quote or order exist, but nothing in the platform populates them from a rule, so they are a manual entry. Aggregated volume pricing therefore requires server side logic, normally a synchronous plugin on the line entity that reads all lines on the parent record, selects the tier from the total, and writes the discount back to every participating line.
What is the difference between a price list and a discount list in Dynamics 365?
A price list is a currency scoped container of prices, and its price list items decide what one product costs per unit, using a fixed amount, a percentage of the list price, or a markup or margin from cost. A discount list is a set of quantity ranges that reduce that price once a line reaches a threshold, and it is attached to a price list item rather than existing on its own. In short, the price list sets the price and the discount list reduces it based on how many the customer buys on that line.
Why is my Dynamics 365 volume discount not being applied?
The usual causes, in the order we check them: the discount list is not attached to the price list item being used, the transaction is on a different price list than the one you configured, the quantity falls into a gap between ranges, the unit on the line is not the unit the price list item is for, the line has its pricing set to override so the engine leaves it alone, the record is locked because it has been invoiced or closed, or the lines were created before the discount list existed and nothing has triggered a recalculation since. Existing lines keep the numbers they were given until a save or the recalculate action reprices them.
Can you tier a discount on order value rather than quantity in Dynamics 365?
Not natively. Begin quantity and end quantity on a discount row are counts, so any rule whose input is money, weight, contracted annual value, or a count of product families is outside the built in engine. The supported approach is to hold the tiers in your own Dataverse configuration tables, with the input type and the scope as columns, and evaluate them in a synchronous plugin on the transaction lines. That keeps the tiers editable by the commercial team without a deployment and keeps the rule correct for records created by an import or an integration as well as by a user.
How do you implement time limited promotional pricing in Dynamics 365 Sales?
A discount list has no dates on it, so a promotion cannot be expressed as a discount list that starts and stops. There are two supported patterns. Build the promotion as a separate price list with its own start and end dates and switch the transactions or the customer defaults onto it, which suits a whole sheet of promotional prices. Or hold the promotion as a scheme record in your own configuration table with valid from and valid to columns and evaluate the dates at pricing time, which suits a temporary uplift on top of normal pricing. Power Automate is the right tool for activating and deactivating on schedule, and the evaluation itself belongs in the pricing layer so an integration created order gets the same answer as a typed one.
Should custom pricing logic go in a plugin or in Power Automate?
The pricing decision belongs in a synchronous plugin, because it has to be correct on the record the moment it is saved and it has to hold for a form, a bulk import, and an integration alike. A cloud flow is asynchronous, so a rule that lives only in a flow leaves a window in which the record shows the wrong number, and it can be bypassed by whatever writes the record fastest. Power Automate is the right tool for the work around the decision: approvals above a discount threshold, activating promotional schemes on a date, assigning agreement price lists to new accounts, and a scheduled audit that flags lines whose realised discount does not match the scheme that should have produced it.
What is the CalculatePrice message and when should you use it?
CalculatePrice is the message the platform raises when it prices a transaction or a line. If you turn off system pricing calculation in the Sales system settings and register a synchronous plugin on that message, the platform stops taking pricing decisions and hands the record to your code instead. It is the right answer when the price genuinely comes from somewhere else, such as an external rating engine or a contract system. It is the wrong answer for a volume tier problem, because you inherit everything that already worked, including price per unit lookup, unit conversion, rounding, and the pricing error handling that tells a user why a line could not be priced.
How do you stop a custom pricing plugin from looping?
An order level tier means writing to sibling lines from a plugin registered on those same lines, which re-enters the registration. Check the execution depth and exit above the first level, filter the update registration to the columns that actually change the answer, compute from stored inputs so the logic is idempotent, and skip the update entirely when the computed value equals the value already on the record. That last check is also what stops a fifty line order turning one user edit into fifty writes, fifty audit rows, and fifty more plugin executions.
Do customer specific price agreements need a price list per customer?
Only when the whole sheet differs. A price list per customer is easy to create and expensive to maintain, because every product change has to be reissued across all of them. The maintainable pattern is one price list per genuinely distinct commercial agreement, set as the default price list on the accounts it covers, plus a discount scheme scoped to a customer or a customer group where only the tiers differ rather than the base prices. Where the agreed price already lives in your finance system, the design question is how it reaches Dataverse and how often, not which system should own it.
Can Solzet build complex pricing and discount logic in Dynamics 365 Sales?
Yes. It is one of the most common reasons we are asked to write server side code on a Sales implementation. We configure the catalogue, units, price lists and native discount lists first so the custom scope shrinks to what genuinely needs it, model the tiers as Dataverse configuration rather than as code, build the aggregation as a tested synchronous plugin, and put approvals and promotional scheduling in Power Automate. Everything ships through managed solutions with a pricing test matrix that gets rerun on every release wave. We work directly with mid-market companies and on a white-label basis for other Microsoft partners.
Pricing rules your CRM cannot express?
Send us the discount schemes as they are actually written in your agreements. We will tell you which parts are configuration, which parts need server side logic, and which parts should never have been in the CRM at all, then build the ones that should be. Directly, or white-label as part of your delivery team.