CoeveraBlueprints

Blueprint 015 · Pricing

How do you handle volume discounts that differ by region and by product?

Price lists can be filtered onto the quotes they apply to, but somebody still picks one — and a price-list row holds exactly one price. Volume breaks live in the gap.

Revised 2026-09-18Verified against a live 1,637-product catalogueMarkdown twin ↓

The short answer

A price-list row holds exactly one price. Its entire settings object is an access level and a role list — there is no minimum quantity, no tier, no break anywhere in it. So quantity discounts are not native and you model them.

Which lists are on offer, though, is highly configurable. Any number of price lists can be valid at once, and each carries a rule — labelled price-list availability — that decides whether it appears in the price-list dropdown on the Products & Services panel. The rule tests any field on the quote, the opportunity or the account.

It governs availability, not application. The dropdown defaults to "Without price list", and a person still chooses. Until somebody does, every line is added at a price of zero — which makes the most common pricing failure a default rather than an edge case.

And of every dimension a rule can narrow the list by, quantity is not one: quantity is a property of the line item, not of any record the filter sees. So the working shape is a discount schedule on the product, stored as percentages, plus quote-only products flagged rather than priced at zero. The percentages matter more than they look: they are what lets a price update happen without rebuilding every tier.

01The business problem

A manufacturer sells a large catalogue through regional distributors. Three things vary at once, and they interact:

  • Region. The same item costs differently in two territories, and the two regions do not stock an identical catalogue.
  • Quantity. Buy ten and you pay one price; buy a hundred and you pay another. The break schedule is not uniform — it differs per product.
  • Exceptions. Some items have no list price at all. They are quote-only, and which ones varies by region.

A distributor building a quote should get the right number without knowing any of this. And when the annual price update lands, it has to be a data load — not a project.

That last sentence is the requirement that quietly decides the whole design, and it is usually the one nobody states.

02Why the obvious approach fails

A price list per region per quantity band

The natural reach, once you know a price list gives you regional pricing, is to make more of them: Europe 10–49, Europe 50–99, Europe 100+, and the same again for every other region. It fails for a reason that is structural rather than cosmetic:

Nothing could route to it. A price list's availability rule filters on fields of the quote, opportunity and account. Quantity lives on the line item, which no rule evaluates — so the band could never even narrow the dropdown. And one price list applies to the whole record, so a person could not pick per line either. A list-per-band scheme has no mechanism at its centre.

The maintenance arithmetic is the second problem, and it compounds. Because a list can be made available on almost any condition, a real catalogue tends to accumulate them — territory, channel, a couple of negotiated agreements. Multiply whatever that number is by the bands: on a 1,637-product catalogue, two regions times four bands is already eight lists and over ten thousand price rows to keep mutually consistent through every repricing. It also turns the dropdown into a list of near-identical names a distributor has to choose correctly every time.

Storing absolute tier prices

Wherever the tiers end up living, the instinct is to store the tier price — because that is what the source spreadsheet holds. It makes every future price change a rebuild: the list price moves, and each tier for that product must be recomputed and re-entered. In practice that does not happen, and the tiers quietly drift into describing last year's prices.

Store the percentage instead. The list price moves, the tier follows automatically, and the annual update is one import into one price list.

Pricing the unpriceable at zero

Quote-only items have no price, and the tempting shortcut is a price row of 0. A zero is a number: it flows into the line total, into the quote value, into the forecast, and it looks entirely legitimate the whole way. Leaving the row out instead is better but still not enough, because a missing price row is indistinguishable from one nobody has configured yet — and on a catalogue this size, plenty are simply missing.

A discount on the whole quote

There is a native quote-level discount, both as a percentage and as an amount. It is the right tool for a negotiated concession on a deal, and the wrong one here, because it cannot say this line qualifies for a volume break and that line does not. Volume pricing is a per-line fact.

03Data model

Four concerns, four different homes. The foundation is covered in Blueprint 007 — a product carries no price; the price lives on a join row between the product and a price list — and everything here sits on top of that. This example prices by region, but nothing in the mechanism is regional: read "region" below as "whichever dimension separates your price lists".

ConcernWhere it livesNative?
Which price list appliesA rules filter on the price list, testing any field in scopeYes
The list priceOne price-list row per product per listYes
The volume break scheduleDiscount-percentage fields on the product, one per band per regionNo — modelled
Quote-only exceptionA checkbox on the product, one per regionNo — modelled

The price-list row is the whole constraint

A price-list row is a product, a price list, a currency, a price — and a settings object whose complete contents are an access level and an optional role list. There is no minimum quantity, no maximum, no tier collection, no break table. One row, one price. Every design decision below follows from that single fact.

Three per-region facts that are not the same fact

The exception handling only works once you notice these are distinct, because conflating any two of them produces silent wrong numbers:

QuestionModelled asWhat it means when absent
Is it sold in this region at all?A multi-select availability field on the productNot offered there
Does it have a list price here?The existence of a price-list rowAmbiguous — quote-only, or nobody loaded it
Is it deliberately quote-only here?A per-region price-on-request checkboxIt should have had a price

The third one exists precisely to disambiguate the second. With it, a missing row plus an unticked box is a data-quality alert; without it, the same state is invisible.

The shape this produced on a real catalogue

MeasureCount
Products in the catalogue1,637
Price rows, region A788
Price rows, region B1,193
Quote-only listings carrying a flag instead of a price709 (370 + 339)

Roughly a quarter of all region listings have no price by design. That proportion is why the exception mechanism is not an edge case here — it is a first-class part of the model.

04Field-level configuration

Price-list properties worth knowing

PropertyValuesWhy it matters
rulesA field filterThe selection mechanism. See below
typeStandard | ScheduledA scheduled list is the mechanism for a dated price change
statusActive | Inactive | Scheduled | ExpiredDerived state — a list can age out on its own
startDate, endDateDatesBound a list to a price year
isDefault, isActiveBooleansThe shipped default list arrives inactive

How a price list becomes available

The rules filter has the same anatomy as filters elsewhere in the platform: an enabled flag, a group operator, a main filter box naming an entity, and sibling boxes for related entities. In a working two-region configuration each list carries:

  • isEnabled: true, operator And;
  • a main box on Opportunity with no conditions;
  • one sibling box on Quote with exactly one condition — a Region dropdown, operator Is, matching that list's own region option.

Both lists filter the same field and claim a different option of it, so setting the region on the quote narrows the dropdown to one sensible choice. The list the user then picks is recorded on the quote in a native price-list relation, so the quote carries a permanent record of which prices it was built from.

The rule governs availability, not application. Its interface label is price-list availability, and that is exactly what it does: it decides which lists appear in the dropdown. The dropdown defaults to "Without price list" and a person chooses. No rule applies a list on its own, and no field you can set will price a quote for you.

Nothing about it is regional, either — a region dropdown is what this catalogue happened to filter on. The condition-field picker offers the quote, the opportunity and the account, so availability can key on an account tier, a contract type, a partner flag, an opportunity attribute. Those three entities are the whole scope: the rule does not reach beyond them.

And of everything it can test, quantity is absent — quantity belongs to the line item, and no record the filter evaluates carries it. That is the entire reason volume pricing cannot live in a price list, and it is the hinge the rest of this blueprint turns on.

What happens when a list is chosen

  • Before selection: the panel reads "Without price list" and every product added lands at a price of zero.
  • After selection: newly added products take their price from that list.
  • On selection: the interface asks whether the lines already on the record should be re-priced from the newly chosen list — so switching lists mid-quote is a prompted action, not a silent recalculation.

The panel also carries its own currency selector and a product-sync toggle, and it exists on Opportunity and Quote only. Any workflow needing priced line items on another entity has to route through one of those two.

Why the main box is empty. The condition that matters is on the quote, so the opportunity-level box carries nothing. An empty main box is not a misconfiguration here — it is what "no condition at this level" looks like.

The discount schedule on the product

Six integer fields — three bands, two regions:

FieldTypeHolds
cf_{region}_disc_10_49integer% off list at 10–49 units
cf_{region}_disc_50_99integer% off list at 50–99 units
cf_{region}_disc_100_plusinteger% off list at 100 or more
cf_{region}_price_on_requestcheckboxQuote-only in this region

Two properties of this layout are deliberate and one is a cost you accept:

  • Per product, not global. The schedule is a product attribute, so a product with no volume break simply has empty fields — no exception list required.
  • Percentages, not prices. A repricing is an import into the price list and nothing else. The schedule never needs touching.
  • The band boundaries are in the field names — which means they are schema, not data. Changing 50–99 to 50–149 is a field rename plus a migration, and every report, form and process referring to it. §06 returns to this.

On the line item

The line item natively carries quantity, price, amount, discount_percentage and discount_value. A working configuration adds one custom field, a net unit price, so the discounted figure is stored explicitly rather than inferred from the others later.

One practical note observed on that field: its label and its api_name had drifted apart, the api name still carrying a region suffix from an earlier design the label no longer mentions. Labels are editable and api names are what integrations, imports and process templates bind to — so the api name you create a field with is the one you live with. Name it for what it is, not for the first place it was used.

05Automation & logic

What has to be computed

Everything above is data model. The one piece of logic is: given a line's quantity and the quote's region, find the right band and apply it. In sequence — read the quantity from the line item; select the band field matching the quote's region; if it holds a percentage, write it to the line's discount percentage, or compute and write the net unit price.

Stated plainly: this calculation was not built in the space examined. The catalogue, the price lists with their region rules, the six schedule fields, the price-on-request flags and the net-unit-price field are all in place and populated; no process computes the band. The design below is what the primitives support, and it is not behaviour-verified.

Why this one is a single process

Most automation on this platform fragments into several processes, because a process carries one filter whose branches are first-match-wins, and independent conditions therefore cannot share it — the constraint behind Blueprint 011.

Quantity bands are the opposite case. They are mutually exclusive: a line of 60 units is in exactly one band. So first-match-wins is not an obstacle, it is the desired semantics, and one filter with its branches ordered from the largest band down is the correct and complete shape. Order them descending and the first match is always the right one.

The exception branch that is easy to forget

A price-on-request product must not silently receive a break — it has no list price to discount. The band filter needs a preceding branch on the price-on-request flag that routes the line to a human instead. Without it, a quote-only item on a large line quietly takes a percentage off a price that does not exist.

Precedence, which has to be decided rather than discovered

The quote also has a native whole-quote discount. Once a line carries a volume break, two discounts are in play, and nothing in the platform decides whether they stack, whether the larger wins, or whether a negotiated quote discount replaces the schedule. Decide it, write it into the process, and put it in the quote template — otherwise every distributor answers it differently and the margin reporting is meaningless.

What the price list will and will not do for you

The availability rule decides which lists are offered — it does not price anything. A person selecting a list is what prices new lines in the interface, and Blueprint 007 documents the equivalent on the API path: a line created without an explicit price lands at zero regardless of what the product costs on any list. Both routes converge on the same failure, so any import, integration or automation that builds lines must set the price itself.

06Limits & trade-offs

There is no native quantity break. A price-list row's settings object contains an access level and a role list, and nothing else. No minimum quantity, no tier collection, no break table. Every volume-pricing design on this platform is therefore a build, and the question is only which build.

  • Band boundaries are schema. Because each band is a field, the boundaries are baked into field names and forms. Adding a band or moving a boundary is a field change, a data migration and an edit to everything that references it — not a configuration change. Choose the bands with the expectation that they will outlive several price lists.
  • Every price list must share one band structure. A catalogue that arrives with five bands under one list and three under another cannot be represented faithfully: either you normalise to the common bands and lose the finer granularity, or you multiply the fields per list and the product form becomes unusable. The live model took the first option and dropped two low-quantity bands that existed in only one region. Note the asymmetry — selection scales freely with new lists, the discount schedule does not.
  • Quantity can never reach a price list. The availability rule evaluates fields on the quote, the opportunity and the account; quantity is a line-item property. Nearly every other dimension is open to you — this one is not, which is the structural reason the whole approach lives on the product rather than in the price list.
  • Zero is the default, not the exception. The panel opens on "Without price list", so a record whose author never touched the dropdown prices every line at zero and looks finished. No rule prevents it, because rules only decide what the dropdown offers. Where quoting matters, treat "lines saved with no price list" as a validation failure and catch it deliberately.
  • Overlapping availability rules produce a choice, not a resolution. Several lists can be valid at once, so rules that both match leave the user facing two plausible options with no guidance — and re-picking prompts to re-price everything already on the record. Write the rules mutually exclusive.
  • A missing price row is ambiguous unless you make it otherwise. Absence means quote-only or unconfigured, and the platform cannot tell you which. The flag is what separates them — and it has to be maintained by whatever loads the catalogue, or it decays into the same ambiguity.
  • Two discounts can apply to one line and nothing arbitrates. The line-level break and the quote-level discount coexist with no defined precedence.
  • Scheduled price lists were not exercised. The type, status and date bounds are schema-verified but not behaviour-verified — how a Scheduled list becomes Active, and what happens to quotes referencing one that has Expired, was not observed.
  • The line item records no price provenance, per Blueprint 007. The quote records which list it used; the individual line does not record which price row it came from, so a later price change cannot be traced to the quotes it would have altered.
  • An api name is permanent in practice; a label is not. They drift apart, and the api name is the one every integration binds to.

The trade-off worth stating plainly

Putting the schedule on the product as percentages buys the thing that matters most over a multi-year life: the annual repricing stays a data load. New prices import into the price list, and the discount schedule, the exception flags and the automation are all untouched. What it costs is that band boundaries become schema, one structure has to serve every region, and the calculation is a build rather than a setting. For a catalogue in the thousands repriced yearly, that is a good trade. For a handful of products with bespoke tiers per customer, it is the wrong shape entirely — that is contract pricing, and it belongs in a per-customer price list, which is Blueprint 007's territory rather than this one.

07Verification

Read on 2026-09-16 from a live space holding a fully imported two-region distributor catalogue.

  • The constraint was confirmed at the schema level: a price-list row's settings object exposes exactly two properties, an access enum and a role list. No quantity, tier or break property exists anywhere on the price list or its rows.
  • Catalogue and price rows reconcile exactly. 1,637 products; 788 price rows in one region, 1,193 in the other. Against the source files — 1,158 and 1,532 region listings, of which 370 and 339 were marked quote-only — both figures reproduce precisely: 1,158 − 370 = 788 and 1,532 − 339 = 1,193. The quote-only listings carry a price-on-request flag and no price row, which is what those two subtractions confirm.
  • The availability rules were read in full on both active lists, which were valid at the same time: enabled, operator And, an empty main box on Opportunity, and one sibling box on Quote carrying a single Is condition against a dropdown — the same field on both lists, each claiming a different option. The rule is an ordinary field filter, so the dropdown being a region is a property of this catalogue and not of the mechanism.
  • The price-list properties — Standard/Scheduled type, the four-value status, start and end dates bounding a price period, and an inactive shipped default list still holding rows from product creation — were read from the live records.
  • The discount schedule was read from the product schema: six integer percentage fields, three bands across two regions, plus two per-region price-on-request checkboxes and a multi-select availability field.
  • The line-item fields were read, including the native quantity, price, amount, discount percentage and discount value, and one custom net-unit-price field whose label and api name have diverged.

Not observed, and stated as such:

  • The band calculation. No process in the space computes a volume discount. The model is populated; the logic is not built. §05's design is what the primitives support, not something watched working.
  • Scheduled price-list behaviour — activation, expiry, and the effect on records already referencing a list.
  • Whether a line-level discount and a quote-level discount compound, and in which order.
  • Whether the price-list dropdown and the re-pricing prompt have any equivalent on the API path, where a line created without a price simply lands at zero.

What would signal a regression

  • Line items landing at a price of zero. The single most likely failure, and it looks like a real number all the way into the forecast. Check any import or automation that creates lines without setting a price explicitly.
  • Price rows outnumbering region listings after a repricing. Means the import created rows for quote-only products, and those products will now quietly receive volume breaks off a price that should not exist.
  • Records with lines but no price list recorded. Nobody moved the dropdown off "Without price list", so every line is at zero. This is the default state, not a rare fault, which is why it belongs in validation rather than in a monthly review.
  • A band field populated on a product whose price-on-request flag is ticked. A contradiction in the data that the exception branch in §05 exists to catch.
Published by Coevera · abstracted to the pattern, no client dataBlueprint 015 · rev 2026-09-18