The short answer
A product carries no price. The price lives on a join record between the product and a price list, holding the amount, its currency, and an access setting that can restrict the list to particular roles. Two lists, two rows, one product, two prices.
Two things will catch you. Creating a price list does not create price rows for products that already exist — only the reverse. And the API never resolves a price from a list: a line item created without an explicit price gets zero, even when the product is priced on the default list.
01The business problem
The same product does not cost the same to everyone. A distributor buys at one price, a direct customer at another. A framework contract fixes a price for a year. A region has its own list because the market bears something different. A partner tier gets a discount structure the direct sales team should not be able to see, let alone quote.
What that means in practice:
- one catalogue entry per product — not four near-duplicates with different prices, which is how this goes wrong;
- several prices per product, each belonging to a named list;
- the right price applied when a salesperson builds a quote, without them having to look it up;
- some lists visible only to some people;
- and a record, afterwards, of what was quoted and on what basis.
02Why the obvious approach fails
Duplicating the product per price
"Widget (EMEA)", "Widget (Partner)", "Widget (Contract A)". It works on day one and decays immediately: four records to keep in step whenever a specification changes, four SKUs where the business has one, and reporting that cannot answer "how much Widget did we sell?" without a mapping table somebody maintains by hand.
Putting the price on the product as a custom field
A custom field per tier — list price, partner price, contract price — puts the whole pricing matrix on one form, visible to everyone who can see the product, and requires a schema change every time a tier is added. It also bypasses the line-item mechanics entirely, so nothing carries the price onto a quote for you.
Assuming the product's price is a property of the product
It is not, and this is the structural fact worth internalising. Reading the
Product entity's schema, price appears in its list of key fields —
but there is no price attribute on the entity. Create a product and the record
comes back with a name, a SKU, a unit symbol, a type, and no price anywhere.
The price is a separate record: a join between the product and a price list. Every surprise in §6 follows from that one fact.
03Data model
Three entities, one of which people do not know exists.
| Entity | Holds |
|---|---|
| Product | The catalogue entry — name, SKU, unit, type, category. No price. |
| Price list | A named list — "List Price", "EMEA", "Partner Tier". Carries a type and an active flag. |
| Price-list price (the join) | The price itself, plus its currency, plus an access setting. One row per product × list. |
So "this product costs 100 on the default list and 85 on the EMEA list" is two join records, not two fields and not two products.
The join carries access control. Each price row has an access setting with an optional role list. That is the mechanism for a partner or internal-only tier: the restriction lives on the price, not on the product and not on the list's name. It is also easy to miss entirely, because nothing about a price list's name suggests that permissions are attached a level below it.
What a fresh space starts with
- One price list, named "List Price", flagged delete-protected — you cannot remove it.
- That list arrives with its active flag set to false, which surprises people who assume the default list is ready to use.
- One currency, the base currency, delete-protected.
Line items
A product reaches a deal as a line item — a join between the opportunity and the product, carrying price, quantity and a computed amount. Line-item pricing exists on both Quote and Opportunity; an opportunity is not restricted to a single summary figure.
The opportunity additionally carries its own product currency, separate from the currency of its headline value, and a flag for whether that headline value is derived from the line items or entered by hand.
04Setting it up
- Create the price lists firstBefore the catalogue, if you can. The order matters — see §6.
- Activate themIncluding the built-in default, which does not arrive active.
- Create the productsEach one automatically gets a price row for every list that already exists, defaulting to zero.
- Set the pricesOne row per product per list. This is the bulk of the work and the part worth scripting.
- Set access on the rows that need restrictingNot on the list — on the price rows within it.
- Verify by fill rateCount price rows per list and compare against the product count. A list with fewer rows than products has gaps, and a gap reads as a price of zero.
Step 6 exists because of the asymmetry in §6, and it is the check that catches the most common way this configuration is silently incomplete.
05Applying the right price
The API does not resolve a price from a price list. Verified: a line item
created without a price came back with price: 0, on a product that had
a price of 100 on the default list. No error, no default, no lookup.
Price-list selection is an interface affordance. On the API path, deciding which list applies and reading the price out of it is the caller's job.
For an integration or an import, that means the resolution logic is yours to write and yours to keep correct: determine the applicable list from whatever drives it — the account's region, its tier, a contract reference — read the price row for that product and list, and write the value onto the line item.
And the line item does not record which list the price came from. There is no price-list reference on it. So once a line exists, nothing in the data says whether 85 was the EMEA price, a negotiated discount, or a typing error. If the basis of a price matters for reporting or audit, capture it yourself in a custom field on the line item at the moment you set the price — afterwards is too late.
06Limits & trade-offs
Price-row creation is one-directional
Creating a product creates a price row for every existing list. Creating a list creates nothing for existing products. Verified in both directions.
So the natural sequence — build the catalogue, then add a regional list when the business expands — leaves every existing product unpriced on the new list, and an unpriced row is indistinguishable from a price of zero. Add lists before products where you can; where you cannot, backfill the rows deliberately and verify the count.
Computed values lag the write response
A line item's amount is computed from price × quantity, but the value in
the response to your own write is stale. Immediately after setting price 85 on a
quantity of 2, the write returned amount: 0; a fresh read returned
170. The calculation is correct — the echo is not.
Never assert a computed field from a write response. This nearly produced a wrong claim in this very blueprint; the same pattern appears in Blueprint 002.
Discount fields are readable but not writable
The line item exposes a discount value and a discount percentage, and the API rejects attempts to write either — it reports them as attributes the entity does not have. They appear to be derived rather than settable, so a discount has to be expressed by adjusting the price rather than by recording a discount.
Unverified: the opportunity value rollup
With the opportunity's auto-calculate flag set to true and a line item whose amount computed correctly, the opportunity's headline value stayed at zero across repeated reads and a deliberate touch of the record. We then repeated the test with the flag set at creation, before any line item existed — same result: the line item's amount computed to 1,000 and the opportunity value remained zero.
We are still not calling it a defect, because the obvious remaining explanation is that the rollup is computed by the interface rather than the server. But both orderings are now ruled out, so an integration cannot rely on line items to drive opportunity value — set the value explicitly if you need it, and confirm the interface behaviour before assuming otherwise.
Multi-currency
Each price row carries its own currency, so a list can be denominated in a different currency from another. What is not native is retaining the exchange rate that applied at a point in time — an exchange-rate-at-transaction history has to be driven by automation if the business needs it. We have not verified this in a multi-currency space; it is recorded from prior analysis rather than observation.
07Verification
- Count price rows per list against the product count. The single most valuable check here, because an unpriced product on a list looks exactly like a product priced at zero.
- Read prices back from the join records, not from the product — the product has nothing to read.
- Create one line item end to end and confirm the price that lands is the one you intended, especially if an integration is doing the resolving.
- Re-read computed amounts after a settle. Never from the write response.
- Test access restrictions as a restricted user, not as an administrator — the same discipline as the record-lock check in Blueprint 004. An administrator cannot see the restriction working.
- Confirm the default list is active before wondering why nothing prices.
What would signal a regression: line items landing at zero, which means the resolution step was skipped; a price list whose row count drifts below the product count after a catalogue addition; or quoted prices that no longer match any list, which means somebody has been editing line items directly.