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_versionor 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.
priceis 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 theprovidersection 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.
#What to read next
- Write your plans down, then enforce them is the plan DSL in full.
- Running it: operations and recovery runs the worker that applies the transitions.
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.