Every B2B merchant I work with has the same pricing story: "Each company has its own prices, with quantity breaks, and they come from the ERP every night."
On one Magento (Adobe Commerce) project, that story looks like this:
ERP ─► SQL ─► nightly sync script ─► Redis/KeyDB ─► pricing API ─► Magento
▲
one JSON blob per company cached in MariaDB, warmed on every request
tier prices injected into products by a plugin
listing prices loaded by AJAX so full-page cache still works
It works. It's also four moving parts whose only job is to answer "what does this company pay for this SKU?"
So for Forgeline v1.3.0, my open-source commercetools storefront, I rebuilt that pricing model natively. Here's what I learned.
- 👉 Live demo: https://forgeline-storefront-gamma.vercel.app
- 💻 Code: https://github.com/sohelrana09/forgeline-storefront
- 📦 Release: https://github.com/sohelrana09/forgeline-storefront/releases/tag/v1.3.0
1. Don't port the cache. Import the prices.
commercetools Standalone Prices are rows like this:
sku · value · currency · country · channel · customerGroup · validFrom/validUntil · tiers[]
A product query with price selection and a cart both pick the most specific matching row. So the catalog price, the cart total, tax, discounts and the order always agree, and there's nothing to warm, cache or keep in sync at runtime.
The Magento layer maps like this:
| Magento + Redis | commercetools |
|---|---|
| Company price list | A distribution channel per company |
{ qty: price } ladder |
value + tiers: [{ minimumQuantity, value }]
|
| B2C / guest price list | Rows on the B2C channel without a customer group |
| Partner members' price list | A customer group with its own rows |
| Fallback to the local price | Price selection falls back to the more general row |
| Per-company JSON cache, AJAX price loading | Gone |
The nightly ERP job stays, but it writes changed rows to the Import API instead of filling Redis.
2. Companies get channels, consumers get customer groups
It's tempting to use customer groups for companies too. Don't.
In commercetools B2B, a customer can be an associate of several business units, and the buyer switches which company they're buying for. The cart follows the selected unit's store and its distribution channel. A customer group belongs to the customer, so it can't change when they switch company.
So: channel per company (company → store → channel), customer groups only for consumers.
const priceSelection = (ctx: PricingContext) => ({
storeProjection: ctx.storeKey,
priceCurrency: ctx.currency,
priceChannel: ctx.channelId, // the company's price list
...(ctx.country && { priceCountry: ctx.country }),
// B2C customer groups: their own rows win, other SKUs fall back to the public price
...(ctx.customerGroupId && { priceCustomerGroup: ctx.customerGroupId }),
});
3. Test price selection against the real API before you trust the docs
I wrote a small script with a --check mode that creates temporary rows and carts, prints what commercetools picks, and deletes everything in a finally block. It answered four questions I'd otherwise have guessed at:
| Question | Answer |
|---|---|
| Company row and a channel-less row both exist | The company's row wins |
| Company has no row for a SKU | The channel-less row is used (your fallback price) |
| Group row and a public row both exist | The group's row wins for members |
| Group has no row for a SKU | The public price |
Ten minutes of script saved a week of "why is this customer seeing that price?"
4. In a cart, lineItem.price.value is already the tier price
Add 12 oil filters with a 12+ tier and the line's price.value is the tier value, not the base price. totalPrice matches. You don't calculate tiers in the cart yourself.
For display, a few pure helpers cover everything (unit-tested, no network):
// The base price at 1, then each tier, lowest quantity first.
export function priceSteps(price: Price): PriceStep[] {
const tiers = (price.tiers ?? []).filter((t) => t.minimumQuantity > 1).sort((a, b) => a.minimumQuantity - b.minimumQuantity);
return [{ minimumQuantity: 1, value: price.value }, ...tiers];
}
// The unit price for a quantity. A product discount replaces the tiers.
export function unitPriceAt(price: Price, quantity: number): TypedMoney {
if (price.discounted) return price.discounted.value;
return priceSteps(price).findLast((s) => quantity >= s.minimumQuantity)!.value;
}
They power "Buy 10 for $36.00 each and save 10%" on the product page, "As low as $32.00" on cards, and "Buy 50 for $32.00 each" next to a cart line.
5. A product discount beats your tiers
This one surprised me. I put a tier on a price that also had a 15% product discount, then moved the cart line from 1 to 2.
price.value switched to the tier price, but discounted stayed the same, still 15% off the base price, and the total used discounted. So with an active product discount, tiers are effectively ignored.
If the ERP ladder is your discount (it usually is in B2B), keep product discounts off those SKUs or decide which one wins on purpose.
6. A cart gets its customer group once
Carts created through /me/carts take the customer's group automatically. Nice. But only when the cart is created. If a consumer joins a group later, their existing cart keeps no group, and the /me cart API can't change it.
The sync that changes a customer's group should also set the group on their active cart with the admin client.
7. Watch your caches
Two things I almost missed:
- The search index. Mine is in memory and was keyed per store. Group prices meant keying it per customer group too, or a trade member's search results show public prices.
-
The guest page cache. Guests never have a group, so cached guest pages stay exactly as they were. Reading the group (60-second memo, no
/mecall per page) only happens for signed-in consumers.
Try it
- Live demo: https://forgeline-storefront-gamma.vercel.app (guests see public B2C prices)
- Code: https://github.com/sohelrana09/forgeline-storefront
- Release notes: https://github.com/sohelrana09/forgeline-storefront/releases/tag/v1.3.0
Next up is the nightly ERP import through the Import API, sending only the rows that changed.
How do you handle company pricing on your platform: synced into the commerce engine, or fetched live? I'd love to hear in the comments.
Top comments (1)
Four moving parts whose only job is answering one question is a painfully familiar shape in B2B integrations — every layer was added to compensate for the one below it, and the cache ends up as a second source of truth that can disagree with the first. Moving the prices into the platform's native model does not just delete Redis, it deletes the whole class of bugs where listing, cart, and order each consult a different copy. Keeping the nightly ERP job but having it write real price rows instead of cache blobs is the right boundary too: the ERP stays the origin, but nothing downstream has to maintain a shadow of it.