Lots, debt and credit that arrives every month

A balance made of lots answers the questions one number cannot: which money expires first, which money a refund takes back, and whether a customer who owes you can spend the credit you gave them. Plus allowances that arrive once per period and roll over by a capped amount.

Free core 20 min Intermediate

A prepaid balance that is one number cannot answer the questions a finance person asks. Which money expires at the end of the month? Did the refund take back the promotional credit we gave them, or the money they paid us? If they owe us, can they spend the free credit we granted last week?

A wallet made of lots answers all three. This guide is how it works, in the free core, and it assumes you have read Sell credit up front, spend it per request.

#A wallet is a stack of lots

Every grant creates a lot: an amount, a category, an optional expiry date, and the record of what has happened to it.

AuroraMeter.Credits.grant(org, 10_000_000, reference: "stripe:pi_3abc")
# a paid lot of $10

AuroraMeter.Credits.grant(org, 2_000_000,
  reference: "welcome:" <> org.id,
  category: :promotional,
  expires_at: ~U[2026-10-01 00:00:00Z]
)
# a promotional lot of $2 that expires

Amounts are integer millionths of a dollar throughout, so 1_000_000 is one dollar and 10_000 is one cent. AuroraMeter.Credits.Money converts and formats.

Every lot satisfies one arithmetic rule, enforced by a database constraint:

available + reserved + consumed + reversed + expired == amount

Nothing leaves a lot without being accounted for in one of those four buckets.

#The spending order, and why it is fixed

When a debit or a hold needs money, the allocator walks the eligible lots in one order and takes from each until the amount is covered:

  1. Promotional before paid. Free money is spent before money somebody paid you, so a customer’s cash is still there when the promotion expires.
  2. Earliest expiry first. Within a category, the lot that dies soonest goes first. Lots that never expire go last.
  3. Oldest grant first.
  4. A sequence number, so the order is total and two lots granted in the same millisecond still have one.

There is no option to change it. A configurable spend order is a configurable answer to “what did we charge them for”, and every support conversation about credit starts from the assumption that there is one answer.

A lot is eligible when it is open and its expiry has not passed. A lot whose expiry passed five minutes ago is already unspendable, whether or not the expiry sweep has reached it.

Read them:

AuroraMeter.Credits.Lots.list(org)                       # open lots, in spend order
AuroraMeter.Credits.Lots.list(org, states: :all, limit: 200)
AuroraMeter.Credits.Lots.get(org, "stripe:pi_3abc")      # by lot id or grant reference
AuroraMeter.Credits.Lots.allocations(org, reference: "gen:abc")

allocations/2 is the one that answers “which lots paid for this request”, and it is the answer you paste into a support reply.

#Debt is a column, not a negative number

Work of unknown cost is charged with a hold and a settle. Sometimes the settle is larger than the hold, because the job did more than you estimated. The shortfall does not become a negative balance. It becomes debt, its own column, which the database will not let go below zero.

That matters because a wallet can hold three true things at once: a positive balance, a positive promotional balance, and a debt.

AuroraMeter.Credits.balance(org)
# %{
#   balance: 3_000_000, held: 0, available: 3_000_000,
#   spendable: -1_500_000,
#   promotional: 2_000_000, promotional_spendable: 0,
#   debt: 4_500_000, expired: 0,
#   currency: "USD", low_balance_threshold: nil
# }

Read those two figures together, because they are the ones your product logic uses:

  • available is balance minus what is held. It is what the wallet contains.
  • spendable is what the wallet may spend, and while any debt is outstanding it is never above zero. It stays negative rather than clamping at zero, so a dashboard can show how deep the hole is.

promotional_spendable is zero whenever there is debt, for the same reason.

Both hold/4 and debit/4 refuse a wallet that owes, whatever it holds:

AuroraMeter.Credits.hold(org, 50_000, "gen:next")
# {:error, :debt_outstanding}

A frozen wallet is a product decision, not an accident. The library will not spend a customer’s promotional credit to settle a debt they owe you, so a wallet in debt stays frozen until money arrives. Decide what your application does at that point: prompt for a top up, allow an overdraft through credits_overdraft_tolerance, or stop the work.

The one door out is a new grant. A grant repays outstanding debt out of the lot it is creating, whatever category that lot is, before the remainder becomes spendable. So topping up clears the debt, and so does granting a goodwill promotion, and neither touches credit the wallet already held.

#Reversals take from the right lots

A refund is a reversal, and it is lot aware:

AuroraMeter.Credits.reverse_lot(org, 5_000_000, "refund:re_1",
  source: %{payment_intent_id: "pi_3abc"}
)

It takes back at most what that PaymentIntent granted, from that payment’s own lots, in spend order, draining what is available before what is reserved. It answers {:error, :exceeds_source} rather than reaching into another payment’s money, and {:error, :no_matching_lots} when the source granted nothing.

The wallet wide reverse/4 is the blunt instrument for when there is no source to point at. Both share the rule that matters:

A reversal never touches a promotional lot. You cannot claw back a refund out of the free credit you gave somebody. If the purchased lots cannot cover the reversal, the shortfall becomes debt, and the reversal still succeeds, because refusing a refund because the money is already spent is not an option.

restore_lot/4 is the other direction, for a dispute that resolves in your favour.

#Expiry takes only its own lot

AuroraMeter.Credits.expire_due(DateTime.utc_now(), limit: 500)
# {:ok, %{examined: 12, expired: 3, ...}}

Expiring a lot moves what is left of that one lot into its expired bucket. It does not touch the next lot, it does not touch money reserved by an open hold, and it does not touch the balance a customer paid for. A promotion that expires on the first of the month takes the promotion.

Under Oban, AuroraMeter.Oban.CreditExpiry runs this every thirty minutes and you do not call it yourself.

#Recurring allowances

A plan can carry credit that arrives every period:

plan :studio do
  price 4_900
  recurring_credits :monthly_allowance,
    amount: 5_000_000,
    rollover: 1_000_000
end

amount is required and is in millionths. category defaults to :promotional, and expires defaults to :period_end, which together are the usual shape: allowance money, gone when the period turns over.

rollover carries at most that much of the previous period’s unused allowance into the next one. It does not accumulate. With a cap of one dollar, two idle periods leave the third holding its own allowance plus one dollar, never plus two. The cap comes from the previous period’s stored policy rather than from your compiled plan, so editing the number does not retroactively change what somebody carried.

Three combinations are refused when your plan module compiles, rather than at three in the morning: a rollover on an allowance that does not expire at the period end, an expiry on a non promotional allowance, and a name or plan id that is not lower snake case.

#One grant per period

AuroraMeter.Credits.Recurrences.run(limit: 500)
# {:ok, %{counts: %{examined: 40, granted: 12, duplicate: 28, ...}}}

Run it from every node, as often as you like. A period gets one grant, held by two independent guards inside the balance row’s lock: a unique row per tenant and period key, inserted on conflict do nothing, and a unique reference on the ledger transaction itself. Either alone would do; both are there because this is money.

A tenant three periods behind gets those three in order, each granted and expired in the same transaction, then its live period. A brand new tenant gets its current period only. There is no back pay and no pro rating.

Under Oban this is AuroraMeter.Oban.RecurringGrants, hourly. Pause it with AuroraMeter.Operations.pause("credits_recurrences:global") and a run writes nothing until you resume.

#Moving an existing wallet onto lots

A wallet created from now on is born on lots. A wallet that existed before you upgraded is not, and it keeps behaving exactly as it did: spendable equals available, promotional_spendable equals promotional, debt and expired are zero, and Lots.list/2 answers with an empty list.

Moving one is a data step, not a schema step:

mix aurora_meter.credits.migrate_lots -r MyApp.Repo

It runs in shadow mode by default: it replays each wallet’s ledger, checks the result against the balance row that is already there, and reports. The balance row is the oracle. A wallet whose replay does not reproduce it exactly is blocked and reported, and is never adjusted to fit, because a migration that quietly rounds a customer’s money to make itself pass is the worst possible outcome. Fix the cause, then run it again for that tenant.

Useful switches: --tenant (repeatable) to do one at a time, --report-only, --retry-blocked, and --no-shadow when you are ready for the real cutover. The task exits non zero if any wallet was blocked, so it composes with a deploy script.

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.