Source: https://www.revvly.ai/docs/guides/versioning

Guide

# 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

1. 1Edit a DraftModules, Bundles, and Variable Components start life as a Draft. Drafts are freely editable and never appear on a customer-facing subscription.
2. 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.
3. 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.
4. 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.
5. 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

- **Subscription → Bundle**: `{ bundleId, bundleVersion }` — pinned at creation. Mutually exclusive with the Module pin.

- **Subscription → Module**: `{ moduleId, moduleVersion }` — for subscriptions that buy a single module rather than a bundle.

- **Subscription → Variable Components**: `variableComponentRefs[]` — one entry per component, each with`{ componentId, componentVersion }`. Inherited components from the bundle/module are flagged via `inheritedFromParent`.

- **Subscription → Price**: `basePrice`, `currency`, and (optionally)`exchangeRateUsed` are frozen on the subscription at creation. Renewals and invoices read from here.

- **Bundle → Modules**: `moduleRefs[]` — each entry pins a specific Module version. Publishing a new Bundle version re-pins to whatever Module versions are current at that moment.

- **Module → Variable Components**: `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.

After a new version is published

1. New version publishedImmutable, available to new subscriptions.
2. Existing pins stayRenewals keep using the pinned version.
3. Migrate, mid-cycleApplies now with an adjustment invoice.orMigrate, next renewalQueued as a pending migration.orGrandfatherReason recorded; pins do not move.

Existing subscriptions stay pinned until someone migrates them or records a grandfather decision.

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](https://www.revvly.ai/docs/guides/legacy-pricing).

i

Neither action is reversible without writing a new lifecycle event. Migrations cannot be undone — only superseded by another migration. Grandfathering can be lifted by an explicit migration.

## 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.

## Next up

- [Scope-based pricing ›](https://www.revvly.ai/docs/guides/scoped-pricing)
- [Pricing models reference ›](https://www.revvly.ai/docs/guides/pricing-models)
- [The Subscription concept ›](https://www.revvly.ai/docs/concepts/subscription)

