AuroraMeter. Schema. CreditLot
(Aurora Meter v1.0.0)
View Source
One grant's worth of credit, with the five buckets its value can be in.
A lot is created by a grant and never merged with another, so "which grant
paid for this" and "which payment funded this grant" both have an answer. The
five quantities always add up to amount, and the database refuses a row
where they do not:
available: spendable now, unless the lot is pastexpires_atreserved: held by a pending hold, and spoken forconsumed: spent by a debit, a settlement or a repayment of debtreversed: taken back by a refund or a chargebackexpired: destroyed by expiry, and never spendable again
state is a total function of the quantities (reversed when
reversed = amount, else expired when nothing is available or reserved and
something expired, else exhausted when nothing is available or reserved,
else open), and a CHECK constraint keeps every writer honest about it.
Ordering
seq is the order, not granted_at and not inserted_at. The clock that
produces a timestamp is not monotonic and steps backwards on an NTP
correction, a leap second or a VM pause; a Postgres identity column cannot.
granted_at is for display and reporting.
Spend order
Promotional before paid (adjustment sorts with paid), then the earliest
non-null expires_at, then the oldest grant, then seq. Non-expiring lots
sort last within their category. The key is total, so the order a query
happens to return lots in cannot change what a debit spends.
Rows are written only by AuroraMeter.Credits, inside one transaction, under
the wallet's balance row lock.
Summary
Functions
The lot categories.
Builds a changeset for a lot.
The five buckets a lot's value can be in, in the order the conservation check adds them up.
The state a lot with these quantities must be in.
The lot states.
Types
@type category() :: :paid | :promotional | :adjustment
@type state() :: :open | :exhausted | :expired | :reversed
@type t() :: %AuroraMeter.Schema.CreditLot{ __meta__: term(), amount: term(), available: term(), category: term(), consumed: term(), expired: term(), expires_at: term(), grant_transaction_id: term(), granted_at: term(), id: term(), inserted_at: term(), reference: term(), reserved: term(), reversed: term(), seq: term(), source: term(), state: term(), tenant_key: term(), updated_at: term() }
Functions
@spec categories() :: [category()]
The lot categories.
Examples
iex> AuroraMeter.Schema.CreditLot.categories()
[:paid, :promotional, :adjustment]
@spec changeset(t(), map()) :: Ecto.Changeset.t()
Builds a changeset for a lot.
The validations mirror every CHECK constraint the table carries, so a bug in a writer surfaces as a changeset error in a test and as a constraint error in production, and neither can be reached without the other firing.
@spec quantities() :: [atom()]
The five buckets a lot's value can be in, in the order the conservation check adds them up.
Examples
iex> AuroraMeter.Schema.CreditLot.quantities()
[:available, :reserved, :consumed, :reversed, :expired]
The state a lot with these quantities must be in.
It is a function of the quantities alone, so it is order free: a writer cannot leave a stale state behind, and two writers that computed it at different moments cannot disagree. The database holds the same expression as a CHECK constraint.
Examples
iex> AuroraMeter.Schema.CreditLot.state_for(%{amount: 10, available: 10, reserved: 0, consumed: 0, reversed: 0, expired: 0})
:open
iex> AuroraMeter.Schema.CreditLot.state_for(%{amount: 10, available: 0, reserved: 0, consumed: 10, reversed: 0, expired: 0})
:exhausted
iex> AuroraMeter.Schema.CreditLot.state_for(%{amount: 10, available: 0, reserved: 0, consumed: 4, reversed: 0, expired: 6})
:expired
iex> AuroraMeter.Schema.CreditLot.state_for(%{amount: 10, available: 0, reserved: 0, consumed: 0, reversed: 10, expired: 0})
:reversed
@spec states() :: [state()]
The lot states.
Examples
iex> :exhausted in AuroraMeter.Schema.CreditLot.states()
true