AuroraMeter. Credits
(Aurora Meter v0.4.0)
View Source
A prepaid credit ledger per tenant: grant credit, hold an estimate while work runs, settle the actual cost, and read the balance back.
Plans and counters answer "how many of X may this tenant use this period";
credits answer "how much money does this tenant have on account". Both are
keyed by the same tenant term (see AuroraMeter.Tenant), and every amount is
an integer number of micro-dollars (1e-6 USD; AuroraMeter.Credits.Money
converts to and from cents, Decimal dollars and display strings).
alias AuroraMeter.Credits
Credits.grant(org, Money.from_cents(2_000), reference: "stripe:pi_123")
Credits.balance(org)
#=> %{balance: 20_000_000, held: 0, available: 20_000_000, ...}
Credits.with_credits(org, estimate, "job:42", fn ->
{:ok, result, actual_cost} = run_job()
{:ok, result, actual_cost}
end)
#=> {:ok, result}Lifecycle
A hold/4 reserves an estimate against the available balance (balance - held), refusing with :insufficient_credits when it would go below zero
(minus :credits_overdraft_tolerance). settle/3 then debits the actual
cost — which may exceed the hold; the balance can go negative and the
overrun is reported in telemetry — and releases the whole hold, while
release/1 drops the hold without charging. debit/4 is a hold and a
settle in one step. with_credits/4 runs all of that around a function,
releasing the hold if the function fails or raises.
Every write takes a reference, the caller's idempotency key: a retried
grant with the same reference returns the original entry instead of crediting
twice, and a retried hold or debit is refused with :duplicate_reference.
Promotional credit
Grants are :paid by default; a :promotional grant (a sign-up bonus, a
goodwill top-up) is consumed before paid credit and may carry an
:expires_at. reverse/4 — a refund or chargeback — is exempt: it takes a
paid grant back and leaves the promotional figure alone. expire_due/1 — run it from a scheduler — removes what is
left of expired grants, never taking the balance below zero. It assumes at
most one live promotional grant per tenant; see the credits guide.
Storage and side effects
The ledger uses the configured Ecto repo directly (a row lock plus an
append inside one transaction), so it requires the Ecto storage and
schema version 3 (mix aurora_meter.gen.migration --from 3). After each
commit it emits [:aurora_meter, :credits, kind] telemetry, broadcasts
{:aurora_meter, :credits, %{tenant_key, balance, held, available}} on
topic/1, and when the available balance first crosses below the tenant's
(or the configured) low-balance threshold fires
[:aurora_meter, :credits, :low_balance], broadcasts
{:aurora_meter, :low_balance, ...} and calls :credits_low_balance_handler.
Summary
Types
A tenant's balance snapshot, in micro-dollars.
One bucket of a money series. spent and granted are positive magnitudes
(a chart never has to think about signs), net is granted - spent, and
balance_after is the ledger balance after the last entry in the bucket —
nil when the bucket has no entries at all.
Totals over a date range, with the range that produced them.
Everything a credit-billed dashboard needs in one read: the balance, this period's movement, and the burn/runway derived from the trailing 30 days.
A ledger entry.
Functions
Returns tenant's available balance (balance - held) in micro-dollars.
Returns tenant's balance snapshot; all zeros (and the configured currency)
when the tenant has never been granted anything.
Debits amount micro-dollars from tenant in one step (no hold), with the
same sufficiency and duplicate-reference rules as hold/4.
Expires promotional grants whose expires_at is at or before now
(default: now), removing what is left of each — min(promotional balance, grant amount), never below zero — as an :expire entry referenced
"expire:<grant id>", and stamping the grant's expired_at. Returns the
number of grants expired. Run it periodically (a Quantum job, an Oban
cron, or a plain timer).
Credits amount micro-dollars to tenant.
Like grant/3, but says whether the entry was new or a reference that had
already been granted.
Returns tenant's ledger entries, newest first.
Reserves amount micro-dollars of tenant's available balance under
reference, to be settled or released later.
Holds that are still open and were taken before :older_than, oldest first.
Releases the hold under reference without charging anything.
Takes amount back off tenant for money that has already left the payment
provider — a refund, a chargeback.
Sets (or with nil clears) tenant's own low-balance threshold in
micro-dollars, overriding :credits_low_balance_threshold.
Settles the hold under reference: debits actual_amount and releases the
whole hold. Never fails for lack of credit — an actual cost above the hold
takes the balance negative and is flagged as overrun: true in the
[:aurora_meter, :credits, :settle] telemetry metadata.
Returns tenant's money movement as zero-filled buckets, oldest first.
Returns tenant's totals over the same range spend_history/2 covers, plus
the resolved range itself.
Subscribes the calling process to tenant's credit updates:
{:aurora_meter, :credits, %{tenant_key, balance, held, available}} after
every ledger entry and {:aurora_meter, :low_balance, %{tenant_key, available, threshold}} on a low-balance crossing.
Returns everything a credit-billed dashboard needs about tenant in one map:
the balance snapshot, what moved this billing period, and the burn and runway
derived from the trailing 30 days.
The PubSub topic for a tenant key's credit updates.
Holds estimate, runs fun, and settles or releases depending on what it
returns.
Types
@type balance() :: %{ balance: integer(), held: non_neg_integer(), available: integer(), promotional: non_neg_integer(), currency: String.t(), low_balance_threshold: integer() | nil }
A tenant's balance snapshot, in micro-dollars.
@type money_point() :: %{ date: Date.t(), spent: non_neg_integer(), granted: non_neg_integer(), net: integer(), balance_after: integer() | nil }
One bucket of a money series. spent and granted are positive magnitudes
(a chart never has to think about signs), net is granted - spent, and
balance_after is the ledger balance after the last entry in the bucket —
nil when the bucket has no entries at all.
@type money_total() :: %{ spent: non_neg_integer(), granted: non_neg_integer(), net: integer(), from: Date.t(), to: Date.t() }
Totals over a date range, with the range that produced them.
@type summary() :: %{ balance: integer(), available: integer(), held: non_neg_integer(), promotional: non_neg_integer(), currency: String.t(), spent_this_period: non_neg_integer(), granted_this_period: non_neg_integer(), period: AuroraMeter.Period.t(), daily_burn: non_neg_integer() | nil, runway_days: non_neg_integer() | nil }
Everything a credit-billed dashboard needs in one read: the balance, this period's movement, and the burn/runway derived from the trailing 30 days.
@type txn() :: AuroraMeter.Schema.CreditTransaction.t()
A ledger entry.
Functions
Returns tenant's available balance (balance - held) in micro-dollars.
Examples
iex> AuroraMeter.Credits.available("never_funded_5506")
0
Returns tenant's balance snapshot; all zeros (and the configured currency)
when the tenant has never been granted anything.
Examples
iex> AuroraMeter.Credits.balance("never_funded_5442")
%{balance: 0, held: 0, available: 0, promotional: 0, currency: "usd", low_balance_threshold: nil}
@spec debit(term(), pos_integer(), String.t(), map()) :: {:ok, txn()} | {:error, :insufficient_credits | :duplicate_reference}
Debits amount micro-dollars from tenant in one step (no hold), with the
same sufficiency and duplicate-reference rules as hold/4.
Examples
{:ok, txn} = AuroraMeter.Credits.debit(org, 1_000, "req:abc", %{"model" => "small"})
txn.metadata
#=> %{"model" => "small"}
@spec expire_due(DateTime.t()) :: {:ok, non_neg_integer()}
Expires promotional grants whose expires_at is at or before now
(default: now), removing what is left of each — min(promotional balance, grant amount), never below zero — as an :expire entry referenced
"expire:<grant id>", and stamping the grant's expired_at. Returns the
number of grants expired. Run it periodically (a Quantum job, an Oban
cron, or a plain timer).
Assumes at most one live promotional grant per tenant: with several, the
balance's promotional part is their sum and the first to expire may take
credit a later grant contributed.
Examples
iex> {:ok, n} = AuroraMeter.Credits.expire_due()
iex> is_integer(n)
true
@spec grant(term(), pos_integer(), keyword()) :: {:ok, txn()} | {:error, Ecto.Changeset.t()}
Credits amount micro-dollars to tenant.
Options:
:reference— required; the idempotency key (a payment id, an invoice number). A second grant with the same reference for the same tenant returns the existing entry as{:ok, existing}without crediting again.:category—:paid(default),:promotionalor:adjustment.:expires_at—DateTime; promotional grants only.:metadata— a map stored on the entry.
Examples
{:ok, txn} = AuroraMeter.Credits.grant(org, 20_000_000, reference: "stripe:pi_123")
txn.amount
#=> 20_000_000
{:ok, ^txn} = AuroraMeter.Credits.grant(org, 20_000_000, reference: "stripe:pi_123")
@spec grant_with_status(term(), pos_integer(), keyword()) :: {:ok, txn(), :new | :duplicate} | {:error, Ecto.Changeset.t()}
Like grant/3, but says whether the entry was new or a reference that had
already been granted.
Decided under the balance row's lock, so two concurrent deliveries of the same payment cannot both be told they are the new one — which is what decides whether the host announces the payment.
Returns tenant's ledger entries, newest first.
Options:
:limit— default 50.:before— aDateTime; only entries inserted strictly before it (pass the last entry'sinserted_atto page).:kinds— which kinds to include; defaults to[:grant, :settle, :debit, :expire], i.e. holds and releases (the bookkeeping around a settlement) are hidden unless asked for.
Examples
AuroraMeter.Credits.history(org, limit: 2)
#=> [%CreditTransaction{kind: :settle, ...}, %CreditTransaction{kind: :grant, ...}]
AuroraMeter.Credits.history(org, kinds: [:hold, :release])
@spec hold(term(), pos_integer(), String.t(), keyword()) :: {:ok, txn()} | {:error, :insufficient_credits | :duplicate_reference}
Reserves amount micro-dollars of tenant's available balance under
reference, to be settled or released later.
Returns {:error, :insufficient_credits} when the available balance (plus
the overdraft tolerance) does not cover it, and {:error, :duplicate_reference} when a hold with that reference already exists.
Options: :metadata.
Examples
{:ok, hold} = AuroraMeter.Credits.hold(org, 500_000, "job:42")
hold.status
#=> :pending
Holds that are still open and were taken before :older_than, oldest first.
For a host that has to find reservations nothing will ever close. A hold is taken before the row that remembers it exists — there is no way to make those two one write, since they are in different databases as often as not — so a process killed in between leaves money reserved against a tenant with nothing left pointing at it. Only the host can tell such a hold from one whose work is simply still running, so the ledger's part is to list them.
Options: :older_than (required, a DateTime), :limit (default 200) and
:reference_prefix to narrow to one kind of work.
Examples
AuroraMeter.Credits.pending_holds(
older_than: DateTime.add(DateTime.utc_now(), -3600, :second),
reference_prefix: "doc:"
)
Releases the hold under reference without charging anything.
Examples
{:ok, txn} = AuroraMeter.Credits.release("job:42")
txn.kind
#=> :release
@spec reverse(term(), pos_integer(), String.t(), map()) :: {:ok, txn()} | {:error, :duplicate_reference}
Takes amount back off tenant for money that has already left the payment
provider — a refund, a chargeback.
Unlike debit/4 this is never refused for want of balance. The money is
gone whatever the ledger says, so refusing would only make the two disagree;
the balance may go negative, which is the honest record of a debt. Still
idempotent on reference.
The entry is categorised :reversal, which keeps it out of two places it
does not belong: it never consumes promotional credit (a refunded top-up
must not quietly spend a sign-up bonus, leaving nothing to expire), and
spend_history/2 reports it against grants rather than as spend.
@spec set_low_balance_threshold(term(), integer() | nil) :: {:ok, AuroraMeter.Schema.CreditBalance.t()}
Sets (or with nil clears) tenant's own low-balance threshold in
micro-dollars, overriding :credits_low_balance_threshold.
Examples
{:ok, row} = AuroraMeter.Credits.set_low_balance_threshold(org, 5_000_000)
row.low_balance_threshold
#=> 5_000_000
@spec settle(String.t(), non_neg_integer(), keyword()) :: {:ok, txn()} | {:error, :not_found | :already_settled}
Settles the hold under reference: debits actual_amount and releases the
whole hold. Never fails for lack of credit — an actual cost above the hold
takes the balance negative and is flagged as overrun: true in the
[:aurora_meter, :credits, :settle] telemetry metadata.
Returns {:error, :not_found} for an unknown reference and
{:error, :already_settled} when the hold was settled or released before.
Options: :metadata.
Examples
{:ok, txn} = AuroraMeter.Credits.settle("job:42", 420_000)
txn.amount
#=> -420_000
@spec spend_history( term(), keyword() ) :: [money_point()]
Returns tenant's money movement as zero-filled buckets, oldest first.
Every bucket in the range is present — a bucket the ledger never touched is
spent: 0, granted: 0, net: 0, balance_after: nil — so a chart can render
the list straight through with no gap handling. Buckets are UTC.
Options:
:days— how many days back from:to, default 30.:from/:to— explicitDatebounds (inclusive), overriding:days.:bucket—:day(default) or:month. A month bucket is dated its first day; the first and last month of a range that does not start and end on month boundaries are partial.:kinds— which kinds count as spend, default[:settle, :debit, :expire].:holdand:releasemoveheldrather thanbalance, so they are never spend and are rejected.
Examples
AuroraMeter.Credits.spend_history(org, days: 3)
#=> [%{date: ~D[2026-09-09], spent: 0, granted: 0, net: 0, balance_after: nil},
#=> %{date: ~D[2026-09-10], spent: 420_000, granted: 0, net: -420_000,
#=> balance_after: 19_580_000},
#=> %{date: ~D[2026-09-11], spent: 0, granted: 0, net: 0, balance_after: nil}]
AuroraMeter.Credits.spend_history(org, bucket: :month, days: 365)
@spec spend_total( term(), keyword() ) :: money_total()
Returns tenant's totals over the same range spend_history/2 covers, plus
the resolved range itself.
Examples
AuroraMeter.Credits.spend_total(org, days: 7)
#=> %{spent: 1_260_000, granted: 20_000_000, net: 18_740_000,
#=> from: ~D[2026-09-05], to: ~D[2026-09-11]}
Subscribes the calling process to tenant's credit updates:
{:aurora_meter, :credits, %{tenant_key, balance, held, available}} after
every ledger entry and {:aurora_meter, :low_balance, %{tenant_key, available, threshold}} on a low-balance crossing.
Examples
iex> AuroraMeter.Credits.subscribe("org_1")
:ok
Whether a hold or debit of amount would currently be accepted: the
available balance plus :credits_overdraft_tolerance covers it. Advisory —
hold/4 and debit/4 re-check under the row lock.
Examples
iex> AuroraMeter.Credits.sufficient?("never_funded_5570", 1)
false
Returns everything a credit-billed dashboard needs about tenant in one map:
the balance snapshot, what moved this billing period, and the burn and runway
derived from the trailing 30 days.
daily_burn is the mean spend per day over those 30 days
(integer division, so a tenant spending a few micro-dollars a month burns
0), and is nil when the tenant has spent nothing at all. runway_days is
available / daily_burn, and is nil whenever daily_burn is nil or
zero — there is no honest number of days to show when nothing is being spent.
The period comes from the configured period source, the same one quota/2
reports.
Examples
AuroraMeter.Credits.summary(org)
#=> %{balance: 19_580_000, available: 19_580_000, held: 0, promotional: 0,
#=> currency: "usd", spent_this_period: 420_000, granted_this_period: 20_000_000,
#=> period: %{start: ~U[2026-09-01 00:00:00Z], end: ~U[2026-10-01 00:00:00Z],
#=> source: :calendar},
#=> daily_burn: 14_000, runway_days: 1_398}
The PubSub topic for a tenant key's credit updates.
Examples
iex> AuroraMeter.Credits.topic("org_1")
"aurora_meter:credits:org_1"
@spec with_credits(term(), pos_integer(), String.t(), (-> {:ok, result, non_neg_integer()} | {:error, term()})) :: {:ok, result} | {:error, :insufficient_credits | :duplicate_reference | term()} when result: term()
Holds estimate, runs fun, and settles or releases depending on what it
returns.
fun must return {:ok, result, actual_amount} — the hold is settled for
actual_amount and {:ok, result} is returned — or {:error, reason},
which releases the hold and is returned as-is. If fun raises, throws or
exits, the hold is released and the error propagates. Any other return
value releases the hold and raises ArgumentError.
Returns {:error, :insufficient_credits} (or :duplicate_reference)
without running fun when the hold is refused.
Examples
AuroraMeter.Credits.with_credits(org, 500_000, "job:42", fn ->
{:ok, generate(), 420_000}
end)
#=> {:ok, result}