AuroraMeter. Plans
(Aurora Meter v1.0.0)
View Source
Compile-time DSL for declaring billing plans, plus runtime lookups.
Define a plans module:
defmodule MyApp.Plans do
use AuroraMeter.Plans
plan :free do
price 0
limit :ai_generations, 50, :hard
feature :api_access, false
end
plan :pro do
price 2_000
limit :ai_generations, 1_000, :hard
feature :api_access, true
feature :seats, 5
end
plan :scale do
price 2_000
metered :ai_generations, included: 1_000, unit_price: 2
feature :api_access, true
end
plan :payg do
price 0
counter :requests
feature :api_access, true
recurring_credits :monthly_allowance, amount: 5_000_000, rollover: 1_000_000
end
endThen point config :aurora_meter, plans: MyApp.Plans. Definitions are validated
at compile time (duplicate features, invalid modes, negative numbers all raise).
Summary
Functions
Returns the effective version of every plan as %{id => Plan.t()}.
The version of id a subscription written before plan versions existed is on.
Declares a counter feature: counter :requests.
Whether any plan declares feature.
The {plan_id, version} a tenant was on at instant, or {:error, :unresolved}.
Declares plain feature access or a plan-level value.
Returns the feature config for feature in plan_id, or nil.
Returns the value of a feature declaration in plan_id, or default.
Returns the plan id's effective version right now, or nil for an unknown id.
Returns one specific version of a plan, or nil.
Declares a hard-capped feature: limit :feature, n, :hard.
Declares a metered feature: metered :feature, included: n, unit_price: cents.
Declares a plan version. Contains price, limit, metered, counter,
feature and recurring_credits calls.
Declares a plan version with options. See plan/2.
Every plan id the configured plans module declares.
Sets the plan's monthly price in minor units (cents).
Declares a recurring credit allowance: recurring_credits :monthly, amount: 5_000_000.
Registers every compiled plan version and names the contract of every subscription written before plan versions existed.
Every known version of id, compiled and stored, oldest first.
Functions
@spec all() :: %{optional(atom()) => AuroraMeter.Plan.t()}
Returns the effective version of every plan as %{id => Plan.t()}.
The shape is the one this function has always had. What changed in Aurora
Meter 1.0 is that its content is time dependent: a plan id with a future-dated
version answers with the version in force at AuroraMeter.Clock.now/0, and
the future one appears here once its effective_at has passed. Use
versions/1 to see every version of one plan and get/2 to pin one.
@spec base(atom()) :: AuroraMeter.Plan.t() | nil
The version of id a subscription written before plan versions existed is on.
It is the version whose block declares no effective_at, which is by
definition the one that was in force before any other version of that id was
written. A plan_version of NULL on a subscription row means "the base
contract", never "whatever is current", which is what stops the window between
the version 10 migration and the first register!/0 from repricing anybody.
Declares a counter feature: counter :requests.
A counter is measured but never billed and never blocked, the thing to
reach for when the money lives somewhere else (a prepaid credit ledger, a
usage-based invoice built outside Aurora Meter) and the plan only wants a
number on the dashboard. check/2 is always :ok, remaining/2 is
:unlimited, and quota/2 reports kind: :counter with limit, included
and percent all nil: a counter has no denominator, so there is no bar to
draw. See ADR 0006.
Whether any plan declares feature.
Internal: it answers "is this a name the plans module knows at all", which is
what separates reason: :not_in_plan from reason: :unknown_feature in
AuroraMeter.UndeclaredFeatureError and what AuroraMeter.track/4 reports as
declared:. Whether a tenant is entitled to it is AuroraMeter.entitled?/2.
A plans module compiled against an older Aurora Meter has no
__aurora_features__/0; this folds over all/0 for it instead.
@spec effective_for(term(), DateTime.t()) :: {:ok, {atom(), String.t()}} | {:error, :unresolved}
The {plan_id, version} a tenant was on at instant, or {:error, :unresolved}.
This is the attribution question, not the entitlement one. get/1 answers
"what may this tenant do now"; this answers "which commercial contract was
this usage sold under", and the two differ for every backdated fact. A
recorded event keeps the answer it was given here (D05, L17.12): no later
process recomputes it, so a plan redeploy or a plan change cannot reprice
history.
Resolution, in order:
- No subscription row:
{:error, :unresolved}. There is no assignment to attribute to, and the default plan is a fallback for entitlement, not a commercial fact. - A row whose
plan_versionis still NULL (the window between core schema version 10 and the firstregister!/0):{:error, :unresolved}, for the same reasonschedule_transition/3refuses one. instantat or after the row'splan_effective_at: the row's own pair. This is the overwhelming majority of calls and costs zero queries.- Otherwise the applied history in
aurora_meter_plan_transitions: the latest applied transition whoseeffective_atis at or beforeinstantgives itstopair, and failing that the earliest applied transition afterinstantgives itsfrompair, because before the first recorded change the tenant was on what that change moved them off. - Nothing covers the instant:
{:error, :unresolved}. An instant before any recorded plan history is a state, never a guess at today's plan (L17.13).
Examples
AuroraMeter.Plans.effective_for("org_1", ~U[2026-02-14 09:00:00Z])
#=> {:ok, {:pro, "1"}}
AuroraMeter.Plans.effective_for("never_subscribed", ~U[2026-02-14 09:00:00Z])
#=> {:error, :unresolved}Not a doctest: it reads the tenant's subscription and, for a backdated
instant, the applied transition history, so it needs a database. It is
covered by test/aurora_meter/plans_test.exs.
Declares plain feature access or a plan-level value.
feature :api_access, true grants (or, with false, denies) access with no
quota behind it. feature :seats, 5 declares a non-negative integer the plan
carries for the host to read with AuroraMeter.feature_value/3 (seats,
retention days, projects); integer features are always entitled and never
metered.
@spec feature_config(atom(), atom()) :: AuroraMeter.Plan.feature_config() | nil
Returns the feature config for feature in plan_id, or nil.
@spec feature_value(atom(), atom(), default) :: boolean() | non_neg_integer() | default when default: term()
Returns the value of a feature declaration in plan_id, or default.
Only feature :name, value declarations (booleans and integers) have a value;
a limit, a metered feature, an undeclared feature or an unknown plan all
return default. To resolve the plan from a tenant use
AuroraMeter.feature_value/3.
Examples
iex> AuroraMeter.Plans.feature_value(:pro, :seats)
5
iex> AuroraMeter.Plans.feature_value(:free, :seats, 1)
1
iex> AuroraMeter.Plans.feature_value(:pro, :api_access)
true
@spec get(atom()) :: AuroraMeter.Plan.t() | nil
Returns the plan id's effective version right now, or nil for an unknown id.
"Effective" is the version with the greatest effective_at that is not in the
future; a version with no effective_at sorts before every instant, and every
plan id is required at compile time to have exactly one of those, so a known
id always resolves.
@spec get(atom(), String.t()) :: AuroraMeter.Plan.t() | nil
Returns one specific version of a plan, or nil.
Compiled definitions first; then the snapshot registered in
aurora_meter_plan_versions, which is what keeps a subscription readable
after its version's block has been deleted from the plans module
(v1-release.md 07.03). A version in neither is nil, and
AuroraMeter.Entitlements.plan/1 then falls back to the default plan and says
so once.
Declares a hard-capped feature: limit :feature, n, :hard.
Declares a metered feature: metered :feature, included: n, unit_price: cents.
Declares a plan version. Contains price, limit, metered, counter,
feature and recurring_credits calls.
plan :pro do # version "1", effective now
price 2_000
limit :ai_generations, 1_000, :hard
end
plan :pro, version: "2", effective_at: ~U[2026-10-01 00:00:00Z] do
price 3_000
limit :ai_generations, 2_000, :hard
endOptions:
:version: a short label,"1"by default. It is the tenant's contract and it is immutable: changing what a version declares isAuroraMeter.PlanVersionConflictError, not a price change.:effective_at: the UTC instant from whichAuroraMeter.Plans.get/1returns this version. Omitted means "from the beginning", and exactly one version of each plan id must omit it.
plan/2 is the form without options and plan/3 the form with them: Elixir
parses plan :pro, version: "2" do ... end as three arguments, the id, the
keyword list and the do block, not as two. Existing plans modules are
untouched, because a block with no options is still plan/2 (finding X289).
Declares a plan version with options. See plan/2.
@spec plan_ids() :: [atom()]
Every plan id the configured plans module declares.
Cheaper than Map.keys(all/0), which resolves an effective version per id
only to throw it away.
Sets the plan's monthly price in minor units (cents).
Declares a recurring credit allowance: recurring_credits :monthly, amount: 5_000_000.
The allowance is granted once per tenant, entitlement, plan version and
billing period by AuroraMeter.Credits.Recurrences.run/1. A plan that does
not declare one grants nothing, which is what "recurring grants default to
disabled" means.
Options:
:amount: required, a positive integer of micro-dollars. A float is an error rather than a warning: this is new API and money is an integer.:category::promotional(default),:paidor:adjustment.:rollover: a non-negative integer cap in micro-dollars,0(default) for no rollover. At most this much of one period's unused allowance is carried into the next period, as a new lot with its own reference. It does not accumulate: two idle periods carry the cap, not twice the cap.:expires::period_end(default),:neveror{:seconds, n}.
Three combinations are refused at compile time, each because the ledger or the arithmetic cannot honour it:
rollover > 0with anything butexpires: :period_end. A rollover is defined as what the previous period's lot did not spend before it expired, so a lot that outlives the period boundary would be carried and still be spendable, and the tenant would hold the same micro-dollar twice.- an expiry on a non-promotional allowance. Only promotional grants expire
(
AuroraMeter.Schema.CreditTransactionrefuses an:expires_aton any other category), which is alsov1-release.md10.1's rule that paid top-ups do not expire unless their own contract says so. - a name or a plan id that is not lower snake case. Both become part of the
recurrence key, which is read by splitting on
:.
expires: :never on a promotional allowance is allowed and warned: a
never-expiring monthly promotion accumulates for ever.
@spec register!() :: :ok
Registers every compiled plan version and names the contract of every subscription written before plan versions existed.
Called from AuroraMeter.start_link/1 after the supervisor starts, and public
so a host can run it from a release task before the rolling deploy when the
subscriptions table is large. It is idempotent: a second run inserts nothing,
compares the same fingerprints and assigns nothing.
What it does, in order:
- inserts a snapshot for every compiled
{plan_id, version}that has none, then re-reads, so a row another node inserted in between is observed rather than assumed; - compares every stored fingerprint against the compiled one and raises
AuroraMeter.PlanVersionConflictErrorfor any that differ (or logs the same message whenplan_version_conflict: :warn); - assigns
plan_versionto rows that have none, in batches of 5000, each batch its own transaction and each row taken withFOR UPDATE SKIP LOCKEDso two booting nodes take disjoint batches instead of queueing behind each other. There is no checkpoint: the predicateplan_version IS NULLis its own checkpoint, so akill -9between batches leaves a consistent prefix and the next run finishes the rest.
It never changes a non-NULL plan_id, plan_version or
plan_fingerprint (L17.4). Registration cannot move a tenant between
contracts; only an explicit transition can, and that is a later release.
Two things it refuses to fail on, because neither is a reason to stop a host from booting:
- a storage adapter with no snapshot support, which logs one warning and leaves compiled code as the only authority (D12: reported, not silently ignored);
- a repo that is not running yet, which logs one warning naming the
boot-order requirement, flags the registry
:deferredand retries once on the first snapshot miss.
Do not call it inside a transaction of your own: the batch loop opens its own.
@spec versions(atom()) :: [AuroraMeter.Plan.t()]
Every known version of id, compiled and stored, oldest first.
Ordered by effective_at with nil first, then by version string. A version
that exists only as a stored snapshot (its block has been deleted from the
plans module) is included, which is what makes this the list an admin screen
can render without lying about history.