We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
From the blog
Team Usage Limits in Phoenix: Pooled Quotas with Per-Member Caps
By Liam Killingback ·
Team Usage Limits in Phoenix: Pooled Quotas with Per-Member Caps
Sooner or later every B2B SaaS with usage limits gets the same support ticket. A company bought your Team plan with 5,000 AI generations a month. On the 9th, the whole team is locked out, because one person wrote a script that looped over a spreadsheet. The admin’s question is always some version of: “Why can one person spend everyone’s allowance, and how do I stop it happening again?”
That ticket is asking for team usage limits: one shared pool for the organisation, plus an optional cap per member that the customer’s admin controls. This guide builds both in Phoenix with Aurora Meter, the free MIT metering library: the pool is enforced atomically on an ETS hot path, member usage is counted alongside it, and an admin screen shows who used what in real time. It is part of our complete guide to usage-based billing in Elixir, and it assumes you have plans and quotas working already.
Three ways to give a team an allowance
Before writing code, decide which model you are selling. There are three common ones, and they behave very differently when someone is a heavy user.
| Model | How it works | Good at | Bad at |
|---|---|---|---|
| Per-user allowance | Each seat gets its own quota (500 each) | Predictable, fair by construction | Light users waste allowance, heavy users hit walls while the team has spare |
| Pooled quota | One quota for the whole org (5,000 shared) | Flexible, matches how teams actually work | One member can drain everyone |
| Pool plus member caps | One shared quota, and admins may cap individuals | Flexible and safe | More to build and explain |
Per-user allowances look simple, but they create the worst customer experience of the three: a member is blocked while the account has thousands of unused units. Pooled quotas fix that and are what most products ship first. The third model keeps the pool’s flexibility and gives admins the one control they keep asking for. It is the one we build here, and it degrades gracefully: with no caps set, it is just a pool.
Rule one: meter the organisation, not the user
The customer who pays is the organisation, so the organisation is the tenant. Every Aurora Meter call takes a tenant first, and the tenant’s key is what the counters, the plan subscription and the PubSub topics hang off. If you meter users, there is no pool to enforce.
Teach Aurora Meter to resolve both the org and a member key from the structs you already have:
defmodule MyApp.MeterTenant do
@behaviour AuroraMeter.Tenant
alias MyApp.Accounts.{Org, User}
@impl true
def to_key(%Org{id: id}), do: "org_#{id}"
def to_key({%Org{id: org_id}, %User{id: user_id}}), do: "org_#{org_id}:member_#{user_id}"
def to_key(key) when is_binary(key), do: key
end
# config/config.exs
config :aurora_meter,
repo: MyApp.Repo,
pubsub: MyApp.PubSub,
plans: MyApp.Plans,
tenant: MyApp.MeterTenant
The member key includes the org id on purpose. A user who belongs to two organisations is two different members, with two different counters, which is what both admins expect.
The pool: a plan limit on the org
The pool is nothing special. It is the plan’s limit, attached to the org:
defmodule MyApp.Plans do
use AuroraMeter.Plans
plan :team do
price 9_900
limit :ai_generations, 5_000, :hard
feature :seats, 10
end
plan :business do
price 29_900
metered :ai_generations, included: 25_000, unit_price: 1
feature :seats, 50
end
# member keys: measured for attribution, never blocked, never billed
plan :member do
price 0
counter :ai_generations
end
end
When the org subscribes to :team with AuroraMeter.subscribe(org, :team), every call to AuroraMeter.with_quota(org, :ai_generations, fun) draws from the same 5,000, no matter which member made it. That is the whole pool. with_quota/3 reserves before it runs your function (increment, compare, roll back on breach), so on a single node two members racing for the last unit cannot both get it. Across a cluster each node compares against its own view, so the cap can be overshot by what other nodes admitted in the last broadcast interval (one second by default); the core docs state that guarantee exactly.
The :member plan is the interesting part. A counter feature is measured but has no limit and is never billed. Member keys subscribe to it so we can count each person’s usage with the same fast ETS path, without any chance of those counts appearing on an invoice. That matters once you report overage to Stripe: you want the org’s counter on the invoice and nothing else.
Counting each member
When a user joins an org (or the first time they use a metered feature), subscribe their member key to the counter plan:
def ensure_member_meter(%Org{} = org, %User{} = user) do
case AuroraMeter.plan({org, user}) do
nil -> AuroraMeter.subscribe({org, user}, :member)
_plan -> {:ok, :already_subscribed}
end
end
From then on, AuroraMeter.track({org, user}, :ai_generations) is an atomic ETS increment, and AuroraMeter.usage({org, user}, :ai_generations) reads it back without touching Postgres on the hot path.
One detail about periods. Usage resets per billing period, and the period comes from Aurora Meter’s period source. The default is the calendar month, which is the same for the org and every member key, so the pool and the member counters reset together. If you anchor periods to each customer’s Stripe billing date with a custom AuroraMeter.Period implementation, give it a clause that resolves a member key to its org’s period, so a member’s counter never resets on a different day from the pool it draws on.
Caps are customer data, so store them yourself
Plan limits are compile-time and the same for every customer on a plan. Member caps are not: one admin caps the intern at 200, another caps nobody, a third gives the data team 3,000. That is customer-owned configuration, so it belongs in your own table:
defmodule MyApp.Repo.Migrations.CreateMemberAllowances do
use Ecto.Migration
def change do
create table(:member_allowances) do
add :org_id, references(:orgs, on_delete: :delete_all), null: false
add :user_id, references(:users, on_delete: :delete_all), null: false
add :feature, :string, null: false
# nil means "no cap: this member can use the whole pool"
add :monthly_cap, :integer
timestamps()
end
create unique_index(:member_allowances, [:org_id, :user_id, :feature])
create constraint(:member_allowances, :cap_not_negative, check: "monthly_cap IS NULL OR monthly_cap >= 0")
end
end
defmodule MyApp.Teams.MemberAllowance do
use Ecto.Schema
import Ecto.Changeset
schema "member_allowances" do
belongs_to :org, MyApp.Accounts.Org
belongs_to :user, MyApp.Accounts.User
field :feature, :string
field :monthly_cap, :integer
timestamps()
end
def changeset(allowance, attrs) do
allowance
|> cast(attrs, [:monthly_cap])
|> validate_number(:monthly_cap, greater_than_or_equal_to: 0)
end
end
Reading the cap on every request would put a database query on the hot path, which is the thing ETS metering exists to avoid. Caps change rarely, so cache them: a :persistent_term or ETS entry per org, invalidated when an admin saves the form, is plenty. The examples below call MyApp.Teams.cap_for/3; behind it is whatever cache you prefer.
Enforcing both limits in one call
Now the function every metered feature calls. It checks the member’s cap, draws from the pool, and records the member’s share when the work succeeds:
defmodule MyApp.Teams.Usage do
alias MyApp.Teams
@spec run(Org.t(), User.t(), atom(), (-> result)) ::
{:ok, result} | {:error, :member_cap_reached | :limit_exceeded | :not_entitled}
when result: term()
def run(org, user, feature, fun) do
member = {org, user}
with :ok <- check_member_cap(org, user, member, feature),
{:ok, result} <- AuroraMeter.with_quota(org, feature, fun) do
:ok = AuroraMeter.track(member, feature)
{:ok, result}
end
end
defp check_member_cap(org, user, member, feature) do
case Teams.cap_for(org, user, feature) do
nil -> :ok
cap -> if AuroraMeter.usage(member, feature) < cap, do: :ok, else: {:error, :member_cap_reached}
end
end
end
The order is deliberate, so it is worth spelling out.
- Member cap first, as a read. If the member is over their cap, nothing is reserved from the pool, so a capped member can never consume shared allowance on a request that was refused.
-
The pool second, as an atomic reservation.
with_quota/3is the part that must be exact, because the pool is what the customer paid for. If the work raises or exits, the reservation is rolled back. - The member counter last, only on success. A failed generation does not count against the member any more than it counts against the pool.
Be honest with yourself about what this guarantees. The pool is exact on a node. The member cap is a check-then-act on a counter, so if one member fires ten requests at exactly the same instant while sitting one unit under their cap, several can pass the check before the first increment lands. The overshoot is bounded by that one member’s concurrency, it never takes the pool past its limit, and for an internal budget set by the customer’s admin, a few units of overshoot is an acceptable trade for keeping the hot path free of locks. If you sell hard per-member limits as a contractual feature, serialise each member’s metered calls through a per-member process instead, and accept the throughput cost.
Calling it from a LiveView looks like any other gated action:
def handle_event("generate", %{"prompt" => prompt}, socket) do
%{current_org: org, current_user: user} = socket.assigns
case MyApp.Teams.Usage.run(org, user, :ai_generations, fn -> MyApp.AI.generate(prompt) end) do
{:ok, output} ->
{:noreply, assign(socket, output: output)}
{:error, :member_cap_reached} ->
{:noreply, put_flash(socket, :error, "You have reached the monthly limit your admin set. Ask them to raise it.")}
{:error, :limit_exceeded} ->
{:noreply, put_flash(socket, :error, "Your team has used this month's generations. An admin can upgrade the plan.")}
{:error, :not_entitled} ->
{:noreply, put_flash(socket, :error, "Your plan does not include AI generations.")}
end
end
The two limit errors say different things because different people can fix them. A member at their cap needs their admin; a team out of pool needs someone with billing rights. Telling a member to “upgrade” when they have no access to billing is how small annoyances become tickets.
The admin screen: who used what, live
Admins need two things on one page: the pool meter, and a row per member with their usage and cap. The pool is Aurora Meter’s drop-in component. Member rows are a handful of ETS reads, one per member, which is cheap even for a large team.
defmodule MyAppWeb.TeamUsageLive do
use MyAppWeb, :live_view
import AuroraMeter.Components
@feature :ai_generations
def mount(_params, _session, socket) do
org = socket.assigns.current_org
if connected?(socket), do: AuroraMeter.LiveView.subscribe(org)
{:ok,
socket
|> assign(quota: AuroraMeter.quota(org, @feature))
|> assign(members: member_rows(org))}
end
def handle_info({:aurora_meter, :usage, _payload} = message, socket) do
socket = AuroraMeter.LiveView.assign_quota(message, socket, :quota, @feature)
{:noreply, assign(socket, members: member_rows(socket.assigns.current_org))}
end
defp member_rows(org) do
for user <- MyApp.Accounts.list_members(org) do
used = AuroraMeter.usage({org, user}, @feature)
cap = MyApp.Teams.cap_for(org, user, @feature)
%{user: user, used: used, cap: cap, share: share(used, AuroraMeter.usage(org, @feature))}
end
|> Enum.sort_by(& &1.used, :desc)
end
defp share(_used, 0), do: 0
defp share(used, total), do: round(used * 100 / total)
end
<.usage_meter quota={@quota} />
<table class="mt-6 w-full text-sm">
<thead>
<tr><th>Member</th><th>Used</th><th>Share of team</th><th>Cap</th></tr>
</thead>
<tbody>
<tr :for={row <- @members}>
<td><%= row.user.email %></td>
<td><%= row.used %></td>
<td><%= row.share %>%</td>
<td><%= row.cap || "No cap" %></td>
</tr>
</tbody>
</table>
The page subscribes to the org’s usage messages only. Every metered call bumps the pool, so each pool update is also the signal to refresh the member rows; there is no need to subscribe to every member key. Updates arrive on Aurora Meter’s broadcast interval, so the table can lag the pool by up to a second, which is fine for a dashboard. If you want a chart rather than a table, our real-time usage dashboard guide builds one on the same messages.
Sorting by usage puts the heavy user at the top, which answers the support ticket from the introduction before anyone has to write it.
Picking sensible defaults
Most admins will never open the caps form, so the defaults decide how the feature feels.
- No cap by default. A new member draws from the pool freely. This keeps the product working exactly like a plain pool until someone chooses otherwise.
- Warn the admin, not just the member. When a single member passes, say, 40% of the pool before the middle of the period, email the admin with a link to the usage page. It is the early version of the lockout ticket, and it arrives while there is still time to act. Our guide to quota alerts in Phoenix has the once-per-period threshold logic.
-
Offer a fair-share preset. A one-click “cap everyone at twice their fair share” (
div(pool, seats) * 2) is a reasonable middle ground that admins understand without arithmetic. Read seats withAuroraMeter.feature_value(org, :seats, 1). - Never cap admins by default. The person who can change caps should not be locked out by them.
Members joining and leaving
Seat changes raise questions you should answer before a customer asks.
When a member is removed, their usage stays in the pool. It was consumed, the work was done, and the org paid for it. Do not subtract it from the org counter. Their member counter simply stops moving; keep the allowance row (or delete it with the membership) as your data retention policy requires.
When a member is re-added in the same period, their counter is still there, so their cap still reflects what they used earlier. That is usually the correct behaviour: a cap is a monthly budget, and leaving and rejoining should not reset it.
When the org changes plan mid-period, the pool limit changes with the plan, and member caps do not. A cap higher than the new pool is harmless (the pool check refuses first), but surface it in the admin screen so nobody is confused by a cap of 3,000 on a 1,000 pool.
Billing the pool, attributing the members
On a plan with included usage and overage, such as :business above, it is the org’s counter that gets billed. Aurora Meter Pro reports the org’s metered usage to Stripe Billing Meters on the org’s subscription, deduplicated so a retry never double charges, and the member keys are on a counter plan, which is never billed. The invoice stays one line for the team, which is what a finance department expects.
Member attribution is still useful outside the invoice. Many customers do internal chargeback (“the data team used 60% of our AI budget”), and a CSV export of the member rows answers it. If you need billing-grade attribution with identities and audit history, record durable events with AuroraMeter.record/4 and put the member in dimensions (for example %{"member_id" => "user_7"}), so every unit carries who used it alongside the idempotency guarantees the durable path gives you.
Testing team limits
The behaviour worth pinning down in ExUnit is the interaction: a capped member is refused without touching the pool, and an uncapped member can still use what is left.
test "a capped member is refused without consuming the pool", %{org: org, alice: alice, bob: bob} do
AuroraMeter.subscribe(org, :team)
Enum.each([alice, bob], &MyApp.Teams.ensure_member_meter(org, &1))
MyApp.Teams.set_cap!(org, alice, :ai_generations, 2)
assert {:ok, _} = Usage.run(org, alice, :ai_generations, fn -> :done end)
assert {:ok, _} = Usage.run(org, alice, :ai_generations, fn -> :done end)
assert {:error, :member_cap_reached} = Usage.run(org, alice, :ai_generations, fn -> :done end)
assert AuroraMeter.usage(org, :ai_generations) == 2
assert {:ok, _} = Usage.run(org, bob, :ai_generations, fn -> :done end)
assert AuroraMeter.usage(org, :ai_generations) == 3
end
Add a case where the work raises and assert neither counter moved, and one where the pool is exhausted and an uncapped member gets :limit_exceeded. Our guide to testing usage-based billing in ExUnit covers period boundaries and clock control.
Summary
Team usage limits come down to a few decisions:
-
The org is the tenant. The plan’s limit on the org is the pool, enforced atomically by
with_quota/3. -
Member keys are counters. Subscribe each member to a
counterplan so their usage is measured on the same ETS path and can never be billed. -
Caps are customer data. Store them in your own table, cache them, and treat
nilas “no cap”. - Check the member, reserve the pool, count the member. In that order, so refused requests never consume shared allowance.
- Show admins who used what, live, sorted by usage, with different messages for “your cap” and “your team’s plan”.
The free Aurora Meter core does all of the metering and enforcement in this post. It is MIT and on GitHub, with the full documentation here. When you are ready to bill the pool’s overage on Stripe, sell prepaid team credits, or reconcile what Stripe invoiced against what you counted, Aurora Meter Pro is licensed per app from $99/mo with a 14-day free trial (one trial per customer), and you can preview what Pro adds first. If you would rather start from a whole product, the phx_saas starter ships multi-tenant organisations with roles and invites, Stripe billing and Oban already wired, which is exactly the shape this post assumes.