← Blog

WordPress

Van WordPress naar Saleor: zes weken voor een koffiebranderij

Een branderij met 28 mensen, 870 SKU's, 4.300 actieve SEPA-mandaten en een dealer-marge sheet uit 2019 die niemand durfde aan te raken. Zes weken om over te zetten zonder iets te breken.

Jacob Molkenboer· Oprichter · A Brand New Company· 13 jun 2026· 8 min
Leren grootboek half open op ivoorpapier, jute koffiezak met touw, koperen gewicht, groene bladwijzer, rood lakzegel.

Het is 06:47 op een dinsdag in Maastricht. De head roaster heeft al drie batches van een Ethiopische Yirgacheffe gedraaid. De eerste dealerorder van de dag faalt bij de checkout omdat een WooCommerce cron voor de achtste keer deze week een Stripe webhook heeft gemist. De eigenaar appt: kun je hier vandaag nog naar kijken? Op dat moment wisten we dat patchen geen optie meer was.

We hadden de site drie maanden eerder overgenomen. WordPress 5.8. WooCommerce 6.1 met WooCommerce Subscriptions 3.0. Eenenveertig plugins, waarvan elf sinds 2022 geen update meer hadden gezien. 870 actieve SKU's, verdeeld over hele bonen, gemalen, zakgrootte en abonnementsfrequentie. 4.300 actieve SEPA-mandaten die maandelijks via Mollie werden geïnd. En een Google Sheet uit 2019, Dealer Margins V7 FINAL FINAL, waarmee de oprichter de groothandelsprijs berekende voor 412 cafés, hotels en kleine speciaalzaken in de Euregio.

De opdracht was simpel. Weg van deze stack. Geen enkele dealerfactuur onderbreken. Geen enkel mandaat ongeldig maken. Zes weken.

Week 0: de audit vóór de rewrite

Voor we één regel code aanraakten, maakten we een spreadsheet van elke plugin, elke custom function in het child theme en elke meta_key in wp_postmeta die daadwerkelijk gedrag stuurde. De uitkomst was ontnuchterend. Van de 41 plugins deden er maar 14 werk dat we wilden behouden. De andere 27 waren ooit geïnstalleerd, vergeten, en breidden in stilte het aanvalsoppervlak uit. Het child theme bevatte 1.840 regels PHP, waarvan ongeveer 600 dood.

Elk te behouden stukje gedrag kreeg een eigenaar, een test en een doelsysteem in de nieuwe stack. Alles waar we geen eigenaar voor konden vinden, stelden we voor te schrappen. De oprichter ging op dag één akkoord met het verwijderen van 19 van de 27 ongebruikte plugins, voordat de rebuild was begonnen. Alleen dat al maakte de site sneller.

De les is breder toepasbaar. Voordat je een verouderde WordPress-shop herbouwt, bevries je 'm eerst. Elke dode plugin die je uit de oude stack haalt, is één stuk gedrag minder dat je tijdens de cutover moet reverse-engineeren.

Week 1: 870 SKU's terugbrengen tot een Saleor-productmodel

In WooCommerce was elke roast, maalgraad en zakgrootte een aparte SKU. 870 stuks. In het productmodel van Saleor was de juiste vorm 38 producten met variantassen voor roastniveau, maalgraad en gewicht. De abonnementsfrequentie verhuisde volledig uit het product en werd een price object in Stripe Billing.

De mapping zag er zo uit:

// 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 },
    ],
  }
}

Het script draaide tegen een CSV die we via WP All Export uit WooCommerce hadden geëxporteerd. Resultaat: 870 variantrijen onder 38 producten, die we met de standaard GraphQL productBulkCreate-mutatie in Saleor laadden. Van begin tot eind duurde de import elf minuten op een gratis Saleor Cloud-instantie.

Week 2: de dealer-margesheet wordt een service

Dit was het onderdeel waar we het bangst voor waren. De sheet uit 2019 had 17 tiers, maar daarmee was het verhaal niet af. Er waren 23 benoemde uitzonderingen (Brasserie Het Veerhuis altijd 18%), drie regels op basis van jaarvolume die aan het einde van elk kwartaal herberekend werden, en één regel die letterlijk een comment was met de tekst Familie Janssen krijgt prijs van 2017.

We probeerden dit niet in de checkout van Saleor te modelleren. We bouwden er een kleine pricing service voor.

// 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))
}

De service leest dezelfde Google Sheet met een cache van 15 minuten. De oprichter bewerkt de sheet nog steeds. Hij hoeft alleen geen prijzen meer met de hand in orders te kopiëren. Nadat we dit live zetten, daalde de order-entry-tijd van het team van ongeveer negen minuten per dealerorder naar minder dan één. Dat getal komt uit hun eigen helpdesktickets, voor en na.

Week 3: het probleem van 4.300 SEPA-mandaten

Dit is het deel waar we wakker van lagen.

Een SEPA Direct Debit-mandaat is een juridische machtiging van een betaler aan een specifieke incassant, geïdentificeerd door een Creditor Identifier (CID) en een mandaatreferentie. Je kunt mandaten niet zomaar tussen processors verplaatsen zoals je card tokens kunt verplaatsen. De CID van het mandaat is gebonden aan de merchant of record.

De CID van de shop stond op naam van de juridische entiteit van de branderij, niet op Mollie. Dat was de meevaller. Het betekende dat de mandaattekst die klanten hadden ondertekend nog steeds geldig was voor dezelfde merchant. We moesten de mandaatreferenties en bankgegevens van Mollie naar Stripe Billing overzetten. Stripe Billing accepteert geïmporteerde SEPA-mandaten via het payment_method.sepa_debit-object, plus een mandate.import-flow die Stripe support op aanvraag aanzet.

De flow die we gebruikten:

  1. Alle actieve mandaten via hun API uit Mollie geëxporteerd (/v2/customers/.../mandates), inclusief IBAN, ondertekendatum en de mandaatreferentie van Mollie.
  2. Een ticket bij Stripe support aangemaakt om bulk mandate import op het account aan te zetten.
  3. De mandaten geüpload als PaymentMethod-objecten, met sepa_debit.mandate_reference ingesteld op de originele Mollie-referentie. Zo blijft de juridische keten intact: als een klant later een incasso betwist, kun je het origineel ondertekende mandaat nog overleggen.
  4. Elk PaymentMethod aan een Stripe Customer gekoppeld, en die Customer aan een Subscription op de nieuwe Stripe Billing-prijs.
  5. Een pre-notificatie-e-mail verstuurd 14 dagen vóór de eerste door Stripe geïnde incasso, zoals het SEPA Rulebook eist.
Let op

De pre-notificatie is niet optioneel. Het SEPA Rulebook eist hem 14 kalenderdagen vóór de eerste incasso op een mandaat bij een nieuwe processor, tenzij de mandaattekst iets anders zegt. Vergeet je hem, dan kan elke betwiste incasso tot 13 maanden teruggedraaid worden.

We stuurden de pre-notificaties op dag 21 van het project. De eerste batch die Stripe inde draaide op dag 36, vier dagen in week 6.

Week 4 en 5: parallel draaien

We zetten geen DNS om. We lieten beide stacks naast elkaar draaien.

De nieuwe site stond op shop2.roaster.example. De oude WooCommerce-site bleef op het publieke domein. Beide schreven naar dezelfde Postgres database voor het orderoverzicht. Een reconciliation cron draaide elke vijftien minuten en vergeleek openstaande orders tussen de systemen. Stond een order wel in het ene en niet in het andere, dan kregen we een melding.

We migreerden dealers in cohorten:

  • Week 4: de 38 dealers met het laagste volume, allemaal handmatig uitgenodigd voor het nieuwe portaal. We hielden hun eerste drie orders met de hand in de gaten.
  • Week 5: de volgende 174 dealers, geautomatiseerde uitnodiging, gemonitord via dashboard.
  • Week 6: de laatste 200, plus de 4.300 retailabonnementen, plus DNS.

Twee dingen waarvan we dachten dat ze lastig zouden worden, vielen mee. Het parsen van adressen was schoon (de meeste dealeradressen hadden al een nette postcodeopmaak), en btw was een non-issue zodra we het land van het kanaal en het btw-nummer van de klant op elk B2B-account hadden gezet. Eén ding dat we makkelijk inschatten, viel tegen: de dealer login. De oude site gebruikte WordPress-accounts. Het nieuwe portaal werkte met magic links. Ongeveer 40 van de 412 dealers hebben geen persoonlijk e-mailadres; ze delen een shop-inbox. We moesten die accounts leren om meerdere apparaten op dezelfde magic link toe te staan, met de eerste keer een handmatige goedkeuring.

Week 6: de cutover

Op een zondag om 03:00 CET, met de oprichter op een videogesprek terwijl hij in Maastricht ontbeet en wij achter een bureau in Bangkok, veranderden we het A-record. De oude WordPress-site begon op alle storefront-routes HTTP 410 terug te geven. Zoekmachines pikten de 410's binnen vier dagen op; de canonical URLs van de nieuwe site stonden al geïndexeerd, omdat we ze 's ochtends bij de flip van noindex naar index hadden gezet.

De eerste door Stripe geïnde SEPA-batch werd dinsdagochtend afgewikkeld. 4.287 van de 4.300 mandaten incasseerden zonder problemen. Dertien kwamen terug met R-codes. Negen waren verouderde IBANs waar Mollie al maanden op faalde. Vier waren legitieme onvoldoende-saldo-returns die we hadden verwacht. Geen enkel mandaat raakte ongeldig. Geen enkele dealerfactuur kwam te laat. De sheet uit 2019 werkt nog steeds, want hij is nog altijd de bron van waarheid.

Wat dit kostte in tijd, niet in geld

De totale doorlooptijd was 41 werkdagen. De dure stukken zaten niet in de nieuwe stack. Next.js, Saleor en Stripe Billing zijn bekend terrein. Duur waren de audit (zes dagen), de dealer-pricing service (vier dagen, vooral discussiëren over edge cases), het papierwerk voor de SEPA-import (drie dagen wachten op Stripe support), en de reconciliation cron voor de parallel run (twee dagen, allemaal in testcases gestoken).

Zit je op een WordPress 5.x WooCommerce-shop met een abonnementsbusiness en een margesheet, dan doet de volgorde ertoe. Eerst auditen. Daarna modelleren. Dan betaalmiddelen migreren. Daarna parallel draaien. Pas op het eind omflippen. Sla je de audit over, dan bouw je dode code opnieuw op. Sla je de parallel run over, dan hoor je van kapotte edge cases pas als een echte klant erin trapt.

Toen we dit voor de branderij bouwden, was het SEPA-pre-notificatievenster wat we onderschatten. We losten het op door 14 dagen lucht toe te voegen aan de cutover-kalender en al op dag 21 te pre-notificeren, ruim voordat de nieuwe stack feature-compleet was. Het volledige playbook voor legacy-site migraties dat we gebruiken vind je achter die link.

Wil je een vijf-minuten-versie van de audit op je eigen shop, draai dit dan op je WordPress-host: wp plugin list --status=active --format=csv | wc -l. Vraag jezelf vervolgens af van hoeveel van die regels je zonder te kijken het doel kunt benoemen. Het gat dat daar zit, is de omvang van het werk dat nog niemand heeft gedaan.

Kern

Audit voordat je herbouwt, stuur de SEPA-pre-notificatie 14 dagen vóór elke nieuwe incasso, en laat beide stacks parallel draaien tot de reconciliatie schoon is.

FAQ

Hoe lang duurt zo'n migratie?

Ongeveer zes weken geconcentreerd werk voor één shop met een lange staart aan abonnementsaccounts, plus een SEPA-pre-notificatievenster van 14 dagen dat parallel meeloopt.

Kun je WooCommerce laten draaien terwijl je de nieuwe stack bouwt?

Ja. Laat ze allebei twee weken naast elkaar draaien onder een reconciliation cron die openstaande orders vergelijkt. Flip DNS pas als er 72 uur lang nul discrepanties zijn.

Ondersteunt Stripe het importeren van SEPA-mandaten van een andere processor?

Ja, via een bulk import flow die Stripe op aanvraag aanzet. De originele Creditor Identifier moet al van de merchant zijn, niet van de vorige processor.

Wat is het grootste risico in zo'n project?

Het missen van de SEPA-pre-notificatie van 14 dagen vóór de eerste nieuwe incasso. Elke betwiste incasso kan dan tot 13 maanden teruggedraaid worden zonder dat je iets kunt inbrengen.

wordpressmigrationcase studye-commercearchitectureintegrations

Iets bouwen?

Start een project