Counting is half the job. This guide is the other half: the billing page, the number that climbs while you watch it, the chart, and the events you hang your own alarms off.
It assumes you have read Every word, explained once and at least one of the product guides.
#1. One call per card
quota/2 returns everything a dashboard card needs, whatever kind of feature it
is:
AuroraMeter.quota(org, :generations)
# %{feature: :generations, kind: :metered, used: 1_240, included: 1_000,
# overage: 240, unit_price: 2, limit: nil, remaining: :unlimited,
# percent: 100, enabled: true, period: %{...}}
The kind tells you how to draw it, and they do not all draw the same way:
kind |
What it is | How to draw it |
|---|---|---|
:hard |
a wall | a bar, full at 100% |
:metered |
an allowance you may pass |
a bar plus overage. percent stops at 100 |
:counter |
measured, no denominator | a bare number. No bar |
:boolean |
a switch | a tick or a cross |
:feature |
a number the plan carries | the number |
:undeclared |
you never declared it | nothing, or a warning for developers |
#The two rules that keep a page honest
percent: nil means “no bar”. Never render it as zero.
A counter has nothing to be a percentage of, so limit, included and
percent are all nil on purpose. Turn that nil into a zero and your page
tells a paying customer they have used “0% of 0” and are presumably out, which
is exactly the reading this kind exists to prevent.
percent never goes above 100, so it cannot tell you about overage. It is
clamped, which is right for a bar and wrong for a sentence. When kind is
:metered, read overage to find out whether they went past, and by how much.
<div class="quota-card">
<h3><%= @q.feature %></h3>
<%= if @q.percent do %>
<.bar percent={@q.percent} />
<p><%= @q.used %> of <%= @q.included || @q.limit %></p>
<% else %>
<p class="count"><%= @q.used %> this period</p>
<% end %>
<p :if={@q.kind == :metered and @q.overage > 0} class="overage">
<%= @q.overage %> over, about
<%= AuroraMeter.Credits.Money.format(@q.overage * @q.unit_price * 10_000) %>
on your next invoice
</p>
</div>
If you would rather not write that, the shipped component already has these rules in it:
<AuroraMeter.Components.usage_meter tenant={@org} feature={:generations} />
<AuroraMeter.Components.usage_meter tenant={@org} feature={:seats} label="People" />
It draws a counter as a bare count with no bar, shows a metered feature’s
overage as a figure rather than pretending the bar can express it, and inherits
your colours through currentColor. There is no stylesheet to import and no
JavaScript.
For every feature on the customer’s plan at once:
<AuroraMeter.Components.usage_summary tenant={@org} />
#2. Live, without polling
Usage totals are broadcast about once a second over PubSub.
defmodule InkwellWeb.UsageLive do
use InkwellWeb, :live_view
def mount(_params, _session, socket) do
org = socket.assigns.current_org
if connected?(socket), do: AuroraMeter.LiveView.subscribe(org)
{:ok, assign(socket, org: org, quota: AuroraMeter.quota(org, :generations))}
end
def handle_info({:aurora_meter, :usage, %{feature: :generations}}, socket) do
{:noreply, assign(socket, quota: AuroraMeter.quota(socket.assigns.org, :generations))}
end
def handle_info({:aurora_meter, :usage, _other}, socket), do: {:noreply, socket}
end
Two things worth copying exactly.
Subscribe only when
connected?/1is true. A LiveView mounts twice: once for the static render, once for the socket. Subscribing in the first one gives you a subscription belonging to a process that is about to die, and a page that never updates.
Keep the catch-all clause. You are subscribed to the customer, so you
receive every feature they use, not only the one you are drawing. Without that
second handle_info/2, an unrelated feature crashes the page.
A broadcast only carries the keys that were touched since the last one, so an idle customer produces no messages at all.
#A live balance
The credit ledger has its own subscription, which fires after each entry commits:
AuroraMeter.Credits.subscribe(org)
def handle_info({:aurora_meter, :credits, %{balance: balance, held: held, available: available}}, socket) do
{:noreply, assign(socket, available: available, held: held, settled: balance)}
end
def handle_info({:aurora_meter, :low_balance, %{available: available}}, socket) do
{:noreply, put_flash(socket, :warning, "Balance is down to #{Money.format(available)}")}
end
available is what the customer can still spend: balance minus anything
currently held. Show available. Show held separately if you show it at all
(“$0.30 reserved for work in progress”), because somebody who sees only
balance will wonder where the difference went.
#3. Charts
Daily buckets, already filled in and sorted oldest first, so there is no gap handling to write:
AuroraMeter.history(org, :generations, days: 30)
# [%{date: ~D[2026-02-10], value: 41}, %{date: ~D[2026-02-11], value: 0}, ...]
For money, the ledger’s own series:
AuroraMeter.Credits.spend_history(org, days: 30)
# [%{date: ~D[2026-03-01], spent: 210_000, granted: 0, net: -210_000,
# balance_after: 24_998_500}, ...]
AuroraMeter.Credits.spend_history(org, from: ~D[2026-01-01], to: ~D[2026-03-31], bucket: :month)
spent and granted are positive magnitudes. net is the change in the
balance. balance_after is the balance at the last entry in the bucket, and
nil for a bucket with no entries, which is another nil to render rather than
zero.
Refunds count against granted rather than as spend, so granted can go
negative in a window whose refunds exceeded its top ups. That is correct, and
your chart should allow for it.
<AuroraMeter.Components.spend_chart
points={@spend}
height={120}
label="Last 30 days"
show_grants
/>
<AuroraMeter.Components.credit_summary summary={@summary} />
Both are inline SVG with <title> tooltips and no JavaScript.
#4. The whole billing page, with Pro
If you have the Pro package, one call assembles everything:
def mount(_params, _session, socket) do
org = socket.assigns.current_org
{:ok, assign(socket, org: org, dashboard: AuroraMeter.Pro.Dashboard.load(org))}
end
<AuroraMeter.Pro.Components.usage_dashboard tenant={@org} data={@dashboard} money={true} />
That renders the money section first when the customer has a credit ledger (balance, spend this period, runway, spend chart), then quota cards, daily charts and monthly history. A product with no ledger simply gets no money section, so you do not need to branch.
Pass money={false}, or Dashboard.load(org, credits: false), to leave money
out of a page that should not show it.
#5. Numbers for humans
alias AuroraMeter.Credits.Money
Money.format(1_500_000) # => "$1.50"
Money.format(0) # => "$0.00"
Money.format(1_500) # => "$0.00" two decimal places
Money.format(1_500, precision: 6) # => "$0.001500"
Money.format_compact(1_234_000_000) # => "$1.2k"
Money.format_compact(1_234_000) # => "$1.23"
Money.format_compact(70_000) # => "$0.07"
Money.format_compact(1_500) # => "$0.0015"
Money.format_compact(0) # => "$0"
The two are for different jobs. format/2 renders a balance, to two decimal
places unless you ask for more. format_compact/1 renders a number on a chart
or a unit price: it shortens thousands to “$1.2k” and, at the other end,
refuses to round a sub-cent amount away to “$0.00”.
Render a per-request price of 1,500 micro-dollars with format/2 and your
pricing page says it is free.
#6. Exports
AuroraMeter.Pro.Export.usage_csv(org, days: 90)
AuroraMeter.Pro.Export.daily_csv(org, :generations, days: 30)
def export(conn, _params) do
csv = AuroraMeter.Pro.Export.usage_csv(conn.assigns.current_org, days: 90)
conn
|> put_resp_content_type("text/csv")
|> put_resp_header("content-disposition", ~s(attachment; filename="usage.csv"))
|> send_resp(200, csv)
end
Finance teams ask for this on day two. It is cheaper to ship it on day one.
#7. Your own metrics
Everything emits telemetry. The ones you will actually want:
def metrics do
[
Telemetry.Metrics.sum("aurora_meter.track.count", tags: [:feature]),
Telemetry.Metrics.counter("aurora_meter.reserve.qty", tags: [:feature, :result]),
Telemetry.Metrics.sum("aurora_meter.credits.settle.amount", tags: [:tenant_key]),
Telemetry.Metrics.counter("aurora_meter.credits.low_balance.available"),
Telemetry.Metrics.summary("aurora_meter.flush.count")
]
end
Two of those deserve an alarm rather than a graph, and Ship it: the production checklist says which and why.
#8. Testing a page whose numbers move
defmodule InkwellWeb.UsageLiveTest do
use InkwellWeb.ConnCase, async: false
import Phoenix.LiveViewTest
setup do
AuroraMeter.Test.reset!()
:ok
end
test "shows the overage", %{conn: conn} do
org = org_fixture()
AuroraMeter.subscribe(org, :writer)
AuroraMeter.track(org, :generations, 1_240)
AuroraMeter.Test.flush!()
{:ok, _live, html} = live(conn, ~p"/usage")
assert html =~ "1,240"
assert html =~ "240 over"
end
end
AuroraMeter.Test.reset!/0 clears the in-memory counters between tests, and
flush!/0 forces the pending numbers to Postgres instead of waiting five
seconds for the timer. For money, fund!/3 puts credit on an account and
credit_balance/1 reads it back.
To assert that a live update actually arrived, push the broadcast yourself rather than sleeping:
AuroraMeter.track(org, :generations, 10)
AuroraMeter.Test.broadcast!()
assert render(live) =~ "1,250"
#What to read next
- Ship it: the production checklist for the alarms and the cluster behaviour behind these numbers.
- Sell credit up front, spend it per request if the money section of the dashboard is empty and you want it filled.
Keep going
The core is free. Pro is the Stripe half.
Everything the free MIT package does is yours with no account and no expiry. Aurora Meter Pro adds Stripe checkout, metered usage reporting, prepaid top-ups, dashboards and alerts, and every plan starts with a free trial.