Blog/Web engineering
B2B pricing engines: customer-specific prices, caching and audits
How B2B platforms resolve customer-specific prices, how to cache them without leaks, explain them in a dispute, test them and round VAT.
Balázs Csorba··12 min read
- Pricing
- Spryker
- SAP
- Caching
- VAT

Key takeaways
- A B2B price depends on up to seven dimensions: customer, customer group or company, contract, region, quantity tier, currency and validity date; the engine picks the most specific matching rule and falls back to a base price.
- Platforms document their resolution order: SAP access sequences search condition tables from specific to general, commercetools uses an eight-step fallback where the cheapest price wins inside a step, and Spryker falls back from concrete to abstract products.
- Cache personalized prices per price list or customer group, purge by tag when a contract changes, revalidate at checkout, and never let the cache be the only copy of a price.
- Store an order-time price snapshot with the winning rule, its version, the tax rate and the rounding mode, so a dispute months later is a lookup instead of a reconstruction.
- Test prices with a golden matrix and property-based invariants after every import, and write down one rounding rule that the shop, the ERP and the invoice share.
A B2B pricing engine decides which net price a logged-in buyer sees for a SKU, given who they are, which contract their company has, where they buy, how many units they order, in which currency and on which date. The computation is the easy half. The hard half is operational: caching prices that differ per customer without showing them to the wrong customer, explaining a price three months later in a dispute, and rounding VAT the same way the invoice will.
This article walks through how the major platforms resolve customer-specific prices (Spryker, Pimcore, the SAP condition technique, commercetools, Adobe Commerce and Shopify B2B), then covers caching, auditing, testing and VAT. How a configurator prices options is covered in headless product configurators for B2B; how prices are exposed as structured data behind a login is covered in schema.org structured data for B2B shops.
How do B2B prices get decided?
Seven dimensions usually decide a B2B price: the customer, the customer group or company, the contract, the region or sales channel, the quantity tier, the currency and the validity date. A pricing engine looks for the most specific rule that matches all of them and falls back step by step to a general base price when nothing more specific exists.
- Customer: an individual price agreed for one buyer account, often for a handful of core SKUs.
- Customer group or company: a price list shared by a segment, such as distributors, installers or all subsidiaries of one company.
- Contract: a negotiated agreement with a validity period, sometimes with its own volume commitments.
- Region or channel: country, store or sales channel, which also decides the tax treatment.
- Quantity tier: the scale level reached by the ordered quantity.
- Currency: prices are maintained per currency, not converted on the fly.
- Validity date: start and end dates, so next quarter’s prices can be loaded before they apply.
The dimensions multiply. Spryker’s guide to ERP-owned pricing gives an example: 1,000 customers times 25,000 products, counted for abstract and concrete products, is 50 million theoretical price records. At 20 percent contract coverage that is still about 20 million, the point where, according to the guide, the standard sync pipeline “begins to degrade seriously” (Spryker). That number is why the questions of where prices live and how they are cached matter more than the formula.
Where prices live and the resolution order
Prices usually have three possible homes: the ERP, which holds negotiated conditions and is usually the system of record; the PIM, which may hold list prices as product attributes; and the commerce platform, which holds price lists or price dimensions for fast reads. The decision that matters is which one is authoritative and how stale a copy in another system may be. Product data sync has the same question, see the Pimcore ERP delta sync.
In SAP S/4HANA the condition technique decides. The system picks a pricing procedure from the sales area, the sales document type and the sold-to party. Each condition type in the procedure has an access sequence that searches condition tables in order, from the most specific key to the most general, until it finds a valid condition record. Quantity scales then pick the scale level, and header conditions are distributed across items by net value unless configured otherwise (SAP Learning).
commercetools documents its price selection as an eight-step fallback, from customer group plus channel plus country down to the base price. Inside each step, prices with a validity period win over prices without one, and if several prices still match, the cheapest one wins. Tiered prices are ignored when a product discount already applies (commercetools).
Spryker stores prices as integers in the smallest currency unit, each with a price type and a gross and a net value per price mode. Retrieval checks the concrete product first and falls back to the abstract product. Volume prices follow the same inheritance: a concrete product’s own volume prices override the abstract ones, and they support only the default price type (Spryker prices, Spryker volume prices). Customer-specific prices are built with price dimensions: the documented route extends the customer and price tables with a group id and exports per-group prices to the key-value store.
Pimcore’s E-Commerce Framework gives each product a price system that returns PriceInfo objects. The built-in AttributePriceSystem reads the price from a product attribute, and anything ERP-driven becomes a custom price system registered in the configuration (Pimcore). Pricing rules then apply discounts as condition and action pairs on top of that price.
Platform support compared
The platforms agree on the idea that the most specific rule wins and differ in where the tie-breaks live and what you get for audits. The table shows what each documents as of October 2026.
| Platform | Price dimensions | Volume tiers | Precedence | Audit or explain hook |
|---|---|---|---|---|
| Spryker | store, currency, price mode; customer groups via price dimensions | JSON per store and currency, default price type only | concrete over abstract product | hybrid pattern with checkout revalidation |
| Pimcore E-Commerce Framework | whatever the price system implements | inside a custom price system | price system per product, then pricing rules | applied rules readable per PriceInfo |
| SAP S/4HANA SD | condition tables keyed on customer, material, sales area and more | quantity scales on condition types | access sequence, most specific table first | condition records found per document item |
| commercetools | customer group, channel, country, validity | tiers on embedded and standalone prices | eight-step fallback, cheapest wins in a step | Pricing and Discount Messages, opt-in |
| Adobe Commerce B2B | shared catalog per company, website scope | tier prices per shared catalog | fixed custom price: minimum of base and fixed value | none named in the cited docs |
| Shopify B2B | catalogs with quantity rules per variant | up to 10 price breaks per product | volume price fixes the price; catalog adjustment no longer applies | none named in the cited docs |
Two documented rules surprise people. In Adobe Commerce, a fixed custom price in a shared catalog never raises the price: the minimum of the base price and the fixed value is used (Adobe). In Shopify B2B, once volume pricing applies the price becomes fixed, so a catalog-wide percentage adjustment no longer reduces it, and quantity rules apply per variant, so a buyer cannot combine variants to reach a minimum (Shopify).
Caching personalized prices without leaking them
A personalized price can be cached as long as every dimension that changes the price is part of the cache key. If the key is the URL alone, the first buyer to load a product page decides the price that every later buyer sees. That is both a wrong price and a data leak: one customer’s contract price is shown to another customer.
- Key on the pricing dimensions, not the user. Cache per price list or customer group, not per user id, otherwise the hit rate collapses. Cloudflare allows headers, cookies and user features in custom cache keys, but those controls are Enterprise-only, and the docs warn that custom keys may shard the cache (Cloudflare).
- Tag responses for invalidation. Varnish’s xkey module lets the backend tag each response with surrogate keys such as the SKU and the price list. One purge call clears every object that carries a key, softpurge keeps them for grace mode, and the README warns to protect the purge endpoint with an ACL (xkey).
- Purge on the business event. A contract change, a new price import or an ERP delta emits the affected SKU and price-list tags. A TTL alone leaves the old contract price live until it expires.
- Never make the cache the only store. Spryker’s guide warns that writing prices only to a cache creates a data-loss risk if the cache is lost (Spryker).
For ERP-owned prices, the default the same guide recommends is a hybrid: a local read model serves browsing and the cart, and checkout revalidates against the ERP so the final price is confirmed at order placement. When cached ERP prices age, the page shows a staleness indicator such as “Price as of 14:23” instead of pretending the number is live. The guide’s target is price retrieval under 100 ms per storefront request.
Auditing and explaining a price
Sooner or later someone asks why customer X saw price Y on a given day. If the answer requires replaying today’s rules against today’s data, you cannot give it, because the contract, the tier table and the base price have all changed since. The answer has to be stored at the moment the price is used.
Store an order-time price snapshot on every order line: the resolved net price, the rule that won and the candidates it beat, the price list and contract ids with their versions, the quantity tier, the currency, the tax rate and the rounding mode. The snapshot costs a few hundred bytes per line and turns a dispute into a lookup. The example below is illustrative, with amounts in cents.
{
"orderLine": "SO-2026-104233/3",
"sku": "VLV-200-DN50",
"quantity": 24,
"resolvedAt": "2026-10-07T09:14:03Z",
"currency": "EUR",
"unitNet": 18350,
"winningRule": { "type": "contract", "id": "CT-7781", "version": 4, "tier": "20+" },
"candidates": [
{ "type": "customerGroup", "id": "PL-INSTALLERS", "unitNet": 19100 },
{ "type": "base", "id": "PL-BASE-EUR", "unitNet": 22400 }
],
"taxRate": "19.00",
"rounding": "HALF_UP per line",
"priceSource": "erp-read-model",
"priceAgeSeconds": 412
}The platforms give you some of the raw material. commercetools emits Messages for changes on resources, including dedicated Pricing and Discount Messages for standalone prices, but messages are not persisted by default and have to be enabled (commercetools Messages). Pimcore can list the pricing rules applied to a PriceInfo, and in SAP each document item carries the condition records its access sequences found. None of that replaces a snapshot you control, because those sources change or expire on their own schedule.
Price anomalies are a good place for automation that knows its limits. A check that flags lines where the charged price differs from the resolved one by more than a tolerance should route the case to a person, not correct it automatically. My jev-decide skill follows the same pattern: a decision with low confidence goes to a human instead of being applied.
Testing price logic with golden snapshots
Price logic is a matrix of customer, contract, region, quantity, currency and date. Pick a small set of representative values for each dimension, compute the resolved price for every combination, and store the result as a golden file in the repository. Playwright writes a golden snapshot on the first run, compares every later run against it and updates it only with --update-snapshots; toMatchSnapshot() works on any text, not just screenshots (Playwright).
import { test, expect } from '@playwright/test'
import { resolvePrice } from '../src/pricing'
import { customers, skus, quantities, dates } from './price-matrix'
// One row per combination; the golden file is reviewed like code
test('price matrix matches the golden file', () => {
const rows: string[] = []
for (const c of customers) for (const sku of skus) for (const qty of quantities) for (const date of dates) {
const p = resolvePrice({ customer: c, sku, quantity: qty, date, currency: 'EUR' })
rows.push([c.id, sku, qty, date, p.unitNet, p.winningRule.id].join(';'))
}
expect(rows.join('\n')).toMatchSnapshot('price-matrix.txt')
})Golden files catch regressions in specific cases; property-based tests catch violations of the general rules. With fast-check you state invariants and let the library generate hundreds of inputs and shrink any failure to a minimal case (fast-check). Useful invariants: the order total equals the sum of the rounded line totals, a higher tier never yields a higher unit price, gross is never below net for a positive tax rate, and a customer-specific price never exceeds the base price where the business rule says it must not.
Run the matrix after every price import and every ERP delta, not only on code changes, because most price bugs come from data rather than code. On the storefront side, I use Playwright MCP to drive the real shop as different logged-in roles and compare the displayed price with the resolved one after a contract change.
VAT, rounding and price-mode pitfalls
B2B prices are usually maintained and shown net, consumer prices gross, and a platform has to know which one it stores. Spryker keeps the price mode, gross or net, on the quote, and a quote cannot use both at once. Its documentation also shows the consequence: in gross mode, a product priced at €100 net with 20% tax in the store’s region stays €120 gross when sold into a region with 10% tax, so the net amount becomes €109.09 (Spryker).
The same opinion notes that no provision in the EU VAT directives explicitly regulates rounding, so the detail is a matter of national law within EU principles, and that with net invoicing between businesses the distortions either do not arise or are greatly reduced. HMRC’s rule is a concrete example: VAT under 0.5p rounds down, 0.5p or more rounds up, and other methods are allowed if they give a fair and reasonable result (HMRC). For consumers, the Price Indication Directive requires the selling price including VAT, which is one reason B2C shops are gross-first and B2B shops are net-first (Directive 98/6/EC).
Make rounding explicit in code. commercetools exposes rounding as configuration, with HalfUp, HalfDown and HalfEven modes and separate cart settings for price rounding, tax rounding and tax calculation, and notes that B2B prices often need sub-cent precision. In PHP, moneyphp keeps amounts as strings and its allocate() splits €10.00 into 334, 333 and 333 cents deterministically (moneyphp). Store prices as integers in the smallest unit or through a money library, never as floats.
When not to build your own pricing engine
Most B2B shops should not build a pricing engine; they should build a pricing adapter. If the ERP already resolves conditions correctly, duplicating that logic in the shop creates two engines that drift apart. The hybrid pattern keeps the ERP authoritative: replicate resolved prices into a read model for browsing, revalidate at checkout, and show staleness instead of hiding it.
Build resolution in the shop when the ERP cannot answer fast enough for browsing at your catalog size, when the shop sells things the ERP does not price, such as configured options or marketplace offers, or when the ERP’s rules are simple enough that a price-list import covers them. Even then, keep one owner per price rule and treat the other system as a copy with a known maximum age.
“Price on request” deserves to be a business state with its own flow, not an error. If no rule matches, a request-a-quote action is better than a base price the customer is not entitled to, and better than a missing price that looks like a bug. Quote handling then takes over, as described in RFQ automation with LLMs.
A checklist before you touch prices
In my Spryker and Pimcore work, where prices live is the first question to settle. This is the list to go through before changing indexes, caches or importers that touch prices.
- Write down the dimensions and the resolution order, including the tie-break inside a step.
- Name one owner for each price rule and a maximum age for every copy.
- Put every price-changing dimension in the cache key, and nothing per user.
- Tag cached prices by SKU and price list, and purge on contract change, not only by TTL.
- Revalidate at checkout and show the price age when it is not live.
- Store an order-time price snapshot with rule ids, versions, tax rate and rounding mode.
- Keep a golden price matrix and property tests, and run them after every import.
- Document one rounding rule for shop, ERP and invoice, and use integers or a money library.
- Treat “price on request” as a state with its own flow.
If you are untangling customer-specific prices across a shop and an ERP, see my B2B e-commerce work. Sales reps who have to explain a price to a customer benefit from the same snapshot data, see an AI copilot for B2B sales reps.
Sources
- Spryker docs: prices feature overview (Sep 2026)
- Spryker docs: price explosion and ERP-owned pricing in B2B (Sep 2026)
- Spryker docs: volume prices overview
- commercetools docs: pricing and discounts overview
- SAP Learning: introducing the condition technique
- Pimcore docs: price systems and getting prices (2026.3)
- Adobe Experience League: shared catalog pricing structure (Oct 2026)
- Shopify Help Center: B2B quantity rules and volume pricing
- Varnish libvmod-xkey README
- Opinion of the Advocate General in Case C-484/06 (Koninklijke Ahold)
- HMRC internal manual VATREC12030: rounding of VAT
- Playwright docs: snapshot testing
Frequently asked questions
What is a B2B pricing engine?
The component that resolves the net price a specific business customer pays for a SKU, considering customer, group, contract, region, quantity, currency and date. It finds the most specific valid rule and falls back to general prices. In many setups the ERP holds the rules and the shop keeps a fast read copy.
How do you cache customer-specific prices safely?
Put every price-changing dimension, usually the price list or customer group, into the cache key, never the URL alone and never the user id. Tag responses by SKU and price list, purge by tag when a contract changes, revalidate the price at checkout, and keep the authoritative price outside the cache.
How does SAP determine a price?
Through the condition technique. A pricing procedure is chosen from the sales area, the sales document type and the sold-to party. Each condition type has an access sequence that searches condition tables from the most specific to the most general key until it finds a valid record, and quantity scales choose the scale level.
What should an order store to explain its price later?
A snapshot per order line: the resolved unit price and currency, the winning rule with its id and version, the candidates it beat, the quantity tier, the tax rate, the rounding mode, and where the price came from and how old it was at that moment.
Should VAT be rounded per line or per invoice?
The EU VAT directives do not regulate rounding explicitly, so national rules apply; HMRC, for example, rounds below 0.5p down and 0.5p or more up. Pick a documented rule your tax adviser accepts and apply it identically in the shop, the ERP and the invoice. With net B2B invoicing the distortions are much smaller than with gross retail prices.