September 16, 2026 · Steve Johnson
Two Payment Systems, One Set of Rules
The hardest product constraint on a platform migration I led wasn't a technical one in the usual sense — it was that customers on the legacy payment rail and customers freshly onboarded onto the new one had to be genuinely indistinguishable in what they were allowed to do, see, and pay, for as long as both rails existed side by side.
Why "just migrate everyone's billing" wasn't the plan
Migrating every customer's payment method and subscription onto the new billing system on day one is the clean answer, and it's also the one that guarantees a wave of failed charges, expired cards, and support tickets at exactly the moment you can least afford them. The actual plan kept the legacy payment rail live and functioning for existing subscribers, while every new signup went straight onto the new one — which meant every piece of product logic that touched entitlement, cancellation, or reactivation had to work correctly against either rail, transparently, without the rest of the application needing to know or care which one a given customer was on.
Where the seams actually showed up
Access checks were the first seam: "does this customer have an active subscription" had to resolve identically whether the underlying record lived in the legacy commerce system or the new one, which meant building a unified check rather than two parallel ones that the rest of the app had to remember to call correctly.
Cancellation was the second, and it had to route through the legacy commerce platform's own API for customers still on that rail — you can't cancel a subscription in a system that doesn't know about your new billing platform by writing to your new billing platform.
Reactivation was where the real complexity concentrated, because a canceled subscription could be in any of several states — on-hold, still within its original trial-aligned period, or fully lapsed and restarting — and each state had a different correct outcome. A customer reactivating from an on-hold state shouldn't be double-charged for a period they'd already paid for. A customer restarting from fully lapsed needed to come back at the current rate, while a customer who'd never actually left grandfathered legacy pricing needed that pricing honored on win-back rather than silently reset. Automatic tax calculation had to apply consistently across every one of those paths, and legacy orders still needed to generate the same PDF receipts customers were used to getting.
The principle that made this tractable
None of this is solvable by writing more careful conditionals scattered through the checkout and account-management code — that approach produces exactly the kind of subtle, state-dependent billing bug that's brutal to catch in testing because there are more state combinations than anyone reliably enumerates by hand. What made it tractable was treating "which rail is this customer on" as an implementation detail resolved at the lowest possible layer, so that every higher-level path — access checks, cancellation, reactivation, receipts — could be written once, against a single unified model of subscription state, rather than twice with a branch at every call site.
The generalizable lesson: when a migration requires two systems to coexist and behave identically from the outside, the temptation is to special-case the transition everywhere it's visible. The more durable answer is building the one seam where the two systems actually differ, keeping it as narrow and low-level as possible, and making every other part of the product provably unaware that a migration is happening at all.