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
end

Then 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

all()

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

base(id)

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

counter(feature)

(macro)

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.

declared_anywhere?(feature)

@spec declared_anywhere?(atom()) :: boolean()

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.

effective_for(tenant, instant)

@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:

  1. 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.
  2. A row whose plan_version is still NULL (the window between core schema version 10 and the first register!/0): {:error, :unresolved}, for the same reason schedule_transition/3 refuses one.
  3. instant at or after the row's plan_effective_at: the row's own pair. This is the overwhelming majority of calls and costs zero queries.
  4. Otherwise the applied history in aurora_meter_plan_transitions: the latest applied transition whose effective_at is at or before instant gives its to pair, and failing that the earliest applied transition after instant gives its from pair, because before the first recorded change the tenant was on what that change moved them off.
  5. 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.

feature(name, value)

(macro)

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.

feature_config(plan_id, feature)

@spec feature_config(atom(), atom()) :: AuroraMeter.Plan.feature_config() | nil

Returns the feature config for feature in plan_id, or nil.

feature_value(plan_id, feature, default \\ 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

get(id)

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

get(id, version)

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

limit(feature, count, mode)

(macro)

Declares a hard-capped feature: limit :feature, n, :hard.

metered(feature, opts)

(macro)

Declares a metered feature: metered :feature, included: n, unit_price: cents.

plan(id, opts)

(macro)

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
end

Options:

  • :version: a short label, "1" by default. It is the tenant's contract and it is immutable: changing what a version declares is AuroraMeter.PlanVersionConflictError, not a price change.
  • :effective_at: the UTC instant from which AuroraMeter.Plans.get/1 returns 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).

plan(id, opts, block)

(macro)

Declares a plan version with options. See plan/2.

plan_ids()

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

price(amount)

(macro)

Sets the plan's monthly price in minor units (cents).

recurring_credits(name, opts)

(macro)

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), :paid or :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), :never or {:seconds, n}.

Three combinations are refused at compile time, each because the ledger or the arithmetic cannot honour it:

  • rollover > 0 with anything but expires: :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.CreditTransaction refuses an :expires_at on any other category), which is also v1-release.md 10.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.

register!()

@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:

  1. 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;
  2. compares every stored fingerprint against the compiled one and raises AuroraMeter.PlanVersionConflictError for any that differ (or logs the same message when plan_version_conflict: :warn);
  3. assigns plan_version to rows that have none, in batches of 5000, each batch its own transaction and each row taken with FOR UPDATE SKIP LOCKED so two booting nodes take disjoint batches instead of queueing behind each other. There is no checkpoint: the predicate plan_version IS NULL is its own checkpoint, so a kill -9 between 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 :deferred and retries once on the first snapshot miss.

Do not call it inside a transaction of your own: the batch loop opens its own.

versions(id)

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