We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
From the blog
Plan-Based Feature Gating and Entitlements in Phoenix
By Liam Killingback ·
Plan-Based Feature Gating and Entitlements in Phoenix
Phoenix feature gating tends to grow by accretion. A plan column on the organisation, an if org.plan == "pro" in a controller, a second one in a LiveView, a third in a background job, and a hard-coded 50 in a plug that everyone is afraid to change. Six months later the pricing page says one thing, the code enforces another, and a cancelled subscription still has API access because nobody wrote the downgrade.
Entitlements deserve one definition, one check, and one place where subscription status turns into access. This post lays out that design for Phoenix and Elixir, using the free MIT core of Aurora Meter for the examples. Nothing here requires the paid tier.
Three kinds of entitlement
Every gate in a SaaS is one of three shapes, and a good system treats them uniformly:
| Shape | Example | Check result when the customer is over |
|---|---|---|
| Hard cap | 1,000 AI generations a month on Pro |
refused with {:error, :limit_exceeded} |
| Metered | 1,000 included on Scale, then $0.02 each |
always :ok; the overage is billed |
| Feature flag | API access on Pro, off on Free |
refused with {:error, :not_entitled} |
The two error cases matter because the UI should respond differently. A customer at their cap wants “you have used all 1,000 this month, upgrade or wait until the 1st”. A customer without the feature wants “API access is on the Pro plan”. Collapse them into one boolean and the upgrade prompt gets vaguer and converts worse.
One definition, validated at compile time
Put the plans in a module and let the compiler catch a typo in a feature name before a customer does:
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
end
plan :scale do
price 2_000
metered :ai_generations, included: 1_000, unit_price: 2
feature :api_access, true
end
end
config :aurora_meter, plans: MyApp.Plans, default_plan: :free
This module is the source of truth for three consumers: the gate in your code, the pricing page (render the limits from the plan, never retype them), and the Stripe products (map each plan to a price id). When the Scale plan’s included allowance changes, it changes in one line.
The check
With the plans declared, one function answers every “can this customer do this” question:
AuroraMeter.check(org, :ai_generations)
# :ok
# {:error, :limit_exceeded} hard cap reached this period
# {:error, :not_entitled} the plan does not include the feature
The semantics follow directly from the plan definition. A limit returns :ok until usage reaches the number. A metered feature always returns :ok, because overage is a billing concern, not a gating one. A feature returns :ok or :not_entitled and ignores usage entirely. An undeclared feature is permissive and logs a warning in development, so forgetting to declare something fails loudly where you will see it rather than blocking customers in production.
Two helpers cover the common shapes: allowed?/2 for a plain boolean, and entitled?/2 to ask whether the plan grants the feature at all, ignoring quota. Use the second to decide whether to show a feature in the navigation and the first to decide whether to run it.
Gating a billable action atomically
For anything with a counter, checking and then incrementing is a race. Two requests both read 999 of 1,000, both pass, and the cap admitted 1,001. The cure is to reserve first and roll back on breach, which is what with_quota/4 does:
def create_generation(org, prompt) do
case AuroraMeter.with_quota(org, :ai_generations, fn -> Generator.run(prompt) end) do
{:ok, result} -> {:ok, result}
{:error, :limit_exceeded} = err -> err
{:error, :not_entitled} = err -> err
end
end
The increment is the reservation. If the function raises, the reservation is released before the exception re-raises, so a failed run does not consume the customer’s quota. Under concurrency a limit of n admits exactly n calls. The real-time metering post explains how the underlying ETS counter makes that cheap enough to sit on every request.
Gating in a plug, a controller and a LiveView
The check is the same everywhere; only the response differs.
A plug for an API:
defmodule MyAppWeb.Plugs.RequireEntitlement do
import Plug.Conn
def init(feature), do: feature
def call(conn, feature) do
case AuroraMeter.check(conn.assigns.current_org, feature) do
:ok ->
conn
{:error, :limit_exceeded} ->
conn |> send_resp(429, "plan limit reached") |> halt()
{:error, :not_entitled} ->
conn |> send_resp(403, "not included in your plan") |> halt()
end
end
end
plug MyAppWeb.Plugs.RequireEntitlement, :api_access
A LiveView that hides what the plan does not include and disables what it has used up:
def mount(_params, _session, socket) do
org = socket.assigns.current_org
if connected?(socket), do: AuroraMeter.LiveView.subscribe(org)
{:ok,
socket
|> assign(:api_enabled?, AuroraMeter.entitled?(org, :api_access))
|> assign(:quota, AuroraMeter.quota(org, :ai_generations))}
end
<.usage_meter tenant={@current_org} feature={:ai_generations} />
<button :if={@quota.remaining != 0} phx-click="generate">Generate</button>
<.link :if={@quota.remaining == 0} navigate={~p"/billing"}>Upgrade for more</.link>
quota/2 returns everything the template needs in one map: kind, used, limit, remaining, percent, the current period, and for metered features included, overage and unit_price instead of a limit.
Subscription status is part of the entitlement
The gap that bites most teams is the downgrade. A customer cancels, Stripe says so, and their organisation row still says pro. Do not model the plan as a column you update; model it as a subscription with a status, and grant the plan only while the status is one that should grant it.
Aurora Meter’s subscription record does exactly this. A subscription grants its plan while its status is active, trialing or past_due. Any other status (canceled, unpaid, incomplete) falls back to the configured default_plan, so a cancellation synced from your billing provider revokes access with no separate downgrade step and nothing for a support engineer to remember. past_due is deliberately in the entitled set: a card that failed yesterday should not lock a paying customer out before Stripe’s retries have run.
In the free core you assign plans locally:
AuroraMeter.subscribe(org, :pro)
That is enough for a product with manual invoicing or a trial you flip on by hand. When Stripe is the source of truth, Aurora Meter Pro’s webhook plug keeps the subscription row current on every customer.subscription.* event, and the plan lookup is cached in ETS on every node and evicted on every subscription write, so the entitlement check stays free of database reads per request even as statuses change.
Testing entitlements
Because the plans are a module and the checks are pure functions over a counter, tests are short. Subscribe a test org to a plan, drive usage to the edge, and assert the exact transition:
test "the free plan admits fifty generations and refuses the fifty-first" do
org = "org_#{System.unique_integer([:positive])}"
AuroraMeter.subscribe(org, :free)
for _ <- 1..50, do: assert {:ok, _} = AuroraMeter.with_quota(org, :ai_generations, fn -> :ok end)
assert {:error, :limit_exceeded} = AuroraMeter.with_quota(org, :ai_generations, fn -> :ok end)
end
test "api access is a plan feature, not a counter" do
org = "org_#{System.unique_integer([:positive])}"
AuroraMeter.subscribe(org, :free)
assert {:error, :not_entitled} = AuroraMeter.check(org, :api_access)
AuroraMeter.subscribe(org, :pro)
assert :ok = AuroraMeter.check(org, :api_access)
end
Run the concurrency case too, with Task.async_stream over a hundred callers against a limit of ten, and assert that exactly ten succeeded. That test is the one that fails for the check-then-increment version.
Where to go from here
The free core covers the whole entitlement story: the plan DSL, check/2, with_quota/4, quota/2, status-aware subscriptions and the live components. It is MIT, on Hex, with docs on aurorameter.com. When the plans need to bill, Aurora Meter Pro adds Stripe Checkout, the webhook sync that drives the status above, metered usage reporting and hosted dashboards; every plan comes with a 14-day trial, one per customer. And the usage-based billing guide walks through the full path from counter to invoice. For a working example of all of it in one codebase, the Aurora API Starter ships plan-gated API keys, quotas and a usage dashboard built on this core.