← Blog

Magento

Magento to Shopify Plus: rescuing 11,000 tier-priced SKUs

A 22-person industrial supplier in Eindhoven hit go-live on Shopify Plus with 11,000 SKUs, watched their tier pricing collapse, and had three weeks to fix it before quarter-end.

Jacob Molkenboer· Founder · A Brand New Company· 8 Nov 2024· 9 min
Open leather ledger with green ribbon, brass divider, iron shipping tag and red wax seal on ivory paper.

The phone call came at 09:14 on a Tuesday. The B2B account manager at a 22-person industrial supplier outside Eindhoven was on the line with their biggest customer, an electrical contractor in Tilburg who buys M6 stainless screws by the 5,000-piece carton. The Shopify Plus cutover had happened on Sunday night. On Tuesday morning the contractor logged in, opened his usual reorder, and saw retail prices on every line. Forty-two percent higher than the contract sheet he signed in January.

By lunchtime three other contract customers had called. By Wednesday the founder paused all B2B order intake on the new store and emailed us. They had eleven days to quarter-end, a board that had already approved the replatforming, and a Magento 2.4 estate that the agency who built it had handed back six months earlier. We took the call, opened the database, and quoted three weeks. It took twenty days.

The hidden geometry of Magento tier pricing

The supplier sells industrial fasteners, electrical components, and consumables to roughly 400 active business accounts across the Benelux. Their Magento 2.4.6 catalogue carried 11,287 active SKUs at the moment of the cutover. Tier pricing had been built up over nine years.

From the outside it looked simple. Open a product. Three or four price breaks at 1 to 9, 10 to 49, 50 to 199, and 200 plus. The reality underneath was different. Magento stores tier pricing in a table called catalog_product_entity_tier_price keyed on entity_id, customer_group_id, website_id, and qty. The supplier had seven customer groups: retail, three wholesale tiers, two distributor tiers, and one special group for a municipal account. Every group could carry its own breaks per SKU, and roughly 60 percent of SKUs had at least one group-specific override.

The row count in that one table was 184,612 price records. The receiving Shopify partner had budgeted for a flat product import. The geometry was an order of magnitude larger.

Where the first migration broke

The original plan, drawn up before we got the call, used a CSV export and the standard Shopify REST product endpoint with metafield writes for the tier table. It collapsed inside the first day of pilot uploads. Three reasons, named individually because each one is its own lesson.

1. REST is not sized for 100k+ price writes

Shopify rate-limits the REST Admin API at 4 calls per second on the standard bucket and 40 per second on Plus, with a leaky-bucket allowance. A naive loop writing 184,612 metafield rows on 11,287 products is at minimum two hours of sustained throughput, and in practice five to six hours once retries are factored in. The pilot script ran at 3 a.m. on a Friday and was still going when the team came in on Saturday. The fix here is the GraphQL Bulk Operations API, which accepts a JSONL payload and runs the write asynchronously on Shopify's side.

2. A metafield is data, not behaviour

This was the structural mistake. The first plan stored the tier breaks as a JSON metafield on each variant, then assumed Shopify would apply them at checkout. Shopify does not. A metafield is inert. To alter line-item prices you need either a B2B catalog publication, a Shopify Function, or a third-party app that wraps both. There is no admin setting that says "treat this metafield as a price rule".

3. Magento customer groups have no one-to-one on Shopify

Shopify B2B, available on the Plus plan, models the buyer side as Companies, which are linked to Catalogs, which are linked to price Publications. That is a richer model than Magento's customer_group table, but it is a different one. Mapping seven Magento groups to seven Shopify catalogs is straightforward. Mapping 400 individual companies into the correct catalog, and keeping that mapping correct when sales reps reclassify an account, is the work.

Warning

If a B2B migration plan stores tier prices as metafields without naming the engine that will read them, the plan is incomplete. Metafields hold data. Functions, catalogs, or apps apply it. Ask which one is doing the work before you start the import.

The architecture that worked

The recovery plan had four moving parts and one constraint: nothing could break the manual sales flow that the inside reps were running in parallel while we rebuilt.

Part one was structural. We created seven B2B catalogs, one per Magento customer group, each with its own price list. The price list carried the base price per variant for that group, meaning the price a single-unit order would pay. That alone covered roughly 35 percent of the supplier's order volume, because most contract customers buy in pack sizes that already match a tier break.

Part two was the tier engine. We wrote a Shopify Function on the cart-transform target that reads a JSON metafield from each variant and rewrites the line price based on quantity. The function is short. It is the most important file in the rebuild.

// extensions/tier-pricing/src/run.js
// @ts-check

const NO_CHANGES = { operations: [] };

/**
 * @param {RunInput} input
 * @returns {FunctionRunResult}
 */
export function run(input) {
  const ops = [];

  for (const line of input.cart.lines) {
    const raw = line.merchandise?.tierBreaks?.value;
    if (!raw) continue;

    /** @type {{min_qty:number, price:number}[]} */
    const tiers = JSON.parse(raw);
    if (!tiers.length) continue;

    const qty = line.quantity;
    const hit = tiers
      .filter(t => qty >= t.min_qty)
      .sort((a, b) => b.min_qty - a.min_qty)[0];
    if (!hit) continue;

    ops.push({
      update: {
        cartLineId: line.id,
        price: {
          adjustment: {
            fixedPricePerUnit: { amount: hit.price.toFixed(2) }
          }
        }
      }
    });
  }

  return ops.length ? { operations: ops } : NO_CHANGES;
}

The function reads tierBreaks from the variant metafield, finds the highest min_qty the line quantity satisfies, and writes the fixed per-unit price. It runs on every cart mutation, well under the instruction budget Shopify Functions impose, because the tier array is short. Most SKUs had three to five entries.

Part three was the importer. We replaced the REST loop with two GraphQL Bulk Operations: one for products and variants, one for metafields. The combined run completed in 38 minutes against the production store, including a verification pass.

Part four was company-to-catalog mapping. We exported Magento's customer-to-group join, ran it through a reconciliation script that flagged 23 accounts whose group assignment did not match the contract sheet on file, and then bulk-created Shopify B2B Companies with the correct catalog assignment via the GraphQL Admin API. The 23 mismatches were not migration bugs. They were nine years of drift between what the database said and what the sales team had agreed in person.

Twenty days on the calendar

Here is what the timeline actually looked like, because the abstract "three-week recovery" hides the part that matters.

Days 1 to 3. We pulled three tables out of Magento: catalog_product_entity_tier_price, customer_group, and customer_entity. We built a reconciliation spreadsheet that compared what every active customer should pay on their ten most-ordered SKUs against what the new Shopify Plus store was charging them that week. The spread averaged 31 percent. Three accounts were off by over 50 percent. The supplier had been refunding the difference manually for two days.

Days 4 to 7. We prototyped the cart-transform Function on a dev store seeded with five real SKUs and three real customers. We wrote 47 test cases derived from actual contract sheets. The Function passed 47 of 47 on day six. We then built the metafield schema, agreed on a tier_breaks definition with the inside sales lead, and signed it off.

Days 8 to 14. Build week. The bulk importer, the catalog creator, the company mapper, and the verification harness. We parallel-ran the harness against the live Magento store every night, comparing what 500 sampled SKU-and-customer combinations would price on each platform. Drift fell from 31 percent on day one to 0.4 percent by day twelve. The remaining 0.4 percent traced back to two Magento overrides that had been entered as flat prices rather than tier breaks. We migrated those as variant-level overrides on the Shopify catalog.

Days 15 to 18. Staging and dress rehearsal. Three customers were invited to log in on Friday afternoon, build a real basket, and stop at checkout. All three baskets priced correctly. One customer noticed a 600ms UI lag on the cart drawer, which we traced to a third-party loyalty app that we ended up removing.

Days 19 to 20. Cutover. The B2B order intake came back online at 08:00 on a Wednesday. By Friday evening, 71 contract customers had placed orders. Zero pricing tickets.

Takeaway

The number to track on a B2B replatforming is not products migrated. It is price fidelity per customer per SKU. If you cannot answer "what would this exact customer pay for this exact pack size right now on the new store" before cutover, you are not ready.

What we would do differently next time

Three things, and they are all upstream of the technology.

First, run the price-fidelity script as a gate, not a post-mortem. The supplier's first agency had a go-live checklist with 142 items on it. None of them were "do contract prices match the source data on a representative sample". Build that script in week one, run it nightly against staging, and let it veto the cutover.

Second, do not trust the previous agency's documentation about what Magento is doing. The handover notes described tier pricing as "standard customer-group tiers". They did not mention the municipal account override, the four legacy distributor tiers that were never decommissioned, or the 23 accounts whose group assignment had drifted from their signed contract. The database is the source of truth. The wiki is a hypothesis.

Third, name the engine before the import. If your migration plan stores tier prices as metafields, the very next paragraph in the plan must say which Function, catalog, or app reads them. The first plan we inherited skipped that paragraph. That was the entire bug.

The smallest thing you can do this week

If you are mid-flight on a Magento to Shopify Plus replatforming with B2B pricing in the mix, you do not need to rebuild your architecture this afternoon. You need one query. Open your Magento database, run the row count on catalog_product_entity_tier_price, and divide it by your active SKU count. If the answer is bigger than three, your migration's hard part is not the products. It is the price geometry around them.

When we rebuilt the Eindhoven supplier's catalogue we treated it as a legacy migration rather than a fresh store build, because the institutional knowledge lived in nine years of overrides rather than in the catalogue itself. The architecture that came out of that project is now what we reach for whenever a B2B Magento estate needs to move and the tier table has more rows than the product table.

Key takeaway

The hard part of a B2B Magento move is not the products. It is the price geometry: how many customer groups multiplied by how many tier breaks multiplied by how many SKUs.

FAQ

Can Shopify Plus handle B2B tier pricing without a third-party app?

Yes. B2B Catalogs cover company-level base prices, and a cart-transform Shopify Function can read tier breaks from a variant metafield and rewrite line prices at checkout.

How long should a Magento to Shopify Plus B2B migration actually take?

Six to twelve weeks for a mid-size catalogue with real tier pricing. Three weeks is recovery time on a half-built migration, not a clean start. Plan two months of build plus a parallel-run window.

Why did storing tier prices as metafields fail?

Metafields are inert data. Without a Function, B2B catalog, or app reading them and applying the price at checkout, they have no effect on what the customer pays.

What is a price-fidelity script and when should it run?

A nightly job that samples real customers and SKUs and compares what each pair would pay on Magento versus the new store. Treat near-zero drift as the gate for cutover, not a post-cutover check.

Should you migrate the customer-group structure as-is?

No. Re-derive it from current contracts before you migrate. Long-lived Magento stores almost always carry drift between the customer_group assignment and what was actually agreed with the customer.

magentomigratione-commercecase studyarchitectureintegrations

Building something?

Start a project