We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
From the blog
Grandfathered Pricing in Phoenix: Raise Prices Without Repricing Existing Customers
By Liam Killingback ·
Grandfathered Pricing in Phoenix: Raise Prices Without Repricing Existing Customers
Every SaaS eventually changes its prices. The plan you launched at $20 with 1,000 AI generations was a guess, and six months of real usage says it should be $30 with 2,000. The pricing page is the easy part. The hard part is everyone who already pays you: do they move to the new plan, stay on the old one, and for how long?
The common answer is grandfathered pricing: existing customers keep the plan they bought, new customers get the new one, and nobody is repriced by surprise. It sounds like a business decision, and it is, but it is also a data modelling problem. If your plan limits live in a module attribute and your Stripe price id lives in config, a single deploy can quietly change what a paying customer gets.
This post covers how to implement grandfathered pricing in Phoenix properly: the data model, the Stripe side, how to move people deliberately when you do want to, and how Aurora Meter handles plan versions so a deploy can never reprice anybody.
What grandfathered pricing actually means
“Grandfathering” covers a few different promises. Be precise about which one you are making, because your code has to keep it:
- Price lock: the customer keeps paying the old amount.
- Entitlement lock: the customer keeps the old limits and features, whatever they pay.
- Both, until something happens: the old plan holds until they change plans, cancel, or a sunset date you announce.
Most SaaS products mean the third. A customer on the old :pro keeps $20 and 1,000 generations until they upgrade, downgrade or cancel. If they come back after cancelling, they get today’s plans.
The rule that makes all three possible is simple to say and easy to break: a subscription must point at a specific version of a plan, not at the plan’s name. “Pro” is a marketing label. “Pro as sold between March and October” is a contract.
The usual way this goes wrong
Here is a plans module that most Phoenix apps start with, and that works perfectly until the first price change:
defmodule MyApp.Plans do
@plans %{
free: %{price: 0, ai_generations: 50},
pro: %{price: 2_000, ai_generations: 1_000}
}
def get(plan), do: Map.fetch!(@plans, plan)
end
The subscriptions table stores plan: "pro". Now you edit the map to %{price: 3_000, ai_generations: 2_000} and deploy. What happens?
- Every existing Pro customer instantly gets 2,000 generations. Generous, and you just doubled your costs for a cohort paying the old price.
- Or, if you went the other way and cut a limit, every existing customer is silently downgraded mid-period. That is a support queue and a churn spike.
- Stripe still charges the old customers $20, because their subscription items point at the old Price. So your app’s idea of “Pro” and your billing provider’s idea of “Pro” now disagree.
The bug is not the edit. It is that one name meant two contracts, and the code had no way to tell them apart.
The data model: plan id plus version
The fix is to version plans and store the version on the subscription.
create table(:subscriptions) do
add :org_id, references(:orgs), null: false
add :plan_id, :string, null: false
add :plan_version, :string, null: false
add :stripe_subscription_id, :string
timestamps()
end
And the plans module learns about versions:
defmodule MyApp.Plans do
@versions %{
{:pro, "1"} => %{price: 2_000, ai_generations: 1_000, effective_at: nil},
{:pro, "2"} => %{price: 3_000, ai_generations: 2_000, effective_at: ~U[2026-11-01 00:00:00Z]}
}
def get(plan_id, version), do: Map.fetch!(@versions, {plan_id, version})
# What a NEW subscriber gets right now.
def current_version(plan_id, now \\ DateTime.utc_now()) do
@versions
|> Enum.filter(fn {{id, _}, v} -> id == plan_id and effective?(v, now) end)
|> Enum.max_by(fn {_, v} -> v.effective_at || ~U[1970-01-01 00:00:00Z] end, DateTime)
|> then(fn {{_, version}, _} -> version end)
end
defp effective?(%{effective_at: nil}, _now), do: true
defp effective?(%{effective_at: at}, now), do: DateTime.compare(at, now) != :gt
end
Two rules keep this honest:
-
New subscriptions are written with
current_version/1. -
Entitlement checks read the version stored on the subscription, never
current_version/1.
That is the whole idea. It is also where hand-rolled versions start to wobble, because nothing stops a teammate from editing {:pro, "1"} in place six months later. The map is just data, and data gets edited.
Doing it with Aurora Meter’s plan versions
Aurora Meter 1.0 builds this into the plans DSL, with one extra guarantee: an edit to an existing version is refused at boot. You declare versions next to each other:
defmodule MyApp.Plans do
use AuroraMeter.Plans
plan :pro do # version "1", the base version
price 2_000
limit :ai_generations, 1_000, :hard
feature :api_access, true
end
plan :pro, version: "2", effective_at: ~U[2026-11-01 00:00:00Z] do
price 3_000
limit :ai_generations, 2_000, :hard
feature :api_access, true
end
end
A block with no version is version "1". From the effective_at instant, a new subscriber to :pro gets version 2. Everyone already on version 1 stays there, because their subscription row names the pair {:pro, "1"}:
AuroraMeter.subscribe(org, :pro) # the version effective now
AuroraMeter.subscribe(org, :pro, version: "1") # pinned to a named version
AuroraMeter.plan(org) # the version this tenant is on
AuroraMeter.Plans.get(:pro) # the version effective right now
AuroraMeter.Plans.get(:pro, "1") # a named version, whatever the clock says
AuroraMeter.Plans.versions(:pro) # every version, oldest first
The entitlement calls you already use (check/2, with_quota/3, quota/2, entitled?/2) resolve through the tenant’s own version. So the same request handler gives a grandfathered customer 1,000 generations and a new one 2,000, with no if legacy? branch anywhere in your code:
case AuroraMeter.with_quota(org, :ai_generations, fn -> generate_report(org) end) do
{:ok, report} -> {:ok, report}
{:error, :limit_exceeded} -> {:error, "You have used this month's generations."}
{:error, :not_entitled} -> {:error, "Your plan does not include reports."}
end
Editing a version is refused
On boot, Aurora Meter stores a fingerprint of each compiled version in an aurora_meter_plan_versions table. If someone changes the price, a limit, a metered allowance or a feature value inside an existing version and deploys, startup raises AuroraMeter.PlanVersionConflictError, naming the plan, the version and the remedy: declare a new version instead.
config :aurora_meter, plan_version_conflict: :raise # the 1.0 default
This is the guard the hand-rolled map cannot give you. Grandfathering stops depending on everyone remembering the rule, because the rule is enforced by the application refusing to boot. Changing a version’s effective_at is allowed, since that only says when new subscribers start getting it.
The stored snapshots also mean that when you eventually delete the version 1 block from your code, tenants still on it remain interpretable from the registry. Code stays the authority for what a plan is; the table exists so old contracts do not vanish.
The Stripe side of grandfathering
Your app and Stripe have to agree, and Stripe’s model happens to suit grandfathering well:
- A Price’s amount cannot be edited. To charge $30 you create a new Price on the same Product. That is Stripe’s own version of “declare a new version”.
- Existing subscriptions keep their Price. Creating the new one does nothing to current subscribers. They renew at $20 until a subscription item is changed.
-
Archive the old Price (
active: false) once version 2 is live. Archived Prices keep billing the subscriptions that use them, but cannot be used for new checkouts, which is exactly the grandfathering rule. -
Move the lookup key. If checkout resolves prices by lookup key (a good habit), create the new Price with the same
lookup_keyandtransfer_lookup_key: true. New checkouts pick up $30 with no code change, and old subscriptions are untouched.
The mapping you need in your own data is from Stripe Price to plan version, so a webhook can record the right pair:
defmodule MyApp.Billing.PriceMap do
# Stripe price id => {plan id, version}
@prices %{
"price_pro_v1_monthly" => {:pro, "1"},
"price_pro_v2_monthly" => {:pro, "2"}
}
def plan_for(price_id), do: Map.fetch(@prices, price_id)
end
Use the real price ids from your Stripe account, or keep them in config per environment. The important part is that the mapping is by Price, not by Product, because both versions live on the same Product.
When customer.subscription.updated arrives, look up the item’s price and write both plan_id and plan_version. A webhook that writes only plan_id has thrown away the one fact grandfathering needs. If you already let customers self-serve through Stripe’s portal, the Stripe Customer Portal post covers keeping those portal changes in sync, and the same rule applies there: always record the pair.
Moving grandfathered customers on purpose
Grandfathering is rarely forever. At some point you will want to move legacy customers, either because the old plan is losing money or because supporting six versions is a tax on every feature. Do it the way you would want it done to you:
- Announce it with a date, ideally at least one full billing period ahead.
- Show each customer exactly what changes for them, not a generic email.
- Apply it at their period boundary, never mid-period.
- Let them cancel or switch before it happens.
Aurora Meter models a plan change as a scheduled transition rather than an edit, which maps cleanly onto that list.
Show what will change
preview_transition/3 is a pure read: no lock, no row written. It returns the from and to versions and a list of changes, each with a direction such as :increase, :decrease, :added or :removed:
{:ok, preview} =
AuroraMeter.Subscriptions.preview_transition(org, :pro, version: "2")
preview.changes
# each change carries a :direction you can render in an email
preview.effective_at
# the end of this tenant's current period
That is the body of your announcement email, generated per customer. One caution, straight from the docs: the price in the preview is the plan’s declared list price in cents. Core computes no proration, so do not present it as “you will be charged”. If a billing provider is configured, its own figures appear in the preview’s provider section.
Schedule it at their boundary
AuroraMeter.Subscriptions.schedule_transition(org, :pro,
version: "2",
ref: "reprice-pro-v2:#{org.id}"
)
Without :effective_at, the transition lands at the end of that tenant’s own current period. On a core install that is the end of the calendar month; with Aurora Meter Pro it is their Stripe billing cycle. Until then nothing changes for them: check/2 and quota/2 still answer from version 1.
The :ref is an idempotency key. Running your migration script twice returns the same transition rather than scheduling two, which matters when you are looping over thousands of tenants:
MyApp.Accounts.orgs_on_plan_version(:pro, "1")
|> Enum.each(fn org ->
{:ok, _} =
AuroraMeter.Subscriptions.schedule_transition(org, :pro,
version: "2",
ref: "reprice-pro-v2:#{org.id}"
)
end)
A customer who replies “please keep me on the old plan” is one call:
AuroraMeter.Subscriptions.cancel_transition(org, "reprice-pro-v2:#{org.id}")
Apply them when they come due
Transitions are applied by a separate call, so you control when:
AuroraMeter.Subscriptions.apply_due_transitions(limit: 500)
# {:ok, %{applied: 3, skipped: 0, failed: 0, cursor: :done}}
It is safe to run from every node at once; each transition is applied exactly once and the rest count a skip. With Oban installed, AuroraMeter.Oban.PlanTransitions runs it every five minutes for you. The full lifecycle, including what happens when a customer changes plans in Stripe while a transition is pending, is in the versioned plans and transitions guide.
Keep Stripe in step
On the Stripe side, the matching move is to change the subscription item’s price at renewal rather than now, so nobody gets a surprise proration. A Stripe Subscription Schedule with a second phase starting at current_period_end, or an update with proration_behavior: "none" timed for the renewal, both work. When a paid change should only take effect once Stripe confirms it, schedule with confirm: :provider and the transition waits for that confirmation. Aurora Meter Pro does that wiring: Stripe Checkout and webhook sync, subscription-aligned periods, and it cancels a pending transition when it sees the customer has already set their subscription to cancel at period end.
Policies worth deciding up front
The code is the easy half. Write these decisions down before your first price change so support can answer consistently:
- Does an upgrade lose the grandfathered price? Usually yes: moving from legacy Pro to Scale puts them on the current Scale. Say so on the upgrade screen.
-
Does a downgrade and re-upgrade keep it? Usually no. Once they leave a version they cannot return to it, and
subscribe/2without a version will hand them the current one anyway. - Does a lapsed payment lose it? Decide. Many products keep it through a dunning period and drop it after cancellation.
- Is there a sunset date? “Legacy plans are honoured for 12 months” is easier to support than “forever”, and gives you a date to schedule transitions for.
- What do you show on the account page? Name the version. “Pro (2026 pricing, $20/month)” stops tickets that start with “why does my colleague’s Pro have more?”
If you are still deciding what the plans should be in the first place, our plan-based feature gating and entitlements guide covers the plan DSL and gating patterns this post builds on.
Summary
Grandfathered pricing in Phoenix comes down to one modelling rule and one operational rule:
- Store the version, not just the plan name, on every subscription, and resolve entitlements through it. Stripe already works this way, because Prices are immutable and existing subscriptions keep theirs.
- Move people explicitly: preview, announce, schedule at their period boundary with an idempotent reference, and let them opt out.
You can build both by hand with a versioned map and a careful webhook. The free MIT core of Aurora Meter (GitHub, docs) gives you versioned plans, boot-time protection against edited versions, previews and scheduled transitions without that work. When you want Stripe to stay in step automatically, Aurora Meter Pro is licensed per app from $99 a month, with a 14-day free trial (one per customer).