Put the numbers on screen

One call fills a dashboard card. One subscribe makes it live. Plus the two rules that keep a usage page honest, and the charts that need no gap handling.

Free core 14 min Beginner

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?/1 is 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"

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.