From the blog

Pay-As-You-Go Billing in Elixir: Prepaid Credits in Aurora Meter 0.4.0

By Liam Killingback ·

Pay-As-You-Go Billing in Elixir: Prepaid Credits in Aurora Meter 0.4.0

Metering usage is the easy half. You count requests in ETS, flush them to Postgres, and show a bar on a dashboard. The hard half starts when each of those requests costs you real money before the customer has paid you anything.

That is the gap Aurora Meter 0.4.0 closes. The free MIT core now ships a prepaid credit ledger: real money, in your own Postgres, in your own transactions. Aurora Meter Pro fills that ledger from Stripe and takes money back out again when a payment is reversed.

This post covers what shipped, with working code, and the design decisions behind the parts that are easy to get wrong.

Why counting is not enough

Say you sell document parsing. A customer uploads a 900 page contract. Your OCR provider invoices you about eight dollars for it whether the customer ever pays or not.

Two common approaches both fail here:

Charge at the end of the month. Two large jobs start against one small balance, both finish, and you are eight dollars down with an invoice the customer may dispute.

Charge a flat subscription. Your heaviest customer costs you more than they pay, and your lightest subsidises them.

Pay-as-you-go fixes the incentives: the customer buys credit up front, and each unit of work spends some of it. The awkward part is that you often do not know what a job costs until it has finished.

What 0.4.0 adds to the free core

Aurora Meter is MIT licensed and the whole ledger is in the free package. Nothing here requires Pro.

  • A prepaid credit ledger with grants, debits, holds, settlements and reversals
  • A counter feature kind for quantities that are measured but never billed at period end
  • Integer plan features, so feature :seats, 5 is a number your code reads
  • A money series for charts: daily and monthly spend, burn rate and runway
  • Inline SVG components for a balance summary and a spend chart

Install it with:

def deps do
  [{:aurora_meter, "~> 0.4"}]
end

The ledger, in three calls

Every grant carries a reference, and the reference is the idempotency key.

alias AuroraMeter.Credits
alias AuroraMeter.Credits.Money

Credits.grant(org, Money.from_cents(2_500), reference: "stripe:pi_3abc")
# {:ok, %CreditTransaction{kind: :grant, amount: 25_000_000}}

Credits.available(org)
# 25_000_000

Credits.debit(org, 1_500, "req:#{request_id}")
# {:ok, %CreditTransaction{}} | {:error, :insufficient_credits}

That reference matters more than any other line in this post. Stripe delivers each webhook at least once and retries a failed delivery for days. Without a stable key, one twenty five dollar payment funds an account fifty dollars, then seventy five. Key it on something Stripe will repeat, like the PaymentIntent id, and a redelivery returns the original entry and credits nothing.

Money is micro-dollars

Amounts are integers. One dollar is 1,000,000.

Floats are out because 0.1 + 0.2 is not 0.3, and a ledger that drifts is worse than no ledger. Cents are out because a request that costs a fortieth of a cent rounds to zero, and a million of those are then free.

Money.from_cents(2_500)        # 25_000_000
Money.format(25_000_000)       # "$25.00"
Money.format_compact(1_500)    # "$0.0015"

One trap to know: Money.format/2 renders two decimal places by default, so a per-request price of 1,500 comes out as "$0.00". Use format_compact/1 for unit prices and format/2 for balances.

Work of unknown cost: hold, then settle

This is the case that makes prepaid billing hard. You estimate a document at 200 pages, so roughly thirty cents, and you learn the real page count only after the parser has run.

Reserve the estimate, then charge the truth:

{:ok, _} = Credits.hold(org, 300_000, "doc:#{doc.id}")
# ... parse. It was really 214 pages, so $0.321 ...
{:ok, _} = Credits.settle("doc:#{doc.id}", 321_000)

While the hold is open, available already reflects it, so a second job cannot spend the same money. A settlement never fails for want of credit: if the document turned out to be three thousand pages, the balance goes negative, which is the honest record of a debt you have already incurred with your provider. The next hold or debit is refused until a grant repairs it.

In practice you want the wrapper, which handles the failure paths:

Credits.with_credits(org, 300_000, "doc:#{doc.id}", fn ->
  case Parser.run(doc) do
    {:ok, text, pages} -> {:ok, text, pages * 1_500}
    {:error, reason} -> {:error, reason}
  end
end)
# {:ok, text} | {:error, reason} | {:error, :insufficient_credits}

Return {:ok, result, cost} to settle for the real cost, or an error tuple to release and charge nothing. If the function raises, throws or exits, the hold is released before the error propagates.

Calling it inside your own transaction

config :aurora_meter, repo: is your repo, so a ledger call inside your own Repo.transaction/1 joins it. Recording a parsed document and settling its charge is one atomic write:

Repo.transaction(fn ->
  {:ok, _} = Documents.store_result(doc, text)
  {:ok, _} = Credits.settle("doc:#{doc.id}", cost)
end)

Every refusal, whether that is insufficient credit, a duplicate reference or an already settled hold, is decided before anything is written and comes back as an error tuple with your transaction still open and still yours to commit.

The counter feature kind, and why it exists

If your product is prepaid, nothing is billed at period end. The money left the ledger when the request ran.

Modelling that with a metered feature and an allowance of zero produces a dashboard that tells a paying customer they are six requests over their limit and will be invoiced. Both halves of that sentence are false.

defmodule MyApp.Plans do
  use AuroraMeter.Plans

  plan :payg do
    price 0
    counter :pages_parsed
    counter :api_requests
    feature :webhooks, true
  end
end

A counter is measured, never blocked and never billed. Its quota reports limit, included and percent as nil, deliberately, because there is no denominator. Anything drawing a progress bar has to treat that nil as “no bar” rather than as zero, which is exactly the misreading the kind exists to prevent. The bundled usage_meter component already does.

If you want plan limits alongside your credits, the core still does hard caps and metered allowances. We covered that side in Plan-Based Feature Gating and Entitlements in Phoenix.

Showing the money

The ledger reports its own series, zero filled and oldest first, so a chart needs no gap handling:

Credits.spend_history(org, days: 30)
# [%{date: ~D[2026-03-01], spent: 210_000, granted: 0,
#    net: -210_000, balance_after: 24_998_500}, ...]

Credits.summary(org)
# %{balance: 24_998_500, held: 300_000, promotional: 5_000_000,
#   spent_this_period: 1_501_500, daily_burn: 210_000, runway_days: 118}

daily_burn and runway_days are nil when there is nothing honest to report, such as a brand new account. Render the nil rather than turning it into a zero and telling a customer they have no runway left.

What Aurora Meter Pro adds

The free core never touches a card. Aurora Meter Pro is the part that does, and 0.3.0 shipped alongside the core release.

Top-ups. A one off purchase is a Stripe Checkout session, and the credit lands from the payment_intent.succeeded webhook, keyed on the PaymentIntent id:

{:ok, url} =
  AuroraMeter.Pro.Credits.checkout(org, 2_500,
    success_url: url(~p"/billing?topped_up=1"),
    cancel_url: url(~p"/billing")
  )

Do not credit the ledger from the success redirect. Anyone can visit a URL.

Auto top-up. When the balance crosses a threshold, the card saved by the last purchase is charged off session:

AuroraMeter.Pro.Credits.update_auto_top_up(org, %{
  auto_top_up_enabled: true,
  threshold_micro: 5_000_000,
  amount_cents: 2_500
})

Refunds and chargebacks. charge.refunded and the dispute events take credit back out of the ledger, capped at what the original payment granted, and switch auto top-up off so a refund cannot immediately trigger a fresh charge on the card you are refunding.

A request audit log. One row per API call with the actor, route, status, duration and the money attributable to the call, with filters and cursor paging.

A credit aware dashboard. Balance, spend this period, runway and a spend chart, above the usual quota cards.

The unglamorous half: correctness

Most of this release, by volume, is not features. It is the result of auditing the ledger and the Stripe integration against a live sandbox, with real payments, a real refund and a real dispute. A few of the findings are worth repeating because they are not specific to this library.

A rollback inside a nested transaction takes the caller down with it. The ledger used to answer a duplicate reference with Repo.rollback/1. Inside a host’s own transaction that marks the whole transaction, whatever :mode you pass, so a redelivered webhook destroyed the host’s unrelated writes and failed its next statement on that connection. Refusals return an error tuple now.

The reason it survived a test suite is worth knowing if you write Ecto tests: the SQL sandbox holds a transaction of its own, so yours is nested inside it and the abort unwinds no further than the sandbox savepoint. That whole class of bug is invisible under a sandboxed DataCase. The regression test for it is deliberately unsandboxed.

A timeout is not a decline. An auto top-up that fails with a network timeout or a 5xx says nothing about whether the card works. Counting those against the card meant a few minutes of Stripe being unwell disabled auto top-up for every customer with a low balance, with a reason naming their card. Failures are now classified by whether Stripe’s answer settles what happened to the money.

A refund is not a debit. Written as a plain negative entry, a refund quietly consumed the customer’s sign-up bonus and showed up in their spend chart as though they had used the money. Reversals carry their own category, leave promotional credit alone, and count against grants rather than spend.

An exit is not a raise. A quota reservation was released on rescue only. An exit is how gated work usually fails, since a GenServer.call, a Task.await and a database checkout all time out by exiting, and an exit unwinds straight past a rescue. Every hard limit ratcheted down by one each time a call timed out.

Upgrading from 0.3.x

Schema versions 3 through 6, in one migration:

mix aurora_meter.gen.migration -r MyApp.Repo --from 3
mix ecto.migrate

Version 3 is the ledger tables, 4 adds a promotional snapshot to every entry, 5 is a partial index for the open hold sweep, and 6 is idempotent flush receipts, so retrying an uncertain commit cannot count the same usage twice. All four are required.

If you were writing metered(:f, included: 0, unit_price: 0) to mean “count it but never bill it”, switch to counter(:f) and your dashboard stops calling every unit overage.

Where to start

If you are new to metering in Phoenix, Metering API Usage in Phoenix in Real Time with ETS covers the hot path this ledger sits beside, and Usage-Based Billing in Elixir and Phoenix covers the subscription and overage model if that fits your product better than credits.

The free core is on Hex as {:aurora_meter, "~> 0.4"} and on GitHub at liamkillingback/aurora-meter. The guides live at /docs/aurora-meter, including worked examples for a team SaaS, a metered allowance and a prepaid product.

Aurora Meter Pro adds the Stripe side, the dashboards and the audit log, licensed per application. Pricing is at /pricing.

Building the rest of the app too? The Phoenix starters at PhxTemplates ship auth, Stripe, email and admin already wired, and the Builder Pass covers all of them.