All field notes
B2B Commerce

Customer-Specific Pricing That Survives 40,000 SKUs

Contract pricing is a resolution problem, not a storage problem. Treat it as storage and you end up with a million-row table nobody trusts.

360 Expert Solutions3 min read

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.

List price48.00
  1. Customer group: Tier 2 distributor − 15%
    Applies unless overridden
    40.80
    Step 1
  2. Contract override: Henderson range
    Wins over group discount
    37.50
    Step 2
  3. Volume break: 250+ units − 4%
    Compounds on the override
    36.00
    Step 3
  4. Promotional floor check: 34.20
    Resolved price is above the floor
    pass
    Step 4
Price charged36.00
Precedence must be explicit and testable. Most pricing bugs are two rules disagreeing about which one wins.

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.

typescript
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;
A rule-ordered resolver. Rules are data; precedence is explicit.

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
Measurable targets make a pricing layer reviewable rather than a matter of opinion.

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.
B2BPricingPrice ListsContractsCachingNetSuite

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.

B2B Commerce

B2B Checkout: Net Terms, PO Numbers and Approval Chains

A B2B checkout is not a retail checkout with an invoice option bolted on. It has to carry a purchase order number, respect a credit limit the ERP owns, and route orders above a threshold to an approver — without becoming the slowest part of the buying process.

Read
B2B Commerce

PunchOut Catalogues: Selling Into Enterprise Procurement

Large buyers do not shop on your website. They shop inside Ariba, Coupa or SAP, and they expect your catalogue to appear there. PunchOut is the protocol that makes your storefront a guest inside their procurement system — and it is usually a contractual precondition, not a nice-to-have.

Read
ERP Integration

Real-Time Inventory Between NetSuite and Shopify Plus B2B

Most NetSuite-to-Shopify integrations poll on a timer. For B2B that is too slow: a single wholesale order can move a thousand units, and the next buyer sees stock that no longer exists. The fix is event-driven sync with a reconciliation pass behind it.

Read
Book