Change a plan without repricing anybody

What happens to the customers already on a plan when you edit its definition, how a fingerprint catches you doing it by accident, and how to move one customer on purpose with a preview you can put in an email first.

Free core 16 min Intermediate

You shipped a plan called :pro at twenty dollars with a thousand generations. Six months later you want :pro to mean twelve hundred generations at twenty four dollars. Two questions follow immediately, and getting either one wrong is a support incident: what happens to the people already on :pro, and what happens when the new code deploys while the old definition is still what they agreed to.

This guide is the answer to both. It is all in the free core.

#A plan is an id and a version

defmodule Bramble.Plans do
  use AuroraMeter.Plans

  plan :pro do
    price 2_000
    limit :projects, 25, :hard
    metered :ai_summaries, included: 1_000, unit_price: 2
    counter :api_requests
    feature :sso, false
  end
end

That declares {:pro, "1"}. Every plan has a version label, and "1" is what you get when you do not say. Exactly one version of each plan id may omit effective_at: that is the base, the one a tenant lands on when nothing else applies.

A second version is a second block:

plan :pro, version: "2", effective_at: ~U[2026-11-01 00:00:00Z] do
  price 2_400
  limit :projects, 25, :hard
  metered :ai_summaries, included: 1_200, unit_price: 2
  counter :api_requests
  feature :sso, true
end

From November the first, a new subscriber to :pro gets version 2. Nobody already on version 1 moves. That is the whole point.

#The fingerprint, and the error it produces

When your application boots, AuroraMeter.Plans.register!/0 writes a snapshot of every compiled plan version to the database and takes a SHA256 of its commercial content: the id, the version label, the price, every feature sorted by name, every recurring allowance sorted by name. Numbers carry a type tag, so 2 and 2.0 are different fingerprints.

effective_at is deliberately not in the fingerprint. Moving the date a version starts applying to new subscribers does not change what the version means to somebody on it.

Now edit {:pro, "1"} in place, raise the price to 2,400 and deploy:

** (AuroraMeter.PlanVersionConflictError) the commercial content of plan
   versions already registered differs from the compiled definition:
     pro version 1

Registration refuses, naming every conflicting version with its stored and its compiled fingerprint. The remedy is never to make the error go away; it is to publish a new version and leave version 1 as the thing your existing customers agreed to.

The strictness is a setting, plan_version_conflict. It is :warn in the 0.5.x transition release, so the first time you meet this it is a log line and your deploy continues, and :raise from 1.0. Neither value reprices anybody: a tenant stays on the stored definition under both.

Registration never moves a tenant. It writes snapshots and it backfills a version label onto old subscription rows that predate versioning. It will not change a tenant’s plan_id, plan_version or fingerprint. Only an explicit transition does that.

#Reading the plan a tenant is actually on

AuroraMeter.plan(org).id               # :pro
AuroraMeter.Plans.get(:pro)            # the version effective now
AuroraMeter.Plans.get(:pro, "1")       # one named version
AuroraMeter.Plans.versions(:pro)       # every version you have declared
AuroraMeter.Plans.base(:pro)           # the one without an effective_at

AuroraMeter.plan/1 reads the tenant’s stored version, not the newest one. Two customers on :pro can legitimately see different limits, and your dashboard should show each of them theirs.

#Moving somebody, on purpose

Plan changes are scheduled rather than applied on the spot, because the honest moment for a price change is a period boundary.

AuroraMeter.Subscriptions.schedule_transition(org, :pro,
  ref: "upgrade:" <> request_id,
  version: "2"
)
# {:ok, %AuroraMeter.PlanTransition{}}

:ref is required and is an identity, exactly like an event id: one to 128 bytes, unique per tenant, and the same value on a retry. Without :effective_at the transition lands at the end of the tenant’s current period, read from that tenant’s own period source, which for a Pro installation is their Stripe billing cycle rather than the calendar month.

Four things it can tell you instead:

Return What happened
{:error, {:invalid, [field: message]}} a bad ref, a version that does not exist, an effective date in the past
{:error, {:conflict, detail}} a transition with that ref already exists and differs
{:error, {:not_found, :subscription}} the tenant has no subscription, so it is on the default plan and there is nothing to move from
{:error, {:unavailable, :registration_incomplete}} the row has no version yet, because register!/0 has not run since the schema upgrade

That last one is worth recognising on sight. It is not a bug in your call; it is the boot step not having happened.

#Showing somebody what will change

AuroraMeter.Subscriptions.preview_transition(org, :pro, version: "2")

A pure read. No lock, no row, nothing written. You get from and to (each with the plan id, version, price, features and recurring allowances), a changes list where every entry carries a direction of :increase, :decrease, :added, :removed or :changed, the effective_at it would take, the tenant’s current period, and a provider section.

price is the plan’s list price in cents. Core computes no proration of any kind. If your billing provider will prorate, the number your customer actually pays comes from the provider, and the provider section of the preview is where it appears. Do not render the core price as “you will be charged”.

Core only, that section reads %{status: :not_configured, detail: %{}}.

#Cancelling one

AuroraMeter.Subscriptions.cancel_transition(org, "upgrade:" <> request_id)

Idempotent: cancelling an already cancelled transition returns it unchanged. An applied transition answers {:error, {:conflict, %{state: "applied"}}}, because the customer is already on the other plan and the fix for that is another transition, not an undo.

#Applying the ones that have come due

AuroraMeter.Subscriptions.apply_due_transitions(limit: 500)
# {:ok, %{applied: 3, skipped: 0, failed: 0, cursor: :done}}

Safe to run from every node at once. cursor is either :done or the value to pass back as :after for the next page. A transition whose target version resolves through neither your compiled code nor a registered snapshot is counted in failed and is not retried automatically, because retrying it would not help until somebody restores the definition.

If you run Oban, AuroraMeter.Oban.PlanTransitions does this every five minutes and you never call it yourself. Running it: operations and recovery covers the workers.

#When a billing provider owns the boundary

Pass confirm: :provider and the transition stays pending until the provider says the change took effect:

AuroraMeter.Subscriptions.schedule_transition(org, :pro, ref: ref, confirm: :provider)
# ... later, from your webhook handler
AuroraMeter.Subscriptions.confirm_transition(org, ref, provider_ref: "sub_sched_123")

The provider’s boundary wins over the one you scheduled, and the instant it replaced is kept in the transition’s detail so the change is auditable. Redelivery is safe: the same provider_ref on an already applied transition returns it unchanged. A different one is a conflict, because two providers cannot both have been the reason.

A host doing local plan changes never needs this call.

#The rule to take away

Deploying a changed plan definition does not reprice anybody. It either registers a new version, or it refuses. Repricing an existing tenant is always an explicit transition with a ref you chose, an effective date you can show them, and a preview you can put in an email first.

Keep going

The core is free. Pro is the Stripe half.

Everything the free MIT package does is yours with no account and no expiry. Aurora Meter Pro adds Stripe checkout, metered usage reporting, prepaid top-ups, dashboards and alerts, and every plan starts with a free trial.