Ship pricing changes without breaking existing customers.
Modules, Bundles, and Variable Components are immutable once Published. Subscriptions pin specific versions. Upgrades are explicit. Grandfathering is a recorded decision, not an accident.
The five phases
- 1Edit a DraftModules, Bundles, and Variable Components start life as a Draft. Drafts are freely editable and never appear on a customer-facing subscription.
- 2Publish a versionPublishing freezes the row, stamps an immutable version number, links it to the previous version via previousVersionId (or supersededBy for components), and slots it into the catalog for new subscriptions.
- 3New subscriptions pin the latestWhen a Subscription is created against a Bundle, it stores (bundleId, bundleVersion) at the version that was current at creation time. Same for moduleId/moduleVersion and for each Variable Component in variableComponentRefs.
- 4Existing subscriptions stay where they arePublishing a new version does not touch live subscriptions. Their pins do not move. Renewals continue to use the pinned version, with prices already frozen on the subscription itself.
- 5Migrate explicitly, or grandfather foreverWhen you do want to move a subscription forward, call migrateSubscriptionModule / migrateSubscriptionBundle / migrateSubscriptionComponent with the target version and a timing (MID_CYCLE_PRORATED applies now and issues an adjustment invoice; NEXT_RENEWAL queues a pendingMigration that applies at the next renewal). bulkMigrateSubscriptions does the same thing for many subscriptions at once. To record that a customer stays where they are, call grandfatherSubscription with a reason — it moves nothing and writes the decision to their plan history, so the pin reads as deliberate rather than neglected.
What gets pinned where
{ bundleId, bundleVersion } — pinned at creation. Mutually exclusive with the Module pin.{ moduleId, moduleVersion } — for subscriptions that buy a single module rather than a bundle.variableComponentRefs[] — one entry per component, each with { componentId, componentVersion } . Inherited components from the bundle/module are flagged via inheritedFromParent.basePrice, currency, and (optionally) exchangeRateUsed are frozen on the subscription at creation. Renewals and invoices read from here.moduleRefs[] — each entry pins a specific Module version. Publishing a new Bundle version re-pins to whatever Module versions are current at that moment.variableComponentRefs[] — each entry pins a specific Component version. Same publish rule applies.Migrate vs. grandfather
Migrate when a customer should move to a newer version — for example, you’ve fixed a pricing error or added a new included quantity that the customer should get. migrateSubscriptionModule / migrateSubscriptionBundle / migrateSubscriptionComponent bump the pin and emit an EXPANDED or CONTRACTED lifecycle event with the MRR delta. The required timing argument decides what happens to revenue: MID_CYCLE_PRORATED applies immediately and issues an adjustment invoice for the remainder of the cycle; NEXT_RENEWAL records a pendingMigration that the renewal cron applies and bills cleanly. bulkMigrateSubscriptions does the same for many subscriptions at once; per-subscription failures do not block the rest. To clear a queued NEXT_RENEWAL before it applies, call cancelPendingMigration.
Grandfather when the customer should stay on the old version indefinitely — perhaps they have a multi-year contract or you don’t want to touch their pricing during a sensitive period. grandfatherSubscription takes a required reason and records the decision on the subscription’s plan history. It moves nothing: the pins stay exactly where they are, which is the point. From then on the subscription reports as grandfathered, subscriptionsWithOutdatedPins leaves it out, and the detail page shows the reason instead of prompting for a migration.
- New version publishedImmutable, available to new subscriptions.
- Existing pins stayRenewals keep using the pinned version.
- Migrate, mid-cycleApplies now with an adjustment invoice.orMigrate, next renewalQueued as a pending migration.orGrandfatherReason recorded; pins do not move.
To put a customer on a version that is older than the one they are on — or on a version of the plan that predates Revvly entirely — see Price a client on an older version.
Tracking what’s outdated
The subscriptionsWithOutdatedPins query returns active subscriptions whose Bundle, Module, or Component pins are behind the latest Published versions — along with the per-pin diff so you can see which entity drifted and by how many versions. It excludes subscriptions whose pin is deliberate — grandfathered, or reconstructed history — so the list stays a list of things to do; pass includeGrandfathered to see them anyway. Use it as the input to an ops triage queue: decide per row whether to migrate or grandfather, then sweep the rest.
Before publishing a new Module / Bundle version, call publishGapCheck to see the per-component, per-currency coverage matrix — publishing 422s if any component lacks a price in any supported currency. To debug a single billed line, call resolvePriceWalk to see exactly which scope (SUBSCRIPTION / BUNDLE / MODULE / GLOBAL_DEFAULT) produced the unit price.
Interplay with scoped pricing
Variable Component Prices are themselves pinned to a Component version. When a Subscription pins Variable Component v1, the price resolver only considers Variable Component Prices that target v1. Publishing a new Component version means publishing fresh prices for it (you can copy from v1 if the numbers haven’t changed). This is what lets you ship a new pricing structure without disturbing existing customers — they keep paying v1 prices for v1 components.