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.

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.

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

balance()

@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.

money_point()

@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.

money_total()

@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.

summary()

@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.

txn()

A ledger entry.

Functions

available(tenant)

@spec available(term()) :: integer()

Returns tenant's available balance (balance - held) in micro-dollars.

Examples

iex> AuroraMeter.Credits.available("never_funded_5506")
0

balance(tenant)

@spec balance(term()) :: balance()

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}

debit(tenant, amount, reference, metadata \\ %{})

@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"}

expire_due(now \\ DateTime.utc_now())

@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

grant(tenant, amount, opts)

@spec grant(term(), pos_integer(), keyword()) ::
  {:ok, txn()} | {:error, Ecto.Changeset.t()}

Credits amount micro-dollars to tenant.

Options:

  • :referencerequired; 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), :promotional or :adjustment.
  • :expires_atDateTime; 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")

grant_with_status(tenant, amount, opts)

@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.

history(tenant, opts \\ [])

@spec history(
  term(),
  keyword()
) :: [txn()]

Returns tenant's ledger entries, newest first.

Options:

  • :limit — default 50.
  • :before — a DateTime; only entries inserted strictly before it (pass the last entry's inserted_at to 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])

hold(tenant, amount, reference, opts \\ [])

@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

pending_holds(opts)

@spec pending_holds(keyword()) :: [txn()]

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:"
)

release(reference)

@spec release(String.t()) :: {:ok, txn()} | {:error, :not_found | :already_settled}

Releases the hold under reference without charging anything.

Examples

{:ok, txn} = AuroraMeter.Credits.release("job:42")
txn.kind
#=> :release

reverse(tenant, amount, reference, metadata \\ %{})

@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.

set_low_balance_threshold(tenant, threshold)

@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

settle(reference, actual_amount, opts \\ [])

@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

spend_history(tenant, opts \\ [])

@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 — explicit Date bounds (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]. :hold and :release move held rather than balance, 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)

spend_total(tenant, opts \\ [])

@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]}

subscribe(tenant)

@spec subscribe(term()) :: :ok | {:error, term()}

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

sufficient?(tenant, amount)

@spec sufficient?(term(), integer()) :: boolean()

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

summary(tenant)

@spec summary(term()) :: summary()

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}

topic(tenant_key)

@spec topic(String.t()) :: String.t()

The PubSub topic for a tenant key's credit updates.

Examples

iex> AuroraMeter.Credits.topic("org_1")
"aurora_meter:credits:org_1"

with_credits(tenant, estimate, reference, fun)

@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}