Price lists
Give companies and customers their own prices - a percent off the catalogue plus fixed prices per product - while your storefront keeps the catalogue cached.
A price list - Gold, Silver, Dealer - gives a group of buyers their own prices. You assign it to a company or to a customer, and cmssy prices that buyer's cart, checkout and orders from it on the server. The catalogue a visitor reads does not change, so your product pages stay anonymous and cacheable.
What a list holds
- A percent off the catalogue price, 0 to 99.99 with at most two decimals. It applies to every product the list has no fixed price for, and to each of that product's volume breaks. It is stored in basis points and computed in whole numbers: rounded half up to a whole minor unit, a free product stays at 0, and a paid one never drops below 1 minor unit.
- Fixed prices per product, in minor units, each with optional volume breaks. A product with variants takes a fixed price per variant, never one for the whole product. A fixed price may sit above the catalogue price - a contract price is still a price.
A fixed price wins over the percent; with neither, the buyer pays the catalogue price. The currency is always the product's own - a list changes amounts, not currencies. A fixed price for a product or variant that was later removed prices nothing, and the list marks it as removed so you can delete it.
Who pays which list
One list per buyer, never two stacked:
- A customer who belongs to a company always pays the company's list - or the catalogue price when the company has none. cmssy refuses to assign a list to such a customer directly: assign it to the company. A list the customer held before joining the company is ignored while they belong to it.
- A customer without a company can hold a list of their own - a VIP, or a single trade buyer.
- A guest pays the catalogue price.
Managing lists
Lists live in Commerce → Price lists. Create one with a name and a percent, then open it to add fixed prices per product or variant. Deleting a list removes its fixed prices and unassigns it from every company and customer; orders keep the name they were priced under.
Assign a list on the customer's page, or on a company record in its model. The permissions:
models:edit- create, edit and delete lists and their fixed prices;models:viewreads them.customers:manage- assign a list to a customer or a company.customers:viewis enough to see which lists exist.
An ERP sync writes fixed prices in bulk: priceList.setEntries on the admin API upserts up to 1000 at once, all or nothing - one bad row refuses the whole call, and the error names every bad row by index.
From Spotlight and the MCP server
Spotlight and the MCP server (0.108.0 and later) carry the same operations, under the same permissions: list_price_lists, get_price_list, create_price_list, update_price_list, delete_price_list, set_price_list_entry, set_price_list_entries, remove_price_list_entries, set_customer_price_list and set_company_price_list.
Prices on a headless storefront
A buyer's price is personal, so it never goes on a product query - that would make every catalogue read member-specific and uncacheable. The pattern instead:
- Render and cache the catalogue anonymously. Product lists and product pages read
public.model.recordsexactly as for a signed-out visitor, with catalogue prices. Never key a cached page on the member. - After sign-in, overlay the buyer's prices. Ask
account.pricesfor the products on screen - at most 100 items per call - sending the member'sAuthorization: Bearer <accessToken>. Without a member token the call is refused. - Never cache that response for another visitor. No shared fetch cache, no CDN. Read it in a dynamic route or a server action and patch the prices into the page you already rendered.
query BuyerPrices($workspaceId: ID!, $items: [PriceQueryItem!]!) {
account {
prices(workspaceId: $workspaceId, items: $items) {
recordId
variantKey
currency
price
priceMax
listed
tiers { minQty price }
}
}
}Each item is { recordId, variantKey }, where variantKey is the delivery ProductVariant.id. Leave it out on a variant product to get a range: price is the lowest, priceMax the highest, and tiers is empty - the "from" price of a listing card. Amounts are in minor units. listed is true when a list set the price, for a "your price" label. An unknown or hidden product is left out, so match rows by recordId and variantKey, not by position.
For a badge like "Your prices: Dealer Gold", read the list's name from the member's profile. It is null when no list applies, and a member only ever sees their own:
query MyPriceList($workspaceId: ID!) {
account {
profile(workspaceId: $workspaceId) {
priceList { name }
}
}
}With @cmssy/core 19.11.0 or later, getBuyerPrices sends the prices query for you.
Cart and checkout
The cart is priced for whoever reads it. On every cart read and write, cmssy re-prices the lines that were priced for a different list - a guest who signs in, a member who signs out, a list assigned or removed. unitPrice is what will be charged; snapshot.basePrice is the catalogue price before the list and volume breaks, for a struck-through "was" price. It is null on a line added before price lists existed.
query CartPrices($workspaceId: ID!) {
cart {
get(workspaceId: $workspaceId) {
items {
id
quantity
unitPrice
currentPrice
priceMismatch
snapshot { price basePrice currency }
}
subtotal
}
}
}Changing a list's percent or fixed prices does not re-price carts already priced from that list. The line shows the new price in currentPrice with priceMismatch: true, and checkout charges what is in the cart.
When checkout refuses
If re-pricing moved the cart since the buyer last saw it, cart.checkout refuses instead of charging an amount nobody showed. The reason is in the GraphQL error's extensions.reason:
CART_REPRICED(codeBAD_USER_INPUT) - the buyer's prices changed. The cart is already stored with the new prices: read it again, show the new totals, ask the buyer to confirm, then callcart.checkoutagain.CART_CURRENCY_CHANGED(codeCONFLICT) - a line would now be priced in another currency than the cart;extensions.itemIdnames it. Remove that line and add the product again.
With @cmssy/core 19.11.0 or later, checkoutErrorReason(error) returns either reason.
Discount codes apply on top of the list: a code is taken off the list-priced subtotal, and a code's minimum subtotal is measured against it too.
Orders
An order freezes the list it was priced from. Its name shows on the order in the admin and in get_order, and stays there after the list is renamed or deleted. On each order item:
price- the unit price charged, list and volume break applied.listPrice- the catalogue unit price before the list and volume breaks. Without a list it is the price before volume breaks, as before; with a contract price above the catalogue it can be belowprice.
Editing an order in the admin re-prices its product lines through the frozen list's current values, or at catalogue prices if that list was deleted. A manual order is priced from the selected customer's list.
Next steps
- Commerce - the cart, checkout and orders a list prices.
- Customers - customers and companies, where a list is assigned.
- Member authentication - sign-in and the access token
account.pricesneeds.