DEV Community

Cover image for We moved ERP company pricing from Magento + Redis to commercetools. The cache layer just disappeared.
Sohel Rana
Sohel Rana

Posted on

We moved ERP company pricing from Magento + Redis to commercetools. The cache layer just disappeared.

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
Enter fullscreen mode Exit fullscreen mode

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.

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[]
Enter fullscreen mode Exit fullscreen mode

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 }),
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 /me call per page) only happens for signed-in consumers.

Try it

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)

Collapse
 
challan116ux profile image
challan116-ux •

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.