---
title: "How do you handle volume discounts that differ by region and by product?"
blueprint: 015
slug: volume-discounts-by-region-and-product
category: Pricing
revised: 2026-09-18
platform: Coevera CRM (formerly Pipeliner CRM)
canonical: https://blueprints.coevera.com/blueprints/volume-discounts-by-region-and-product/
publisher: Coevera
customer_data: none
---

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

**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.

## 01 · The 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.

## 02 · Why 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.

## 03 · Data model

Four concerns, four different homes. The foundation is covered in [Blueprint
007](https://blueprints.coevera.com/blueprints/pricing-one-product-many-prices/) — 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".

| Concern | Where it lives | Native? |
|---|---|---|
| Which price list applies | A `rules` filter on the price list, testing any field in scope | **Yes** |
| The list price | One price-list row per product per list | **Yes** |
| The volume break schedule | Discount-percentage fields on the **product**, one per band per region | **No** — modelled |
| Quote-only exception | A checkbox on the product, one per region | **No** — 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:

| Question | Modelled as | What it means when absent |
|---|---|---|
| Is it sold in this region at all? | A multi-select availability field on the product | Not offered there |
| Does it have a list price here? | The existence of a price-list row | **Ambiguous** — quote-only, or nobody loaded it |
| Is it deliberately quote-only here? | A per-region price-on-request checkbox | It 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

| Measure | Count |
|---|---|
| Products in the catalogue | **1,637** |
| Price rows, region A | 788 |
| Price rows, region B | 1,193 |
| Quote-only listings carrying a flag instead of a price | **709** (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.

## 04 · Field-level configuration

### Price-list properties worth knowing

| Property | Values | Why it matters |
|---|---|---|
| `rules` | A field filter | **The selection mechanism.** See below |
| `type` | `Standard` \| `Scheduled` | A scheduled list is the mechanism for a dated price change |
| `status` | `Active` \| `Inactive` \| `Scheduled` \| `Expired` | Derived state — a list can age out on its own |
| `startDate`, `endDate` | Dates | Bound a list to a price year |
| `isDefault`, `isActive` | Booleans | The 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:

| Field | Type | Holds |
|---|---|---|
| `cf_{region}_disc_10_49` | integer | % off list at 10–49 units |
| `cf_{region}_disc_50_99` | integer | % off list at 50–99 units |
| `cf_{region}_disc_100_plus` | integer | % off list at 100 or more |
| `cf_{region}_price_on_request` | checkbox | Quote-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.

## 05 · Automation & 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](https://blueprints.coevera.com/blueprints/keeping-hundreds-of-automations-maintainable/).

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](https://blueprints.coevera.com/blueprints/pricing-one-product-many-prices/) 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.

## 06 · Limits & 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](https://blueprints.coevera.com/blueprints/pricing-one-product-many-prices/). 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](https://blueprints.coevera.com/blueprints/pricing-one-product-many-prices/)'s
territory rather than this one.

## 07 · Verification

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.

## Related blueprints

- [Blueprint 007 — How do you price one product differently per region, segment or
  contract?](https://blueprints.coevera.com/blueprints/pricing-one-product-many-prices/) — the
  price-list model everything here is built on, and the API's refusal to resolve a price.
- [Blueprint 011 — How do you keep hundreds of CRM automations
  maintainable?](https://blueprints.coevera.com/blueprints/keeping-hundreds-of-automations-maintainable/)
  — first-match-wins filters, and why volume bands are the case where that is exactly what you
  want.
- [Blueprint 005 — How do you migrate contacts from another system without silently losing
  data?](https://blueprints.coevera.com/blueprints/contact-migration-without-data-loss/) — why a
  catalogue import needs a fill-rate check, and how silently a value can fail to land.
- [Blueprint 003 — How do you model something your CRM has no object
  for?](https://blueprints.coevera.com/blueprints/where-should-this-data-live/) — the framework
  for deciding that a break schedule belongs on the product rather than in a new entity.

---

Published by Coevera. Abstracted to the reusable pattern — no customer names, no client
data, no personal data.
