Migration
Magento 1.9 to Medusa.js: a ten-week cutover playbook
A 26-person fashion wholesaler in Gent. Magento 1.9 on borrowed time. Thirty-eight EDI partners that could not drop a feed. Here is how we got off it in ten weeks.

It was a Tuesday in Gent, and the warehouse manager was reading invoice numbers off a printed list. The Magento admin had been timing out since lunch. Three EDI partners had stopped pulling stock feeds, and the German chain was the one that pulled every nine minutes. The 26-person fashion wholesaler we were sitting with had built their last decade on this platform, and they could not get a usable answer out of it for the next forty minutes.
That was the meeting that started the project. What follows is the playbook we actually ran, in the order we ran it.
The brief that landed on our desk
The client sold premium menswear to roughly 800 independent boutiques across the Benelux, France, Germany and Northern Italy. Their stack was a Magento 1.9.4.5 install that had eaten nine years of one-off customisations, a PHP cron bridge to a Navision ERP, and a homegrown commission engine written in 2013 by a developer who had since moved to Lisbon and now ran a coffee shop.
Three constraints made the work interesting. Magento 1 was four years past official end-of-support and the PCI auditor had finally stopped issuing extensions. Thirty-eight EDI partners pulled stock and price feeds on schedules that, between them, hit the server every nine minutes. And the commission engine fed the salaries of fourteen people, a quarter of the company, every Friday.
Cutover had to be clean. Not because management said so, but because rolling back a wholesaler in the middle of the autumn order book is not actually possible. Once your buyers have committed to a season, the system that booked their commitments is the system you live with until February.
Mapping the rules nobody owned anymore
The commission engine was the riskiest piece, so we started there. The PHP code was 4,200 lines across nine files, with method names in a mix of Dutch and English. Comments were sparse. There was no test coverage. The current finance lead had inherited it in 2019 and treated the calculator as a black box that printed a CSV every Friday morning.
We did not try to read the code first. We instrumented it.
// app/code/local/Abn/CommissionAudit/Model/Observer.php
public function logCalculation(Varien_Event_Observer $observer)
{
$invoice = $observer->getEvent()->getInvoice();
$rep = $invoice->getOrder()->getSalesRepId();
$payload = [
'invoice_id' => $invoice->getId(),
'order_id' => $invoice->getOrder()->getId(),
'rep_id' => $rep,
'subtotal' => $invoice->getSubtotal(),
'discount' => $invoice->getDiscountAmount(),
'commission' => $this->_legacy->calculate($invoice),
'rule_path' => $this->_legacy->getLastRulePath(),
'inputs_hash' => sha1(json_encode($invoice->getData())),
];
Mage::getModel('abn_audit/run')->setData($payload)->save();
}
That observer logged every commission calculation that ran in production for eight weeks. By week three we had 47,000 rows. We could now ask the database what the legacy code actually did, instead of guessing from the code what it might do.
This step is boring and high leverage. If you take one thing from this post, take this: when the business logic is older than half your staff, do not start by reading it. Start by recording it.
The replacement target
For the new stack we picked Medusa.js as the commerce core and Temporal for everything that had to run reliably across hours or days. Magento gave the client a flexible storefront and a passable admin. Medusa gave them the same flexibility without the maintenance debt of a PHP monolith that was already legacy when the iPhone 5 shipped. Temporal handled the work that Magento had never been good at: the EDI cron storm, the commission rollups, the Navision sync.
The split mattered because Magento 1 had stuffed every long-running job into one cron table. When the German EDI partner's feed got slow, the price sync to the warehouse lagged, and on bad days the commission run missed Friday entirely. Separating durable workflows from request-time commerce was the architectural shift that made everything else possible.
A trimmed version of the commission workflow looks like this:
// workflows/commission-run.ts
import { proxyActivities } from '@temporalio/workflow';
import type * as activities from '../activities';
const { fetchInvoices, applyRule, postToNavision } =
proxyActivities<typeof activities>({
startToCloseTimeout: '5 minutes',
retry: { maximumAttempts: 8 },
});
export async function commissionRun(weekIso: string) {
const invoices = await fetchInvoices(weekIso);
const lines = [];
for (const inv of invoices) {
// applyRule mirrors the audited legacy path exactly.
// Any divergence raises a non-retryable error.
lines.push(await applyRule(inv));
}
await postToNavision(weekIso, lines);
return { week: weekIso, count: lines.length };
}
The workflow is dull on purpose. The interesting part is what applyRule does, and the interesting part of applyRule is that it reads from a flat rules.csv file that we generated from the 47,000 audit rows. The legacy code base shrank to roughly 180 lines of TypeScript and one spreadsheet.
EDI as its own cutover lane
The 38 EDI partners were the part of the project where the client got nervous. Most used EDIFACT D96A over SFTP. A handful used X12. Two still posted CSV to an FTP folder that the cron job swept hourly. One sent us a fax every Monday morning, which the warehouse re-keyed by hand. We did not fix the fax.
We did not migrate the partners in one shot. We built a small EDI gateway as a Medusa subscriber plus a Temporal workflow per partner, then moved partners across in batches of five.
// subscribers/edi-dispatch.ts
import { SubscriberArgs, SubscriberConfig } from '@medusajs/framework';
export default async function ediDispatch(
{ event: { data }, container }: SubscriberArgs<{ partnerId: string }>
) {
const temporal = container.resolve('temporalClient');
await temporal.workflow.start('partnerSync', {
workflowId: `edi-${data.partnerId}-${Date.now()}`,
taskQueue: 'edi',
args: [data.partnerId],
});
}
export const config: SubscriberConfig = {
event: ['inventory.updated', 'price.updated'],
};
Each partner workflow knew that partner's schedule, format, idempotency rules and quirks. The German chain that rejected line items with more than two decimals of precision. The Italian distributor that required ISO-8859-1 and silently dropped UTF-8 BOMs. The two retailers in Lille that ran a manual reconciliation every Wednesday morning and needed the feed paused for an hour.
None of that lived in shared code. It lived in the workflow for that partner. We were tempted, twice, to normalise across partners. We resisted both times.
The ten-week parallel run
We ran both stacks side by side for ten weeks. Magento 1.9 stayed canonical. Medusa was the shadow. Every order, invoice and commission calculation happened in both systems, and a comparator job ran nightly to flag any field where the two disagreed by more than a cent.
Week one, divergence was at 11 percent. By week four, it was at 0.4 percent, and every remaining gap was either a known legacy bug (which the client decided to keep) or a rounding choice in floating-point math that we moved to integer cents.
Where divergence came from
Three sources accounted for most of the gaps. Tax rounding on multi-line invoices, where Magento rounded per line and our first Medusa pass rounded the total. Free-text discount codes that bypassed the rule engine in Magento and got applied by hand on the invoice. And the 2017 commission rule for the Belgian sales rep who, for reasons nobody could explain, earned 0.6 percent extra on shoes but only between sizes 41 and 44.
We did not refactor that rule. We encoded it as a line in rules.csv with a comment that read # do not ask, and moved on. The instinct to clean up undocumented behaviour during a migration is the instinct that kills migrations.
Parallel run is not a luxury. On a legacy commerce migration with EDI partners and decade-old payroll logic, it is the difference between a quiet weekend and a six-week incident review.
The cutover weekend
Cutover happened on a Friday evening. We froze writes to Magento at 18:00 CEST. The final reconciliation comparator ran clean. At 19:30 we switched the DNS for the admin and the storefront. At 20:00 the first EDI partner pull hit Medusa and returned the expected stock feed. The team went home by 21:30.
Saturday morning, two partners reported empty feeds. Both had cached the old VPS IP address. We had left the old VPS running for exactly this reason, listening on a small Node service that responded with a redirect header and an email to ops. By noon both partners were green again. Nobody from the client called us until Monday, and that call was about a Navision report unrelated to the migration.
What we would do differently
Two things, mostly.
First, we underestimated how much legacy commission logic lived in invoice PDF templates rather than the calculation engine. The PDF generator quietly recomputed three fields from raw order data, using different rounding than the calculator. We caught it in week six. It should have been week one. If you are auditing money paths, audit the rendering layer as well, not just the calculation layer.
Second, we built the comparator job too late. It came online in week three of the parallel run, which meant the data backfill ran without it. If we had shipped it on day one, even with a thin schema covering only totals, we would have seen the tax-rounding gap during backfill instead of during live shadow traffic.
The smallest version of this for your own stack
If you are sitting on a Magento 1, a Drupal 7 commerce build, or a custom-PHP platform that the original developer no longer answers emails about, the cheapest five hours you can spend this month is to instrument your money paths. Write a logger like the one above. Capture every commission, tax and discount calculation for two weeks. Store the inputs hash next to the output.
You will know more about your own business at the end of that fortnight than the original developer knew when they wrote it. Whether you then migrate to Medusa, Shopify, commercetools or stay where you are becomes a much smaller decision than it currently feels.
When we ran the Gent project, the part that surprised us was how much of the eleven-year-old commission engine became debatable once we could see what it actually computed. The legacy rules survived, in a CSV. The 4,200 lines did not. If you want the longer version of this approach, including the Temporal workflow schemas and the comparator, our notes on legacy migration walk through the rest.
Key takeaway
A legacy commerce migration only fails on the parts nobody owns. Instrument the money paths first; the platform swap is the easy half.
FAQ
Why Medusa.js instead of Shopify or commercetools?
We needed full control of the data model and the ability to run custom EDI logic per partner without a SaaS rate-limit ceiling. Medusa gave that without the platform-team overhead of commercetools.
Could you have done this without Temporal?
Yes, with a worse outcome. We needed retries, signals and durable state for the EDI partner workflows. Doing that on cron and a queue is possible. Doing it well is roughly the same amount of code as adopting Temporal.
Why ten weeks of parallel run? Could you have cut over sooner?
We could have cut over at week six. The last four weeks bought us a clean Friday commission run inside the parallel window, which let finance sign off without a fallback plan. That signature was worth four weeks.
What happened to the original PHP commission code?
It stayed in production for the full parallel run, then was archived. The 47,000-row audit log lives in a cold store, which is the actual specification of the business logic if anyone ever needs to argue about it.