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 order

Spend 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

Types

One movement of value between two buckets of one lot.

One grant's worth of credit.

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

allocation()

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

lot()

@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

allocations(tenant, opts \\ [])

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

for_source(tenant, match)

@spec for_source(term(), map()) :: [lot()]

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, ...}]

get(tenant, id_or_reference)

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

list(tenant, opts \\ [])

@spec list(
  term(),
  keyword()
) :: [lot()]

tenant's lots, in spend order.

Options:

  • :states: which lot states to include. Default [:open]; :all for 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])