rustlavel-ledger
A double-entry ledger for credits and balances, for the Rustlavel framework: atomic debit and credit, holds that capture or release exactly once, credits that expire by batch, and a history that adds up.
The number that has to be right
An application that sells credits has a number per customer that must be right — not approximately, not after a nightly job, right now and under load.
let ledger = new;
ledger.top_up.await?;
ledger.consume.await?; // Err if 30 is not available
ledger.balance.await? // Balance { total, held, available }
ledger.history.await? // newest first, with running balance
Every top-up and consume is idempotent on its reference. A payment webhook retried three times credits once; a job retried credits once. The unique index on the reference enforces it, so it holds under concurrency.
Double entry
Every movement is a transfer between two accounts and writes two entries, equal and opposite. A top-up comes from system:topup; a spent credit goes to system:consumption; an expired one to system:expiry. So the sum of every balance in the book is zero, always — and ledger.audit() says whether it is. A single-entry design has no such check, and its errors are invisible until a customer complains.
Holds
A job that will cost 50 credits and take a minute must not find the balance gone when it finishes.
let hold = ledger.hold.await?;
// available is down by 50; balance is not
// … the job runs …
ledger.capture.await?; // spend it — once; a second capture is an error, not a charge
// or
ledger.release.await?; // give it back — once
A hold has a lifetime. ledger.sweep() releases the ones nobody came back for, and expires lots past their date; run it on a schedule.
What is atomic, and how
Every operation is one transaction. The debit is
UPDATE ledger_accounts SET balance = balance - ? WHERE account_id = ? AND balance - held >= ?
and the row count is the answer: zero means the funds were not there, and nothing else has happened. That guard is also the lock — the database serialises two transactions writing the same row — so two consumers racing for the last credit cannot both have it. Capture and release are the same shape on the hold's status.
Measured against PostgreSQL, not argued: twenty tasks each spending 10 from a balance of 100 — exactly ten succeed, the balance ends at zero, the book balances. Remove the WHERE guard and twenty succeed. Sixteen tasks capturing one hold — one charge. Eight first-time callers for one owner — one account.
One of those tests found that PostgreSQL aborts the whole transaction on any error, including the unique-index violation an idempotent insert courts on purpose; every such insert is inside a savepoint for that reason.
Tables
create_tables(schema) or the CreateLedgerTables migration: ledger_accounts, ledger_transfers, ledger_entries, ledger_holds, ledger_lots.
Licence
MIT.