AuroraMeter. Credits. Lots
(Aurora Meter v1.0.0)
View Source
Reads a tenant's credit lots and the allocation trail that moved them.
A lot is one grant's worth of credit, with the five buckets its value can be in. A grant creates exactly one and nothing ever merges two, so "which grant paid for this" and "which payment funded this grant" both have an answer. An allocation is one movement of value between two buckets of one lot, naming the ledger entry that caused it.
This module is how a human answers "where did the money go" without reading
aurora_meter_credit_transactions by hand, and it is the only supported way
for anything outside AuroraMeter.Credits to read lot data.
alias AuroraMeter.Credits.Lots
Lots.list(org) # open lots, in spend order
Lots.list(org, states: :all) # including exhausted and reversed
Lots.get(org, "stripe:pi_123") # by grant reference, or by lot id
Lots.for_source(org, %{payment_intent_id: "pi_123"})
Lots.allocations(org, lot_id: lot.id) # what moved, in orderSpend order
list/2 returns lots in the order the next debit will consume them:
promotional before paid (adjustment sorts with paid), then the earliest
non-null expires_at, then the oldest grant, then the ordering key. A reader
therefore sees what the next spend will take, not merely what exists. The
order is fixed by the engine and no option changes it: a caller that could
choose the spend order could choose which of two customers' money is spent
first, which is not a display concern.
order: :granted_at re-sorts the same lots for a human reading a history. It
changes what the list looks like, never what a debit does.
A snapshot, not a transaction
Everything here is read only and takes no lock, so a lot's quantities may
differ a microsecond after it is returned. That is the right trade for a
support screen and the wrong one for a decision: the only read that is
consistent with a write is one made inside the transaction that writes, and
AuroraMeter.Credits' own operations make it under the balance row's lock.
Never compute an amount to write from a figure read here.
Legacy wallets
A wallet that has not been cut over to lots (lots_enabled_at IS NULL, which
is every wallet until mix aurora_meter.credits.migrate_lots runs) has no
lots at all, and every function here answers [] or nil for it. That is
the honest answer rather than a synthesised one: the legacy ledger does not
record which grant a spend came out of, so a lot list invented from it would
be a guess presented as provenance.
Summary
Functions
tenant's allocation trail, oldest first.
The lots whose source matches every key of match, in spend order.
One lot of tenant's, by lot id or by the reference of the grant that created
it, or nil.
tenant's lots, in spend order.
Types
@type allocation() :: %{ id: Ecto.UUID.t(), lot_id: Ecto.UUID.t(), transaction_id: Ecto.UUID.t(), kind: AuroraMeter.Schema.CreditAllocation.kind(), from_bucket: AuroraMeter.Schema.CreditAllocation.bucket(), to_bucket: AuroraMeter.Schema.CreditAllocation.bucket(), amount: pos_integer(), seq: integer(), inserted_at: DateTime.t() }
One movement of value between two buckets of one lot.
from_bucket and to_bucket are not redundant with kind: a consume can
come out of available (a debit) or out of reserved (a settlement against
its own hold), and a reverse out of any of three. Folding a lot's
allocations back into its five quantities needs the source (finding X249).
@type lot() :: %{ id: Ecto.UUID.t(), reference: String.t(), category: :paid | :promotional | :adjustment, amount: non_neg_integer(), available: non_neg_integer(), reserved: non_neg_integer(), consumed: non_neg_integer(), reversed: non_neg_integer(), expired: non_neg_integer(), state: :open | :exhausted | :expired | :reversed, granted_at: DateTime.t(), expires_at: DateTime.t() | nil, grant_transaction_id: Ecto.UUID.t(), seq: integer(), source: map() }
One grant's worth of credit.
available + reserved + consumed + reversed + expired == amount, always, and
the database refuses a row where it does not. seq is the ordering key and
granted_at is for display: a timestamp comes from a wall clock and orders
nothing.
Functions
@spec allocations( term(), keyword() ) :: [allocation()]
tenant's allocation trail, oldest first.
Options:
:lot_id: only this lot's movements.:transaction_id: only the movements one ledger entry caused.:reference: only the movements the entry with this reference caused. Matches a reversal written under either kind, so a refund reads the same whether it was written before or after schema version 9.:limit: default 100, capped at 500.:cursor: the last allocation of the previous page, or its id.
Examples
AuroraMeter.Credits.Lots.allocations(org, lot_id: lot.id)
#=> [%{kind: :reserve, from_bucket: :available, to_bucket: :reserved, amount: 500_000, ...}]
The lots whose source matches every key of match, in spend order.
This is how a refund finds the credit a payment bought. AuroraMeter.Credits
stamps :source from grant/3 onto the lot the grant creates, so a host that
passed source: %{payment_intent_id: "pi_123"} can ask for exactly those
lots back and nothing else.
Only [:payment_intent_id, :recurrence_key] are supported in this release, and any other
key raises ArgumentError. Silently returning everything for a key the
matcher does not understand would turn a typo into a refund against the wrong
customer's credit.
Examples
AuroraMeter.Credits.Lots.for_source(org, %{payment_intent_id: "pi_123"})
#=> [%{reference: "stripe:pi_123", category: :paid, ...}]
@spec get(term(), Ecto.UUID.t() | String.t()) :: lot() | nil
One lot of tenant's, by lot id or by the reference of the grant that created
it, or nil.
The reference form is the one support has: a payment id, an invoice number,
whatever the host passed as :reference to AuroraMeter.Credits.grant/3. A
reference is unique per kind across the ledger, so it names at most one grant
and therefore at most one lot.
Examples
AuroraMeter.Credits.Lots.get(org, "stripe:pi_123")
AuroraMeter.Credits.Lots.get(org, "0b5f...uuid...")
tenant's lots, in spend order.
Options:
:states: which lot states to include. Default[:open];:allfor every state; or a list drawn from[:open, :exhausted, :expired, :reversed].:categories: restrict to these categories, from[:paid, :promotional, :adjustment]. Every category by default.:limit: default 100, capped at 500.:order::spend(default) or:granted_at.:cursor: the last lot of the previous page (the map this function returned, or its id). Paging never skips a lot and never returns one twice, because both orders end in the ordering key, which is unique.
Examples
AuroraMeter.Credits.Lots.list(org)
#=> [%{reference: "promo:welcome", category: :promotional, available: 5_000_000, ...}]
AuroraMeter.Credits.Lots.list(org, states: :all, categories: [:paid])