rustlavel-ledger 0.8.2

A double-entry ledger for credits and balances: atomic debit and credit, holds that capture or release, credits that expire by batch, and a history that adds up
Documentation

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 = Ledger::new(db.clone(), "credits");

ledger.top_up("user:42", 500, "payment_abc", Some(Duration::from_secs(60 * 86400))).await?;
ledger.consume("user:42", 30, "job_7").await?;          // Err if 30 is not available
ledger.balance("user:42").await?                          // Balance { total, held, available }
ledger.history("user:42", 50).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("user:42", 50, "job_7", Duration::from_secs(900)).await?;
// available is down by 50; balance is not
// … the job runs …
ledger.capture(&hold.id).await?;   // spend it — once; a second capture is an error, not a charge
// or
ledger.release(&hold.id).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.