AuroraMeter.Entitlements (Aurora Meter v1.0.0)

View Source

Plan resolution and the entitlement gate.

Semantics (see plan.md D12 and ADR 0010): a :hard limit blocks at its cap; a :metered feature is always allowed (overage is billed); a :counter is always allowed and never billed (ADR 0006); a {:feature, false} is denied. with_quota/4 reserves atomically so hard limits are correct under concurrency, releasing the reservation if the wrapped function raises.

Features the plan does not declare

A feature the tenant's effective plan does not declare follows :undeclared_feature_policy (AuroraMeter.Config.policy_for/1). A feature another plan declares counts as undeclared here: that is the case the policy exists for.

Entry point:allow:warn:deny:raise
check/2:ok:ok plus one log{:error, :not_entitled}raises
allowed?/2, entitled?/2truetrue plus one logfalseraises
feature_value/3defaultdefault plus one logdefaultraises
quota/2kind: :undeclared, enabled: truesame plus one logkind: :undeclared, enabled: falseraises
remaining/2:unlimited:unlimited plus one log0raises
reserve/2,3counts, :okcounts, :ok, one log{:error, :not_entitled}, counter untouchedraises, counter untouched
with_quota/3,4runs the functionruns it, one log{:error, :not_entitled}, not runraises, not run

Every entry point keeps its documented return shape under every policy; only the value inside it changes. :warn behaves exactly as :allow and logs once per feature per node. AuroraMeter.track/4 is deliberately outside all of this and keeps counting: metering is not entitlement.

The default is :warn in the 0.5.x transition release and :deny from 1.0; config :aurora_meter, undeclared_feature_policy: :allow restores the 0.4.x behaviour exactly.

Plans resolve through AuroraMeter.Subscriptions (cached), and only a subscription in an entitled status (AuroraMeter.Schema.Subscription.entitled_statuses/0) grants its plan; anything else gets the default plan.

Summary

Types

Result of an entitlement check.

A dashboard-ready view of one feature's quota. kind is :hard, :metered, :counter, :boolean, :feature (an integer plan value, carried in value) or :undeclared; limit is set for hard caps, included for metered allowances; percent is used relative to whichever applies (nil when neither does, which includes every :counter; see ADR 0006).

Functions

Whether check/2 currently returns :ok.

Checks whether tenant may use feature right now.

Whether the tenant's plan grants access to feature at all (ignores quota).

The value of a feature :name, value declaration on tenant's plan, or default when the plan does not declare it (or declares it as a limit or a metered feature). Booleans and non-negative integers are both values

Returns the plan for tenant: the version its subscription is pinned to when the subscription is in an entitled status, else the default plan.

A dashboard-ready snapshot of feature for tenant: kind, usage, cap or allowance, remaining, overage, percentage and the current period.

Remaining quota for a hard-limited feature, or :unlimited, which is what a metered feature, a counter and a plain feature all report, because none of them has a cap to count down from.

Atomically reserves qty of feature against the plan (increments the counter).

Assigns plan_id to tenant locally (no billing provider).

Assigns a specific version of plan_id to tenant.

Gates, runs, and meters in one atomic step.

Types

check_result()

@type check_result() :: :ok | {:error, :limit_exceeded | :not_entitled}

Result of an entitlement check.

quota()

@type quota() :: %{
  feature: atom(),
  kind: :hard | :metered | :counter | :boolean | :feature | :undeclared,
  enabled: boolean(),
  value: non_neg_integer() | nil,
  used: integer(),
  limit: non_neg_integer() | nil,
  included: non_neg_integer() | nil,
  unit_price: number() | nil,
  remaining: non_neg_integer() | :unlimited,
  overage: non_neg_integer(),
  percent: non_neg_integer() | nil,
  period: AuroraMeter.Period.t()
}

A dashboard-ready view of one feature's quota. kind is :hard, :metered, :counter, :boolean, :feature (an integer plan value, carried in value) or :undeclared; limit is set for hard caps, included for metered allowances; percent is used relative to whichever applies (nil when neither does, which includes every :counter; see ADR 0006).

Functions

allowed?(tenant, feature)

@spec allowed?(term(), atom()) :: boolean()

Whether check/2 currently returns :ok.

check(tenant, feature)

@spec check(term(), atom()) :: check_result()

Checks whether tenant may use feature right now.

entitled?(tenant, feature)

@spec entitled?(term(), atom()) :: boolean()

Whether the tenant's plan grants access to feature at all (ignores quota).

feature_value(tenant, feature, default \\ nil)

@spec feature_value(term(), atom(), default) ::
  boolean() | non_neg_integer() | default
when default: term()

The value of a feature :name, value declaration on tenant's plan, or default when the plan does not declare it (or declares it as a limit or a metered feature). Booleans and non-negative integers are both values:

AuroraMeter.feature_value(org, :seats, 1)       # 5 on :pro, 1 on :free
AuroraMeter.feature_value(org, :api_access)     # true | false | nil

default is also what an undeclared feature yields under :allow, :warn and :deny; under :raise it raises AuroraMeter.UndeclaredFeatureError.

plan(tenant)

@spec plan(term()) :: AuroraMeter.Plan.t() | nil

Returns the plan for tenant: the version its subscription is pinned to when the subscription is in an entitled status, else the default plan.

The pin is what makes a plan deploy safe. A tenant on :pro version "1" keeps version 1's limits, feature values, price and recurring credits when version 2 becomes effective, and keeps them when version 1's block is deleted from the plans module, because the definition is resolved from the registered snapshot (v1-release.md 07.03, invariant I17).

A row with no plan_version at all (written before core schema version 10 and not yet named by AuroraMeter.Plans.register!/0) resolves to the plan's base version, never to whatever is current: the migration and the registration are not simultaneous, and a tenant must not be repriced in the window between them.

quota(tenant, feature)

@spec quota(term(), atom()) :: quota()

A dashboard-ready snapshot of feature for tenant: kind, usage, cap or allowance, remaining, overage, percentage and the current period.

remaining(tenant, feature)

@spec remaining(term(), atom()) :: non_neg_integer() | :unlimited

Remaining quota for a hard-limited feature, or :unlimited, which is what a metered feature, a counter and a plain feature all report, because none of them has a cap to count down from.

An undeclared feature reports :unlimited under :allow and :warn, and 0 under :deny: the documented return type is non_neg_integer() | :unlimited, and 0 is the honest number when nothing is entitled.

reserve(tenant, feature, qty \\ 1, period_start \\ nil)

@spec reserve(term(), atom(), pos_integer(), DateTime.t() | nil) ::
  :ok | {:error, :limit_exceeded | :not_entitled}

Atomically reserves qty of feature against the plan (increments the counter).

period_start names the period to count it against; without it the current one is used. A caller that will release the reservation later has to hold on to the period it reserved in; see with_quota/4.

Raises ArgumentError for a feature configured feature_sources: %{name => :events}. This is the bill-immediately primitive: it writes the quantity straight into the pending flush, so it would put reserved units into aurora_meter_counters for a feature whose commercial quantity is its recorded events. Use with_quota/4, whose reservation stays in memory, or check/2 to ask without reserving.

subscribe(tenant, plan_id)

@spec subscribe(term(), atom() | String.t()) ::
  {:ok, AuroraMeter.Schema.Subscription.t()} | {:error, Ecto.Changeset.t()}

Assigns plan_id to tenant locally (no billing provider).

The plan has to exist. A plan id no plans module declares used to be written and then silently resolved to the default plan for the life of the install; in the transition release it is written with a warning, and from 1.0 it is {:error, changeset} with plan_id: ["is not a known plan"].

subscribe(tenant, plan_id, opts)

@spec subscribe(term(), atom() | String.t(), keyword() | :strict | :transition) ::
  {:ok, AuroraMeter.Schema.Subscription.t()} | {:error, Ecto.Changeset.t()}

Assigns a specific version of plan_id to tenant.

Options:

  • :version: the plan version to pin. Without it the tenant gets the version effective at AuroraMeter.Clock.now/0, which is what "new subscriptions select the intended effective version" means. With it, the named version is pinned even when it is not yet effective: an explicit opt-in is not the same thing as a future-dated version becoming active early, because the caller asked for it.
  • :mode: undocumented, for the suite, so both halves of the transition can be exercised without depending on the package's own version.

A version the plan does not have, in code or in the registry, is {:error, changeset} with plan_version: ["is not a known version of this plan"].

The row records the version, its fingerprint and the instant the assignment started, so a later deploy of a new version cannot change what this tenant was sold (D05).

with_quota(tenant, feature, fun)

@spec with_quota(term(), atom(), (-> result)) :: {:ok, result} | {:error, term()}
when result: term()

Gates, runs, and meters in one atomic step.

Reserves qty of feature; if allowed, runs fun and returns {:ok, result} (the reservation is the usage). If the reservation is denied, returns {:error, reason} without running fun. If fun raises, the reservation is released and the error re-raised.

Over an events-source feature

For a feature configured feature_sources: %{name => :events} the gate still works and the reservation is still strict on this node, but the reservation is released on success instead of being committed. It is admission control and nothing else: the billable fact is whatever AuroraMeter.record/4 committed, and committing the reservation as well would charge the estimate on top of the recorded quantity.

The recipe, and the arithmetic it produces, is to record inside the callback:

AuroraMeter.with_quota(org, :tokens, estimate, fn ->
  {:ok, result} = do_work()
  {:ok, _event, _outcome} =
    AuroraMeter.record(org, :tokens, result.tokens,
      id: result.request_id, occurred_at: result.finished_at)
  result
end)

+estimate at admission, +result.tokens from the projection, -estimate at release: the in-memory value nets to the durable total, and while the callback runs every other caller sees the estimate held. Unlike a buffered feature, nothing here can reach a flush batch.

with_quota(tenant, feature, qty, fun)

@spec with_quota(term(), atom(), pos_integer(), (-> result)) ::
  {:ok, result} | {:error, term()}
when result: term()