Skip to content

Powered by Grav CMS + Helios Theme

Price lists and quantity tiers

A quantity tier is a price that depends on how many the customer is buying: one at 20.00, ten at 17.50 each. A price list is a named set of prices that applies to some shoppers and not others: a trade list, a wholesale list, a members' list. This page is for a store administrator setting either one up.

Important

Price lists and quantity tiers determine what the shopper is charged in the cart and checkout. The resolved price becomes the line's base price, then sales and coupons apply to it. Rules can target customer groups, including the default group for guests. A customer's own group, email and order count count only when they are signed in. See Customer groups.

Before you begin

  • The kahunacart.sales.manage permission on whoever manages pricing. It is the same permission a sale needs, and it does not include payment credentials or tax zones.
  • At least one product with a variant to price. See Products and catalog.

What is wired in this release

Part Works today
The admin screens (KahunaCart → Price lists, both tabs) Yes
The REST API endpoints and the seven MCP tools Yes
The resolver, and the published selection algorithm it follows Yes
A price-list save queuing products for reindexing Yes
The cart and checkout charging the resolved price Yes
A rule on customer_group resolving a group Yes

The cart and checkout use the published resolver, which is also available to the catalog index and add-ons. Repricing applies to carts; placed orders keep their agreed prices.

What a price list is

A price list is a header, a set of rules, and a set of prices.

A sale is a scheduled discount for every shopper: it computes a reduction against the price the variant already had, puts a badge on the product card, and feeds the On sale filter. A price list names the shoppers it applies to and charges them a different price: it states the figure, draws no badge, and appears in no catalog facet.

Sale Price list
What it stores A reduction (percent or amount off) A price
Who it applies to Every shopper The shoppers the rules name
Catalog badge Yes No
In the On sale filter Yes No
Quantity breaks A tiered discount off the base price Tiered price rows
Stacks with a coupon Configurable per sale Yes — the coupon applies to the resolved price after any sale

A store running sales and no price lists behaves in every way it did before.

The header

The header says when the list is live and how it behaves against other lists.

Field What it does
name What you call the list in the admin — "Trade, EU". Never shown to a customer.
kind override or sale. See The two kinds of list.
priority Higher wins when two lists match the same variant equally well.
starts_at UTC epoch seconds, inclusive. Blank means "since always".
expires_at UTC epoch seconds, exclusive. Blank means "until you turn it off".
currency The currency the list's prices are in, chosen when the list is made and fixed after. A list is read only for a cart in its own currency, so a EUR cart never sees a list of dollar prices. See In another currency.
active Off means the list does nothing, whatever its dates say.

The rules

The rules decide who the list applies to. Each rule is one comparison, an attribute, an operator and a value, written against the same published vocabulary a sale's conditions use.

Field What it does
attribute A key from the published rule vocabulary. An attribute nobody publishes is refused when the list is saved.
operator eq, in, gte and the rest, limited to the ones the attribute's type offers.
value Your side of the comparison. A list survives the round trip, so "one of these three products" is one rule.

A list's rules are joined with and: every one must match. There is no nesting and no any container. A list with no rules applies to everyone. See Rules and conditions.

A rule on the shopper's email (customer_email or customer_email_domain) needs an email to read. A signed-in customer's cart is in their name from its first line, so the list applies in the cart and on the checkout page. A guest's email never counts, even once they type one at checkout, because an address proves nothing about who typed it. Anybody could type an @acme.com address to get an "Acme employees" list. So an email rule needs the customer signed in. For a known list of people, use a coupon with allowed_emails, or a customer group.

The prices

The prices are what the list charges. Each names a variant and the quantity range it covers.

Field What it does
variant_id The variant this price is for. Through the API, sku can name it instead. See Naming a variant by SKU.
min_quantity The minimum quantity for this tier. 1 for a plain price with no quantity break.
max_quantity The last count it covers. Blank for a highest tier with no upper limit.
amount_minor The unit price in minor units. Not a discount: the price.
currency The currency of amount_minor, carried in the row's unique key.

Important

A tier is a price row, not a discount. amount_minor is what one unit costs at that quantity, so "ten for the price of eight" is written as the per-unit figure and never as a percentage off something else. Nothing rounds and nothing is derived.

The two kinds of list

Kind Supplies the effective price Supplies the reference price
override Yes Yes
sale Yes No

The reference price is the number a storefront strikes through. An override list is what this variant costs for this shopper, so a trade customer sees the trade price with nothing struck through beside it. A sale list is a temporary cut, so the price it is measured against comes from elsewhere. Otherwise the strikethrough would read "was 17.50, now 17.50".

Quantity tiers on a variant

A variant can have its own quantity tiers without a price list. These "buy ten and save" prices apply to every shopper. Each tier has the same columns as a price-list price row. Price-list prices take precedence over variant tiers, which take precedence over the variant's base price.

Each tier has a min_quantity, an optional max_quantity, and a unit price. The tier applies when the quantity falls within its range. Leave the highest tier's max_quantity blank so it covers every larger order.

Worked example: a 20.00 widget

Three quantity tiers on one variant:

Rung min_quantity max_quantity Unit price
1 1 9 20.00
2 10 49 17.50
3 50 (blank) 15.00
Quantity bought Rung matched Unit price Line total
9 1 20.00 180.00
10 2 17.50 175.00
12 2 17.50 210.00
50 3 15.00 750.00
400 3 15.00 6,000.00

A gap between tiers is allowed. Remove tier 2 from the example above and a quantity of 12 falls in the gap, so it is priced at the variant's own price of 20.00.

Note

Quantity tiers apply when the cart is recalculated. Changing the quantity selects the matching tier again; falling into a gap restores the catalog price quoted when the line was added.

The selection algorithm

This is the published, deterministic order that decides which price the resolver answers with. Each sentence has a test named after it in the plugin's suite.

Given a variant, a quantity and a moment:

  1. A price list is live when active is on and the moment falls inside its window. starts_at is inclusive, expires_at is exclusive, and either may be blank.
  2. A live list applies when every one of its rules matches. A list with no rules applies to everyone.
  3. A rule written against an attribute the vocabulary does not publish is refused when the list is saved. Nothing is skipped at resolution time and nothing is logged instead of applied.
  4. A list is a candidate for a line when it applies, its currency is the cart's currency, and it holds a price row covering the quantity being bought.
  5. Among candidates, a list carrying an exact rule, a rule on line_variant or line_product naming this line's own variant or product, beats one that matches only on broader rules.
  6. Where both or neither carry an exact rule, the list matching the most rules wins.
  7. A remaining tie breaks on the higher priority, then on the lower list id.
  8. Inside the winning list, the price row covering the quantity supplies the price. Where two rows cover it, the one with the higher min_quantity wins.
  9. With no candidate list, the variant's own quantity tiers supply the price for that quantity. With no covering tier, the variant's own price does.
  10. The reference price is what sentences 1 to 9 would answer if no sale-kind list existed.
  11. A reference price at or below the effective price is not a reference price, and none is returned.

Why is this the price?

Every decision the resolver makes carries the reasoning that produced it: the candidate lists it considered, the list it chose, why that list won, how many rules matched, the quantity range of the row it used, and both amounts. The reason is one of only_list, exact_rule, most_rules, priority, list_id, base_tier or base_price.

That record crosses the onKahunaCartResolvePrice event, so an add-on can log it, mirror it or report on it. A listener may observe the decision and cannot change it. See the Provider developer guide.

Customer groups

Customer groups exist, and customer_group is the vocabulary attribute that names one. A group-scoped price list is an ordinary price list with an ordinary rule on it: attribute customer_group, operator is or is one of, value the group's code. The selection algorithm and the condition vocabulary are unchanged.

Point a rule at the code, never at the id and never at the label. Codes are normalised on write, lower-cased with non-word characters folded to hyphens, so "Trade Customers" is stored as trade-customers.

A store may mark one group as the default. It stands in for anybody who is in no group, a guest included, so a list ruled on the default group applies to a logged-out visitor as well.

A customer's own groups count only when they are signed in. That goes for sales, coupons, payment methods and checkout fields too. A Trade customer has to sign in to get Trade prices. A guest who types a Trade member's email at checkout gets the default group's lists. The exception is an order your staff built: a till sale, or an order made in the admin. The customer staff attached counts as if they had signed in. See Whose customer data a rule reads.

A rule that names an empty group or an unknown group code matches no customers. It raises no error and writes nothing to the log.

Important

A group-scoped list changes the cart price for shoppers in that group. A group's flat Discount (%) is the simpler option: it comes off everything in the cart like a sale, but product pages show the ordinary price. Use a price list when members should see their prices while they browse. See Customers → A group's discount.

Create a price list

  1. In the admin sidebar, click KahunaCart, then Price lists.
  2. Click New price list.
  3. Enter a Name for yourself. Nothing here is shown to a customer.
  4. Choose the kind. Pick Override for a trade or wholesale list, Sale for a temporary cut you want struck through.
  5. Set the window. Leave the start blank to begin immediately; leave the expiry blank for a list you will end by hand.
  6. Add the rules that decide who the list applies to. Leave the rules empty for a list that applies to everyone.
  7. Add the prices. One row per variant per quantity range, lowest quantity first. Each row picks its variant in two steps: the product, then the variant by its option and SKU ("Large · LS-L"). A product with only one variant picks it by itself.
  8. Click Save.

The list is live from the moment it is saved, subject to its window and rules, and applies when eligible carts are recalculated.

Add quantity tiers to a variant

  1. In the admin sidebar, click KahunaCart, then Price lists.
  2. Open the Quantity tiers tab.
  3. Pick the product, then the variant by its option and SKU. Its tiers load as soon as it is picked.
  4. Add a tier for each quantity break: a min quantity, an optional max quantity, and the unit price at that quantity.
  5. Leave the highest tier's max quantity blank so it covers every larger order.
  6. Click Save.

Saving replaces the variant's existing quantity tiers. Saving an empty list removes them.

On a store with more than 200 products. The product pickers list the first 200 products, so each unpicked price row also has a box that takes a variant id or a SKU, and the Quantity tiers tab has one beside a Load button. A number is read as the id and anything else as the SKU. A SKU no variant has, or one two variants share, is refused with its name. The product editor prints each variant's id under its name in the variants table, if you need the id.

Priced add-ons and price lists

A priced add-on option in percent mode is recalculated against the resolved base price on every cart recalculation. A 10% engraving on a 20.00 product that a trade list prices at 10.00 costs 1.00. Fixed-amount add-ons keep their amount.

The line's original snapshot still records the catalog price and add-on amount quoted at add-to-cart. Repricing changes the calculation without rewriting that snapshot.

Note

A price list sets the base price. A sale then discounts that base, leaving add-on charges intact. A coupon applies to what remains after the sale, including add-ons. Shipping, payment-method fees and tax follow those calculations.

Price lists and bundle prices

A grouped product or a kit can be sold as a set at a bundle price, and a price list never changes that price. A list changes what the parts cost. So a trade customer whose parts are already cheaper saves the difference between the bundle price and their own parts, which can be nothing. A bundle price never raises a total.

Pay what you want and price lists

A price list never reprices a pay-what-you-want line. The shopper named that price, and a list names a price for a variant, so the list's price for the variant is ignored and the line keeps the amount the shopper typed. Quantity tiers don't apply either. Sales and coupons leave the line alone for the same reason.

What a price change does to the catalog index

Every write here marks the catalog index dirty, because the index carries a resolved price per row and a listing sorted or filtered on price reads the index rather than the variant table.

What you did What is queued for reindexing
Created, edited or deleted a price list Every product
Replaced a variant's quantity tiers That variant's product, and no other

A price list queues the whole catalog because its rules are evaluated against a cart, in PHP, so no query can say which products it might reprice. A set of quantity tiers belongs to one variant, so only that variant's product is queued.

The marking happens in the repository, at the write, so the admin screens, the API, a CLI command, an importer and an add-on all get it. The queue is drained by bin/plugin kahunacart work, or by hand with bin/plugin kahunacart index --dirty. A store with no index built has nothing to mark.

Turn pricing off

Set pricing.enabled to false on a store that uses neither price lists nor quantity tiers. The resolver then answers from the variant's own price without reading either price table. The default is true. See Configuration for the full key reference.

API reference

All price-list endpoints require kahunacart.sales.manage, the same permission a sale needs.

TXT
GET    /kahunacart/price-lists
POST   /kahunacart/price-lists
GET    /kahunacart/price-lists/{id}
PATCH  /kahunacart/price-lists/{id}
DELETE /kahunacart/price-lists/{id}
GET    /kahunacart/price-tiers
PUT    /kahunacart/price-tiers

A price list is always read and written with its rules and its prices. PATCH follows the same three-state contract a sale's rules do: omit rules or prices and they are left standing, send an empty list and they are removed, send a list and it replaces the whole set inside one transaction. There is no "add a rule" call.

GET /kahunacart/price-tiers takes variant_id and returns that variant's own quantity tiers. PUT /kahunacart/price-tiers replaces them: send variant_id and a tiers array, and an empty array removes the tiers. Both take sku in place of variant_id. On a store that sells in more than one currency both take currency as well, naming the currency of the ladder (the base when left out), and POST /kahunacart/price-lists takes currency to make the list in that currency. See A price list in another currency.

The API rejects invalid quantity tiers: a min_quantity below 1, two rows at the same count, a max_quantity below its own min_quantity, or two rows whose ranges overlap. It refuses a rule whose attribute is not published, and a rule whose operator that attribute does not offer, naming the key in both cases.

The same seven operations are available to an AI assistant as MCP tools: list_price_lists, get_price_list, create_price_list, update_price_list, delete_price_list, list_price_tiers and set_price_tiers. See REST API for authentication and conventions, and Run the store from an AI assistant for the tools.

Naming a variant by SKU

A price row can name its variant by sku instead of variant_id, and so can both tier routes and their MCP tools, because a SKU is what your spreadsheet already has. Send one or the other, never both: a row naming both is refused, since a row where the two disagree is a mistake whichever one is right.

A SKU typed exactly as it is stored is found first. Only when no variant has it exactly is it matched without regard to case. A SKU no variant carries is refused with a 422 that names it, and so is one two variants share, because nothing stops a catalog doing that and a price written against the wrong one would be a price nobody meant.

JSON
PATCH /kahunacart/price-lists/3
{ "prices": [
  { "sku": "LS-L", "min_quantity": 1, "amount": "18.00" },
  { "sku": "LS-L", "min_quantity": 10, "amount": "15.50" }
] }

Prices always come back with variant_id. The product editor prints each variant's id under its name in the variants table, for a script or an MCP tool that wants the id.

In another currency

On a store that sells in more than one currency, every price here is worked out in the cart's own currency. A price list row and a quantity tier carry a currency of their own, so a euro trade price is a euro row on the list, and neither uses the table that holds a variant's other-currency prices. The prices a variant, a fixed add-on, pay what you want and a sale have in other currencies are set where each is edited, and with typed pricing an empty one means "not for sale in that currency", never zero. A currency set to convert works an empty price out from the base price. Price lists and quantity tiers are never converted, because each already has a currency of its own. See Pricing a product in another currency. The catalog price a line starts from is the variant's price in that currency (its price_minor for the base currency, a row of kahunacart_currency_amounts for any other), and a variant with no price in the currency cannot be added to the cart: the shopper is told it is not available in that currency, and nothing is written. See Currencies.

  • Price lists and tiers carry their own currency. The resolver reads only the lists and price rows in the cart's currency, so a EUR cart is priced from EUR rows and a list of dollar prices does nothing to it. A tier ladder belongs to one currency too: the rungs in euro are a ladder of their own, and a variant with tiers in dollars only has no tiers in euro.
  • The frozen price is in the line's currency. The catalog price a line was added at is kept in snapshot_json.variant, and for a line in a currency other than the base the snapshot also says which one (snapshot_json.variant.currency). A line in the base currency has no such key, so its snapshot is byte for byte what it was before the store sold in anything else. A price stage that finds a snapshot in a different currency from its order does not trust it, and prices the variant again in the order's currency.
  • Option prices and the amount a shopper names follow the same rule. An option's price in another currency is its own row, and an option with none cannot be chosen there. A pay-what-you-want line's minimum is the variant's price in the currency, and its suggested and maximum amounts are the product's own rows (pwyw_suggested_minor, pwyw_max_minor under owner product_pwyw). With none, there is no suggestion (the box starts at the minimum) and no maximum, and the base amounts are never used, because a ceiling of 50.00 dollars is a ceiling of fifty yen.
  • An add-on that prices a line is handed the cart's currency and the variant priced in it, and can refuse with LinePriceAnswer::refuse(). See the onKahunaCartCartLinePrice event in the events reference.
  • A store with one currency reads exactly what it read before and runs no extra query. The base currency never reads kahunacart_currency_amounts.

A price list in another currency

A price list is in one currency. You choose it when you make the list (the base currency when you don't say), and every price on the list is in it. A merchant who sells to trade customers in dollars and in euro makes two lists: "Trade" and "Trade EUR", each with its own rules and its own prices, and each applying only to a cart in its own currency. The two can have the same rules, so a trade customer gets the right list whichever currency they are shopping in.

The currency is fixed once the list is saved. Its price rows are in that currency, and changing the header alone would leave every one of them in a currency the list is no longer read for. To move a list to another currency, make a new one. A list's prices are read with the decimal places of its own currency: 3.80 is three euro eighty, 640 is six hundred and forty yen, and 640.50 in yen is refused instead of being rounded.

A price list changes the price of a variant that already has a price in the currency. It does not make a variant available there: a variant with no EUR price in the catalogue cannot be added to a EUR cart, whatever a EUR list says about it.

The screen shows a Currency column on the list of price lists, a choice on the form while the list is new, and the same currency as plain text once it is saved. A store with one currency sees none of this.

Quantity tiers in another currency

A variant's own ladder is one ladder per currency. The rungs in euro are written separately from the rungs in dollars: replacing one never touches the other, and a variant with a dollar ladder and no euro one has no quantity breaks in euro. Nothing is worked out from the dollar ladder, because a rung is an amount and one currency's amount means nothing in another. The Quantity tiers tab shows a ladder for each currency the store sells in, and saves each one that you changed.

Through the API, name the currency with currency on the query of GET /kahunacart/price-tiers and in the body of PUT /kahunacart/price-tiers. Leave it out for the base currency. A store with more than one currency also gets currency in the answer.

JSON
PUT /kahunacart/price-tiers
{ "variant_id": 12, "currency": "EUR",
  "tiers": [ { "min_quantity": 1, "max_quantity": 4, "amount": "18.50" },
             { "min_quantity": 5, "amount": "16.00" } ] }

Editing a saved currency after it is disabled

A saved price list remains editable in its own currency and decimal places. The quantity-tier API can still read and edit an existing ladder in its saved currency; name that currency in the request. A money condition keeps the currency selected beside its threshold when saved and reopened. Disabling that currency for new carts does not change the condition or its amount.