AuroraMeter. Subscriptions
(Aurora Meter v1.0.0)
View Source
Cached subscription lookups, and the scheduled plan change.
Resolving a tenant's plan on every check/2 or reserve/3 would otherwise
cost one database query per request. get/1 memoises the storage lookup in
ETS for :subscription_cache_ttl milliseconds (default 5s, 0 disables), and
every write through AuroraMeter.Storage.put_subscription/1 evicts the entry
locally and broadcasts the eviction over PubSub so other nodes drop it too.
The cache is transparent: it only ever holds what storage returned.
Moving a tenant between plans
A plan change is explicit and scheduled. Redeploying a plan definition never moves anybody (decision D05, invariant I17); a tenant moves because somebody scheduled it, at an instant they chose, with a reference they can cancel or retry.
{:ok, transition} =
AuroraMeter.Subscriptions.schedule_transition("org_1", :scale, ref: "upgrade-8412")With no :effective_at that is the end of the tenant's current period, from
the tenant's own period source: the first of next month under the calendar
default, current_period_end under AuroraMeter.Pro.Period, the end of the
week under a weekly host source. Until that instant the tenant is entitled
under the old plan, and nothing about the change is visible to check/2.
apply_due_transitions/1 is what moves them, and a host runs it from
whatever scheduler it already has (AuroraMeter.Oban.PlanTransitions when
that is Oban). It is safe to run from every node at once: every effect is a
conditional update, so one run applies the change and the others report a
skip.
See Plans for the lifecycle, the precedence table, the lag the default schedule implies and how to tighten it.
Core never prorates
preview_transition/3 reports each plan's declared list price, never an
invoice amount. Aurora Meter computes no proration of any kind: the billing
provider is authoritative for what a customer is charged, and the provider
section of a preview is the only place a price id or a proration mode
appears.
Summary
Functions
Applies every transition whose effective time has arrived.
Cancels the pending transition ref for tenant.
Records a billing provider's confirmation of ref, and applies it if due.
Returns the tenant's subscription (or nil), served from the cache when warm.
Drops the cached subscription for tenant on this node and announces the
change so every other node drops it as well.
What a transition would change, without writing anything.
Schedules one plan change for tenant, effective at a period boundary.
Types
@type applied() :: %{ applied: non_neg_integer(), skipped: non_neg_integer(), failed: non_neg_integer(), cursor: String.t() | :done }
What one apply_due_transitions/1 call did.
cursor is :done when the page came back shorter than :limit, and
otherwise the value to pass as :after for the next page.
@type error() :: {:invalid | :conflict | :not_found | :unavailable, term()}
An error from the transition API.
:invalid carries a keyword list of field and message; :conflict carries a
map naming what disagreed; :not_found names :subscription or
:transition; :unavailable names why the operation cannot run yet.
Functions
Applies every transition whose effective time has arrived.
Call it from any scheduler, or from iex. It opens one transaction per
tenant, not one for the batch: a batch-wide transaction would hold hundreds
of row locks for the length of the run and one poisoned row would discard the
others' work.
Options: :limit (default 500), :after (the cursor from a previous call),
:tenant (force one tenant) and :now (the instant to treat as now; defaults
to the database's clock, because the instants it is compared against are
stored in the database).
Running it twice, from two nodes, at any interleaving, is safe: each effect is
an update predicated on transition_state = 'pending' and the transition's
reference, so exactly one caller applies and the rest count a skip
(invariant I16).
A transition whose target version resolves through neither compiled code nor a
registered snapshot is counted failed and is never retried automatically: an
automatic retry of a target that does not exist loops forever. An operator
cancels it and schedules a new one.
Examples
AuroraMeter.Subscriptions.apply_due_transitions(limit: 100)
#=> {:ok, %{applied: 1, skipped: 0, failed: 0, cursor: :done}}
@spec cancel_transition(term(), String.t(), keyword()) :: {:ok, AuroraMeter.Schema.PlanTransition.t()} | {:error, error()}
Cancels the pending transition ref for tenant.
Idempotent: cancelling an already cancelled transition returns it unchanged.
An applied transition is {:error, {:conflict, %{state: "applied"}}}, and an
unknown reference is {:error, {:not_found, :transition}}.
Examples
AuroraMeter.Subscriptions.cancel_transition("org_1", "upgrade-8412")
#=> {:ok, %AuroraMeter.Schema.PlanTransition{state: "cancelled"}}
@spec confirm_transition(term(), String.t(), keyword()) :: {:ok, AuroraMeter.Schema.PlanTransition.t()} | {:error, error()}
Records a billing provider's confirmation of ref, and applies it if due.
For a billing provider integration. Aurora Meter Pro calls this after the retrieved provider subscription shows the new price; a host scheduling local plan changes never needs it.
Options: :provider_ref (required), :effective_at (the provider's own
boundary, which wins over the scheduled one because the provider is
authoritative for the paid boundary, with the replaced instant recorded in
detail.provider_effective_at_changed) and :detail.
Idempotent under redelivery: the same provider_ref on an already applied
transition returns it unchanged, and a different one is
{:error, {:conflict, %{provider_ref: stored}}}.
Examples
AuroraMeter.Subscriptions.confirm_transition("org_1", "upgrade-8412",
provider_ref: "sub_123")
@spec get(term()) :: AuroraMeter.Schema.Subscription.t() | nil
Returns the tenant's subscription (or nil), served from the cache when warm.
Examples
iex> AuroraMeter.Subscriptions.get("nobody_5578")
nil
@spec invalidate(term()) :: :ok
Drops the cached subscription for tenant on this node and announces the
change so every other node drops it as well.
Examples
iex> AuroraMeter.Subscriptions.invalidate("org_1")
:ok
@spec preview_transition(term(), atom() | String.t(), keyword()) :: {:ok, map()} | {:error, error()}
What a transition would change, without writing anything.
A pure read: no lock, no transaction, no row. Safe to call from a LiveView render, and the dry run task 07.09 asks for.
Returns from and to (each %{plan_id, version, price, features, recurring_credits}), a changes list, the effective_at the transition
would carry, the tenant's current period, and a provider section.
price is the plan's declared list price in cents and never an invoice
amount: core computes no proration (decision D05). The provider section
comes from the optional AuroraMeter.Billing.Provider.describe_plan_change/3
callback and is %{status: :not_configured, detail: %{}} on a core-only
installation, %{status: :error, detail: %{reason: ...}} for a provider that
errors or raises, and the provider's own map otherwise. The entitlement diff
is returned either way, because that half is core's and is right whatever the
provider says.
Each change carries a direction of :increase, :decrease, :added,
:removed or :changed.
Examples
{:ok, preview} = AuroraMeter.Subscriptions.preview_transition("org_1", :scale)
preview.provider.status
#=> :not_configured
@spec schedule_transition(term(), atom() | String.t(), keyword()) :: {:ok, AuroraMeter.Schema.PlanTransition.t()} | {:error, error()}
Schedules one plan change for tenant, effective at a period boundary.
Options:
:ref(required): the caller's idempotency reference, 1 to 128 bytes, unique per tenant. Submitting the same reference with the same parameters returns the existing transition; with different parameters it is a conflict naming both. This is the same identity rulerecord/4applies to events, deliberately.:version: the plan version to move to. Without it, the version in force at:effective_at, which is what makes "upgrade at the boundary" pick up a version that becomes effective at that boundary and refuse one that becomes effective later.:effective_at: a UTCDateTimein the future. Defaults to the end of the tenant's current period, from the tenant's own period source.:replace(defaulttrue): cancel an existing pending transition in the same transaction. Withfalse, a second reference is a conflict.:confirm(default:local)::providerkeeps the transition pending and visible until a billing provider confirms it withconfirm_transition/3, which is how a paid upgrade is ordered "provider first, local second" (task 07.07).:detail: a map merged into the audit row'sdetail.
Errors are {:error, {:invalid, [field: message]}},
{:error, {:conflict, map}}, {:error, {:not_found, :subscription}} (a
tenant with no subscription is on the default plan and has nothing to move
from) and {:error, {:unavailable, :registration_incomplete}} (the tenant's
row has no plan_version yet, so AuroraMeter.Plans.register!/0 has not run
since the upgrade to core schema version 10).
Examples
AuroraMeter.Subscriptions.schedule_transition("org_1", :scale, ref: "upgrade-8412")
#=> {:ok, %AuroraMeter.Schema.PlanTransition{state: "pending"}}