← Blog

WordPress

WordPress to Saleor: a six-week coffee roaster cutover

A 28-person roaster, 870 SKUs, 4,300 active SEPA mandates, and a 2019 dealer-margin spreadsheet nobody dared touch. Six weeks to cut over without breaking either.

Jacob Molkenboer· Founder · A Brand New Company· 13 Jun 2026· 8 min
Leather ledger half-open on ivory paper, burlap coffee sack with twine, brass weight, green bookmark, red wax seal.

It is 06:47 on a Tuesday in Maastricht. The head roaster has already pulled three batches of an Ethiopian Yirgacheffe. The first dealer order of the day fails at checkout because a WooCommerce cron missed a Stripe webhook for the eighth time this week. The shop owner texts: kun je hier vandaag nog naar kijken? That is the moment we knew the patching window had closed.

We had inherited the site three months earlier. WordPress 5.8. WooCommerce 6.1 with WooCommerce Subscriptions 3.0. Forty-one plugins, eleven of which had not seen an update since 2022. 870 active SKUs across whole bean, ground, bag size, and subscription cadence. 4,300 active SEPA mandates billed monthly through Mollie. And a 2019 Google Sheet, Dealer Margins V7 FINAL FINAL, that the founder used to calculate the wholesale price for 412 cafés, hotels, and small grocers across the Euregio.

The brief was simple. Get off this stack. Do not interrupt a single dealer invoice. Do not invalidate a single mandate. Six weeks.

Week 0: the audit before the rewrite

Before we touched a line of code, we built a spreadsheet of every plugin, every custom function in the child theme, and every meta_key in wp_postmeta that actually drove behaviour. The result was sobering. Of 41 plugins, only 14 were doing work we needed to preserve. The other 27 had been installed once, forgotten, and were silently extending the attack surface. The child theme contained 1,840 lines of PHP, of which roughly 600 were dead.

We tagged every preserved behaviour with an owner, a test, and a target system in the new stack. Anything we could not assign an owner to, we proposed dropping. The founder signed off on dropping 19 of the 27 unused plugins on day one, before any rebuild started. That alone made the site faster.

The lesson generalises. Before you rebuild a legacy WordPress shop, freeze it. Every dead plugin you remove from the old stack is one fewer behaviour you have to reverse-engineer when you cut over.

Week 1: collapsing 870 SKUs into a Saleor product model

In WooCommerce, every roast, grind, and bag size was a separate SKU. 870 of them. In Saleor's product model, the right shape was 38 products with variant axes for roast level, grind, and weight. Subscription cadence moved out of the product entirely and into Stripe Billing as a price object.

The mapping looked like this:

// scripts/map-skus.ts
type WooSku = {
  sku: string
  name: string
  attributes: { roast: string; grind: string; weight_g: number }
  subscription_interval?: 'weekly' | 'biweekly' | 'monthly'
  price_cents: number
}

type SaleorVariant = {
  productSlug: string
  attributes: Record<string, string>
  channelListings: { channel: string; priceAmount: number }[]
}

function toVariant(woo: WooSku): SaleorVariant {
  return {
    productSlug: slugify(woo.name.replace(/\s+\d+g.*/, '')),
    attributes: {
      roast: woo.attributes.roast,
      grind: woo.attributes.grind,
      weight: `${woo.attributes.weight_g}g`,
    },
    channelListings: [
      { channel: 'retail-eu', priceAmount: woo.price_cents / 100 },
    ],
  }
}

The script ran against a CSV exported from WooCommerce via WP All Export. It produced 870 variant rows under 38 products, which we loaded into Saleor with the standard GraphQL productBulkCreate mutation. End to end, the import took eleven minutes on a free-tier Saleor Cloud instance.

Week 2: the dealer-margin spreadsheet becomes a service

This was the part of the project that scared us. The 2019 spreadsheet had 17 tiers, but the tiers were not the whole story. There were 23 named exceptions (Brasserie Het Veerhuis always 18%), three rules based on annual volume that recalculated at the end of each quarter, and one rule that was literally a comment that said Familie Janssen krijgt prijs van 2017.

We did not try to model this inside Saleor's checkout. We built a small pricing service in front of it.

// services/dealer-pricing/resolve.ts
import { dealers, marginTiers, namedExceptions } from './data'

export function resolveDealerPrice(args: {
  dealerId: string
  productSlug: string
  unitListPrice: number
  quantity: number
  asOf: Date
}): number {
  const dealer = dealers.get(args.dealerId)
  if (!dealer) return args.unitListPrice

  const exception = namedExceptions.find(
    (e) => e.dealerId === args.dealerId && e.productSlug === args.productSlug,
  )
  if (exception) return exception.fixedPrice

  const ytdVolume = dealer.ytdVolumeAsOf(args.asOf)
  const tier = marginTiers.find((t) => ytdVolume >= t.minVolume)
  const margin = tier?.marginPct ?? 0

  return Math.round(args.unitListPrice * (1 - margin / 100))
}

The service reads the same Google Sheet on a 15-minute cache. The founder still edits the sheet. He just no longer needs to copy prices into orders by hand. After we shipped this, the team's order-entry time dropped from roughly nine minutes per dealer order to under one. That number is from their own helpdesk tickets, before and after.

Week 3: the 4,300 SEPA mandates problem

This is the part that kept us up at night.

A SEPA Direct Debit mandate is a legal authorisation from a payer to a specific creditor, identified by a Creditor Identifier (CID) and a mandate reference. You cannot just move mandates between processors the way you can move card tokens. The mandate's CID is bound to the merchant of record.

The shop's CID was registered to the roaster's legal entity, not to Mollie. That was the lucky break. It meant the mandate text the customers had signed was still valid for the same merchant. We needed to transfer the mandate references and bank details from Mollie to Stripe Billing, which accepts imported SEPA mandates via the payment_method.sepa_debit object plus a mandate.import flow that Stripe support enables on request.

The flow we used:

  1. Exported all active mandates from Mollie via their API (/v2/customers/.../mandates), including IBAN, signature date, and Mollie's mandate reference.
  2. Opened a ticket with Stripe support to enable bulk mandate import on the account.
  3. Uploaded the mandates as PaymentMethod objects with sepa_debit.mandate_reference set to the original Mollie reference. This preserves the legal chain so that, if a customer disputes a future debit, you can still produce the original signed mandate.
  4. Attached each PaymentMethod to a Stripe Customer, and the Customer to a Subscription on the new Stripe Billing price.
  5. Sent a pre-notification email 14 days before the first Stripe-collected debit, as required by the SEPA Rulebook.
Warning

The pre-notification is not optional. The SEPA Rulebook requires it 14 calendar days before the first collection on a mandate from a new processor, unless the mandate text specifies otherwise. Miss it and any disputed debit can be reversed for up to 13 months.

We sent the pre-notifications on day 21 of the project. The first Stripe-collected batch ran on day 36, four days into week 6.

Weeks 4 and 5: the parallel run

We did not flip DNS. We ran both stacks in parallel.

The new site lived at shop2.roaster.example. The old WooCommerce site stayed on the public domain. Both wrote to the same Postgres database for the order ledger. A reconciliation cron ran every fifteen minutes and compared open orders between systems. If an order existed in one and not the other, the cron paged us.

We migrated dealers in cohorts:

  • Week 4: the 38 lowest-volume dealers, each manually invited to the new portal. We watched their first three orders by hand.
  • Week 5: the next 174 dealers, automated invitation, dashboard-monitored.
  • Week 6: the final 200, plus the 4,300 retail subscriptions, plus DNS.

Two things we expected to be hard turned out to be straightforward. Address parsing was clean (most dealer addresses already had a proper postal-code format), and tax was a non-event once we set the channel's country and the customer's VAT ID on each B2B account. One thing we expected to be straightforward turned out to be hard: dealer login. The old site used WordPress accounts. The new portal used magic links. Roughly 40 of the 412 dealers do not have a personal email; they share a shop inbox. We had to teach those accounts to allow multiple devices on the same magic link, with a manual approval the first time.

Week 6: the cutover

On a Sunday at 03:00 CET, with the founder on a video call eating breakfast in Maastricht and us at a desk in Bangkok, we changed the A record. The old WordPress site started returning HTTP 410 on all storefront routes. Search engines picked up the 410s within four days; the new site's canonical URLs were already indexed because we had served them with noindex flipped to index on the morning of the flip.

The first Stripe-collected SEPA batch settled on Tuesday morning. 4,287 of 4,300 mandates collected cleanly. Thirteen returned with R-codes. Nine were stale IBANs that Mollie had also been failing on for months. Four were legitimate insufficient-funds returns that we expected. No mandate was invalidated. No dealer invoice was late. The 2019 spreadsheet still works, because it is still the source of truth.

What this cost in time, not in money

The total clock time was 41 working days. The expensive parts were not the new stack. Next.js, Saleor, and Stripe Billing are well-trodden. The expensive parts were the audit (six days), the dealer-pricing service (four days, mostly arguing about edge cases), the SEPA import paperwork (three days waiting on Stripe support), and the parallel-run reconciliation cron (two days, all of it spent in test cases).

If you are sitting on a WordPress 5.x WooCommerce shop with a subscription business and a margin spreadsheet, the order matters. Audit first. Model second. Migrate payment instruments third. Parallel-run fourth. Flip last. Skip the audit and you will rebuild dead code. Skip the parallel run and you will find out about the broken edge cases when a real customer hits them.

When we built this for the roaster, the part we underestimated was the SEPA pre-notification window; we solved it by adding 14 days of slack to the cutover calendar and pre-notifying on day 21, before the new stack was even feature-complete. The full legacy-site migration playbook we use lives behind that link.

If you want a five-minute version of the audit on your own shop, run this on your WordPress host: wp plugin list --status=active --format=csv | wc -l. Then ask yourself how many of those lines you could name the purpose of without checking. Whatever that gap is, that is the size of the work nobody has done yet.

Key takeaway

Audit before you rebuild, send the SEPA pre-notification 14 days before any new debit, and run both stacks in parallel until reconciliation is clean.

FAQ

How long should this kind of migration take?

About six weeks of focused work for a single shop with a long tail of subscription accounts, plus a 14-day SEPA pre-notification window that has to happen in parallel.

Can you keep WooCommerce running while you build the new stack?

Yes. Run both for two weeks under a reconciliation cron that compares open orders. Flip DNS only when zero discrepancies persist for 72 hours.

Does Stripe support importing SEPA mandates from another processor?

Yes, via a bulk import flow that Stripe enables on request. The original Creditor Identifier must already belong to the merchant, not to the previous processor.

What is the biggest risk in a project like this?

Missing the 14-day SEPA pre-notification before the first new debit. Any disputed collection can then be reversed for up to 13 months with no defence.

wordpressmigrationcase studye-commercearchitectureintegrations

Building something?

Start a project