The instinct is to precompute every customer-product price. With 800 accounts and 40,000 SKUs that is 32 million rows that go stale the moment a contract changes. The alternative is to store rules and resolve them — with caching designed around how buyers actually browse.
Ask a team how they handle contract pricing and you will usually hear some version of "we sync the price list". Push a little and the design turns out to be a table of customer-product-price rows, generated nightly from the ERP.
That works at small scale and fails predictably at real scale. Eight hundred accounts against forty thousand SKUs is thirty-two million rows. Regenerating that nightly is slow, regenerating it on every contract amendment is slower, and in between the storefront is confidently showing prices that are no longer correct.
Store rules, resolve on demand
Real commercial agreements are not thirty-two million independent facts. They are a few hundred rules with a precedence order: a list price, a customer group discount, a contract override on certain ranges, a volume break, maybe a promotional floor. The row count you actually need to store is small. The work is deciding which rule wins.
Price resolution ladder for a single line. Start at list price 48.00. Customer group discount for Tier 2 distributors takes 15 percent off, giving 40.80. A contract override on the Henderson range sets 37.50 and wins over the group discount. A volume break at 250 units applies a further 4 percent, giving 36.00. A promotional floor check confirms 36.00 is above the 34.20 floor, so the resolved price is 36.00.
- Step 140.80Customer group: Tier 2 distributor − 15%Applies unless overridden
- Step 237.50Contract override: Henderson rangeWins over group discount
- Step 336.00Volume break: 250+ units − 4%Compounds on the override
- Step 4passPromotional floor check: 34.20Resolved price is above the floor
The resolver
Expressed as code, the resolver is short. That is the point — the complexity belongs in the data, where the business can change it, not in branching logic that requires a deployment.
type Rule =
| { kind: 'group_discount'; percentOff: number }
| { kind: 'contract_price'; price: number; skus: string[] }
| { kind: 'volume_break'; minQty: number; percentOff: number }
| { kind: 'floor'; price: number };
const PRECEDENCE = ['contract_price', 'group_discount', 'volume_break', 'floor'] as const;
export function resolvePrice(
listPrice: number,
sku: string,
quantity: number,
rules: Rule[]
): { price: number; applied: string[] } {
let price = listPrice;
const applied: string[] = [];
for (const kind of PRECEDENCE) {
const rule = rules.find(r => r.kind === kind);
if (!rule) continue;
switch (rule.kind) {
case 'contract_price':
if (rule.skus.includes(sku)) {
price = rule.price; // replaces, does not stack
applied.push('contract_price');
}
break;
case 'group_discount':
// Skipped when a contract price already won — the agreed precedence.
if (!applied.includes('contract_price')) {
price = round2(price * (1 - rule.percentOff / 100));
applied.push('group_discount');
}
break;
case 'volume_break':
if (quantity >= rule.minQty) {
price = round2(price * (1 - rule.percentOff / 100));
applied.push('volume_break');
}
break;
case 'floor':
if (price < rule.price) {
price = rule.price;
applied.push('floor');
}
break;
}
}
return { price, applied };
}
const round2 = (n: number) => Math.round(n * 100) / 100;Caching around how buyers actually browse
Resolution per line is cheap; resolution for a 200-product category page on every request is not. But B2B browsing has a property that makes caching unusually effective: a given buyer sees the same prices repeatedly, and those prices change rarely — typically when a contract is amended, which is a discrete, observable event.
- Cache by account and SKU, not by user. Everyone at the same company sees the same price, so the cache key is the company, not the individual, which multiplies your hit rate.
- Invalidate on the event, not on a timer. A contract amendment in the ERP should publish an event that evicts exactly the affected account, not expire everything hourly.
- Precompute only the hot set. The first screen of the top categories for the most active accounts covers the overwhelming majority of views. Resolve the long tail on demand.
- Never cache across accounts. This sounds obvious and is nonetheless the most damaging bug in this area — one buyer seeing another’s negotiated price is a commercial incident, not a technical one.
Keep one source of truth
Where the ERP owns commercial terms, it must own them entirely. The storefront should hold a derived, invalidatable copy and nothing more. The moment someone adds a discount rule directly in the platform admin "just for this promotion", you have two systems disagreeing about price and no way to say which is right.
Four target metrics for a B2B pricing layer. Price resolution under 50 milliseconds at the ninety-fifth percentile. Cache hit rate above 90 percent for the hot set. Cache invalidation propagating within 60 seconds of a contract change. Zero cross-account cache keys.
- < 50ms
- p95 resolution time
- > 90%
- cache hit rate, hot set
- < 60s
- invalidation after contract change
- 0
- cross-account cache keys
Key takeaways
- Do not precompute the customer × product matrix. Store a few hundred rules and resolve them.
- Agree precedence in writing first — most pricing bugs are two rules disagreeing about which wins.
- Return the list of applied rules alongside the price so support can explain it instantly.
- Cache by company rather than user, and invalidate on contract events rather than on a timer.
- One system owns commercial terms. A promotional rule added in the storefront admin is how drift starts.
360 Expert Solutions
E-commerce & ERP Integration Team
We build and scale B2B and DTC commerce on Shopify Plus, BigCommerce and WooCommerce, wired into NetSuite and Oracle. These articles are written by the engineers and architects who deliver those projects.
Building something like this?
We architect B2B commerce and ERP integrations end to end.