The product here is Parsely, a document parsing API. There is no monthly fee. Customers buy credit, $25 at a time, and every document spends some of it. A small PDF costs a fraction of a cent. A 900 page contract costs several dollars. New accounts get $5 free, which expires after 30 days.
This is the credit ledger: the part of Aurora Meter that holds actual money. It is the longest guide on the site, because money has more ways to go wrong than counting does.
#1. The plan has no limits in it
defmodule Parsely.Plans do
use AuroraMeter.Plans
plan :payg do
price 0
counter :pages_parsed
counter :api_requests
feature :webhooks, true
end
end
Every quantity here is a counter: measured, never blocked, never billed.
That is not laziness. The money is in the ledger, not in the plan. A customer with credit may parse as much as they like. A customer without credit is stopped by the balance, not by a quota.
Modelling this with metered ... included: 0 would make your dashboard announce
that every single request is overage awaiting an invoice. Both halves of that
sentence are false, because nothing is invoiced at the end of the month: the
money left when the request ran.
#2. Money is micro-dollars
One dollar is 1,000,000 micro-dollars. Use the helpers rather than writing zeroes:
alias AuroraMeter.Credits
alias AuroraMeter.Credits.Money
Money.from_cents(2_500) # => 25_000_000 ($25.00)
Money.format(25_000_000) # => "$25.00"
Money.format_compact(1_500) # => "$0.0015"
Money.format(1_500) # => "$0.00" rounds at 2 decimal places
Money.format(1_500, precision: 6) # => "$0.001500"
Sub-cent amounts are the whole reason for the unit. Parsely charges $0.0015 per page. In cents that rounds to zero, and a million pages would be free.
Which is also why format/2 needs care. Its default precision is two, so a per
page price rendered with it reads “$0.00”, the very misreading the unit exists
to prevent. Use format_compact/1 for unit prices and format/2 for balances.
#3. Putting money in
Every grant needs a :reference, and the reference is the idempotency key:
Credits.grant(org, Money.from_cents(2_500), reference: "stripe:pi_3abc")
# {:ok, %CreditTransaction{kind: :grant, amount: 25_000_000}}
# the same webhook, delivered twice
Credits.grant(org, Money.from_cents(2_500), reference: "stripe:pi_3abc")
# {:ok, %CreditTransaction{...}} the original entry. Nothing was added.
This matters more than any other line in this guide. Stripe delivers each event at least once and retries for days. Without a stable reference, one $25 payment funds an account $50, or $75. Key it on something Stripe gives you and will repeat, like the PaymentIntent id. Never a timestamp, never a random value.
When you need to know which of the two happened, to send a receipt exactly once for example, ask for the status:
Credits.grant_with_status(org, amount, reference: "stripe:pi_3abc")
# {:ok, txn, :new} | {:ok, txn, :duplicate}
Ask the ledger. Do not look the reference up first and decide yourself: two
deliveries arriving together both find nothing, both call themselves new, and
the customer gets two receipts. grant_with_status/3 decides inside the row
lock, where that race cannot happen.
#4. Taking money out
#When you know the cost up front
Credits.debit(org, 1_500, "req:#{request_id}")
# {:ok, %CreditTransaction{}}
# {:error, :insufficient_credits}
# {:error, :duplicate_reference}
#When you do not
This is the normal case for real work. You know a document is about 200 pages, so it will cost about $0.30, but you only learn the true page count after the parser has run.
Charging afterwards lets two large jobs start against one small balance and both overdraw. Charging up front overcharges. So: hold the estimate, then settle the truth.
{:ok, _} = Credits.hold(org, 300_000, "doc:#{doc.id}") # reserve $0.30
# ...parse. It was really 214 pages, $0.321...
{:ok, _} = Credits.settle("doc:#{doc.id}", 321_000) # charge the real cost
| Step | What moves | Fails with |
|---|---|---|
hold/4 |
held goes up, available goes down |
:insufficient_credits, :duplicate_reference |
settle/3 |
balance drops by the real cost, the hold is freed |
:not_found, :already_settled |
release/1 |
the hold is freed, nothing is charged |
:not_found, :already_settled |
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 3,000 pages and cost more than the hold, the balance goes negative. That is deliberate: the work is done and your provider has already invoiced you, so a negative balance is the honest record of a debt. The next
holdordebitis refused until a grant brings them back above zero.
#The one call that does all three properly
Credits.with_credits(org, 300_000, "doc:#{doc.id}", fn ->
case Parsely.Parser.run(doc) do
{:ok, text, pages} -> {:ok, text, pages * 1_500} # settle the real cost
{:error, reason} -> {:error, reason} # release, charge nothing
end
end)
# {:ok, text} | {:error, reason} | {:error, :insufficient_credits}
Your function returns {:ok, result, cost_in_micros} to settle, or
{:error, reason} to release and charge nothing. If it raises, throws or exits,
the hold is released and the error travels on.
Use this rather than hand rolling hold and settle. It is the same three calls with the failure paths already right.
#5. Calling it from inside your own transaction
Parsely records the parsed text and settles the charge together, so either both land or neither does:
Parsely.Repo.transaction(fn ->
{:ok, _} = Parsely.Documents.store_result(doc, text)
{:ok, _} = Credits.settle("doc:#{doc.id}", cost)
end)
This is supported and intended. config :aurora_meter, repo: is your repo, so
the ledger call joins your transaction.
Every refusal (:insufficient_credits, :duplicate_reference,
:already_settled) is decided before anything is written, and comes back as
{:error, reason} with your transaction still open. None of them calls
Repo.rollback/1, deliberately: in a nested transaction a rollback marks the
whole transaction, so a duplicate webhook delivery would take your own writes
down with it.
If you write a test for this, do not use a sandboxed
DataCase. The Ecto SQL sandbox holds a transaction of its own, so yours is nested inside it and an abort unwinds no further than the sandbox’s savepoint. This class of bug is invisible there. It survived an audit round in this very library behind a passing test.
#6. Holds that nothing will ever close
A hold is taken before the row that remembers it exists, and those two cannot be
one write: the ledger is a different schema and often a different database. Kill
the process in between and money is reserved against a customer with nothing
pointing at it. available stays low for ever and nobody knows why.
Only you can tell such a hold from one whose work is still running, so the ledger’s job is to list them:
defmodule Parsely.Workers.HoldSweeper do
use Oban.Worker, queue: :maintenance
require Logger
@impl true
def perform(_job) do
cutoff = DateTime.add(DateTime.utc_now(), -3600, :second)
for hold <- AuroraMeter.Credits.pending_holds(older_than: cutoff, reference_prefix: "doc:") do
"doc:" <> id = hold.reference
unless Parsely.Documents.running?(id) do
{:ok, _} = AuroraMeter.Credits.release(hold.reference)
Logger.warning("released orphan hold #{hold.reference}")
end
end
:ok
end
end
:older_than is a DateTime and is required. :reference_prefix narrows to one
kind of work, and :limit defaults to 200.
Give your holds references you can look up again (the document id, the job id)
and run this on a schedule. Every prefix you use needs covering: a sweeper that
only knows about "doc:" will never free a stranded "query:".
#7. The free trial, and making it expire
Credits.grant(org, Money.from_cents(500),
reference: "signup:#{org.id}",
category: :promotional,
expires_at: DateTime.add(DateTime.utc_now(), 30, :day)
)
Categories are :paid (the default), :promotional and :adjustment.
Promotional credit is spent first, so a customer who tops up before their
trial runs out burns the free money before their own.
Expiry is a sweep:
{:ok, expired_count} = Credits.expire_due(DateTime.utc_now())
With Pro, AuroraMeter.Pro.Credits.Expirer is an Oban worker that does this for
you every half hour.
Two behaviours worth knowing, because both were bugs once:
- Expiry will not take back money a hold has reserved. A hold promises the money will be there when the work settles, so the grant keeps its expiry unset and a later pass finishes the job once the hold closes.
- A grant expires only its own remainder. With two promotional grants live, the first to expire cannot reclaim money the second put in. Promotional spend is attributed soonest-expiring-first.
#8. Refunds and chargebacks
A refund is not a debit. Use reverse/4:
Credits.reverse(org, Money.from_cents(2_500), "stripe:re_3xyz", %{"source" => "refund"})
It is never refused for want of balance, because the money has already left Stripe and refusing would only make the ledger disagree with reality. It is idempotent on the reference like everything else.
Reversals are written with category: :reversal, which keeps them out of two
places a plain negative debit did not belong:
- they do not eat promotional credit, so refunding a top up no longer silently consumes the sign-up bonus;
-
they count against
grantedrather than as spend, so a refunded customer does not see the money in their spend chart or in the burn rate behind their runway estimate.
With Pro you do not call this yourself. The Stripe webhook does, for
charge.refunded and for disputes, including putting the credit back if you win
one.
#9. Telling them before they run out
# a default for everybody
config :aurora_meter,
credits_low_balance_threshold: 5_000_000, # $5.00
credits_low_balance_handler: &Parsely.Billing.low_balance/1
# or per customer, overriding the default
Credits.set_low_balance_threshold(org, 20_000_000) # $20.00
defmodule Parsely.Billing do
alias AuroraMeter.Credits.Money
def low_balance(%{tenant_key: key, available: available}) do
org = Parsely.Orgs.get_by_key!(key)
Parsely.Mailer.low_balance(org, Money.format(available))
end
end
The handler runs after the crossing commits, and only on a crossing. Going from $6 to $4 fires once. Going from $4 to $3 does not fire again. That is what you want for an email, and worth knowing before you write one that assumes otherwise.
#10. Showing the money
Credits.summary(org)
# %{balance: 24_998_500, held: 300_000, promotional: 5_000_000, currency: "usd",
# spent_this_period: 1_501_500, granted_this_period: 25_000_000,
# daily_burn: 210_000, runway_days: 118}
Credits.spend_history(org, days: 30)
# [%{date: ~D[2026-03-01], spent: 210_000, granted: 0, net: -210_000,
# balance_after: 24_998_500}, ...]
daily_burn and runway_days are nil when there is nothing honest to report:
a brand new account, or one that has spent nothing. Render the nil. Do not
turn it into a zero and tell a customer they have no runway left.
spend_history/2 is filled across the whole range and sorted oldest first, so a
chart needs no gap handling. Holds and releases are excluded, because they move
held rather than balance.
Two components draw it, and both are plain SVG with no JavaScript:
<AuroraMeter.Components.credit_summary summary={@summary} />
<AuroraMeter.Components.spend_chart points={@spend} height={120} label="Last 30 days" />
And live updates have their 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
Show available, which is what the customer can still spend. 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.
#11. Letting them buy more
The core never touches a card. Pro sells credit through Stripe Checkout and credits the ledger from the webhook:
{:ok, url} =
AuroraMeter.Pro.Credits.checkout(org, 2_500,
success_url: url(~p"/billing?topped_up=1"),
cancel_url: url(~p"/billing")
)
redirect(conn, external: url)
And automatic recharge charges the card saved by the last purchase, off session, when the balance crosses a threshold:
AuroraMeter.Pro.Credits.update_auto_top_up(org, %{
auto_top_up_enabled: true,
threshold_micro: 5_000_000, # below $5.00
amount_cents: 2_500 # charge $25.00
})
The details, including how a charge that times out is not charged twice and what happens after three declines, are in Let customers top up.
#The whole thing, end to end
# new customer: $5 free, expiring in a month
Credits.grant(org, Money.from_cents(500),
reference: "signup:#{org.id}", category: :promotional,
expires_at: DateTime.add(DateTime.utc_now(), 30, :day))
# they parse a document of unknown size
Credits.with_credits(org, 300_000, "doc:#{doc.id}", fn ->
{:ok, text, pages} = Parsely.Parser.run(doc)
{:ok, text, pages * 1_500}
end)
# and it is counted, for the dashboard, without pretending to be billable
AuroraMeter.track(org, :pages_parsed, pages)
# they run low, and the handler emails them
# they top up through Stripe, and the webhook grants, keyed on the PaymentIntent
# a month later the unused trial credit expires, but not what a hold reserved
#What to read next
- Let customers top up, and top them up automatically for the Stripe side.
- Put the numbers on screen for the balance, the burn and the counters.
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.