The mechanics both ledgered balances share: Gamend.Economy (currency in a
wallet) and Gamend.Inventory (quantity of an item).
The two contexts are near-isomorphic — the same run_change, apply_delta,
record_ledger, idem_applied? set, differing only in whether the noun is a
currency or an item. Most of that difference is real: a wallet and an item
stack are different tables with different constraints, and folding them into
one generic ledger would trade a little duplication for a lot of indirection.
What is not different is the transaction shape around them, which is what lives here:
- apply the delta and write the ledger row in one transaction;
- treat a duplicate
idempotency_keyas a replay rather than an error, because losing that race means the other request already applied it — so the caller should be told the resulting balance, not a failure; - roll back anything else.
Getting that wrong is a double-spend or a phantom grant, so it is worth having exactly one copy of it.
Summary
Functions
Runs apply_fun and record_fun in one transaction and normalises the
result.
A page of ledger entries from query, newest first, with the user loaded —
the admin listing both ledgers expose.
Classifies a failed ledger insert: a duplicate idempotency key is a replay, anything else is a genuine error. Rolls back either way.
Functions
@spec change( (-> {:ok, integer()} | {:error, term()}), (integer() -> term()), (-> integer()) ) :: {:ok, integer()} | {:error, term()}
Runs apply_fun and record_fun in one transaction and normalises the
result.
apply_fun returns {:ok, new_total} or {:error, reason}. record_fun
receives the new total and writes the ledger row; it is expected to
Repo.rollback(:idempotent_replay) when the idempotency key already exists.
replay_fun is called only on that replay, to read back the total the
winning request produced.
@spec list_entries(Ecto.Queryable.t(), keyword()) :: [struct()]
A page of ledger entries from query, newest first, with the user loaded —
the admin listing both ledgers expose.
@spec rollback_insert_error(Ecto.Changeset.t(), atom()) :: no_return()
Classifies a failed ledger insert: a duplicate idempotency key is a replay, anything else is a genuine error. Rolls back either way.