Autumn gives you a typed money value and an append-only, double-entry ledger that the framework enforces. A retried charge, a replayed webhook, or a crash halfway through a post cannot double-count money, lose it, or leave the books unbalanced.

The ledger in this page is the money ledger. It is not autumn_web::ledger, which records the history of a #[repository] row.

Migrations

The ledger keeps three framework tables — _autumn_money_accounts, _autumn_money_transactions and _autumn_money_postings. They ship in Autumn's own migration set, so autumn migrate creates them with the rest of the framework schema. There is nothing to add to your own migrations/.

They live in the control database. A sharded app posts money against the control database, not against a shard.

Each account row also stores its balance. The migration 20261029183512_add_money_account_balance adds the column. Run autumn migrate before you deploy the new binary. Without the migration, each ledger call fails with LedgerError::Database. The migration does not read the postings, so it is fast on a large ledger. Old and new binaries can post at the same time. The trigger keeps the balance for both.

The money value

autumn_web::money::Money<C> is an amount in one currency. The currency is a type parameter, so the compiler refuses to add dollars to euros:

Rust
use autumn_web::money::{Money, Usd};

let fee = Money::<Usd>::from_minor(250);   // $2.50
let tip = Money::<Usd>::from_major(1)?;    // $1.00
let total = fee.checked_add(tip)?;
assert_eq!(total.minor(), 350);
assert_eq!(total.to_string(), "3.50 USD");
# Ok::<(), autumn_web::money::MoneyError>(())

The amount is an i64 count of minor units — cents for USD, yen for JPY, fils for BHD. There is no f64 in the value or in any operation on it.

There are no + or - operators, because an operator cannot report an overflow. Use checked_add, checked_sub, checked_neg, checked_abs, checked_mul and try_sum. Each returns Result<_, MoneyError>.

autumn_web::money::CurrencyCode::known() lists the currencies this build knows. Each carries its ISO 4217 code, its minor-unit exponent and a symbol.

When the currency is known only at run time

A ledger row stores its currency as text, so the same value has a runtime-tagged form, AnyMoney. It rejects what Money<C> refuses to compile:

Rust
use autumn_web::money::{AnyMoney, CurrencyCode, MoneyError, Usd};

let dollars = AnyMoney::new(500, CurrencyCode::parse("USD")?);
let euros = AnyMoney::new(500, CurrencyCode::parse("EUR")?);
assert!(matches!(
    dollars.checked_add(euros),
    Err(MoneyError::CurrencyMismatch { .. })
));

let typed = dollars.try_into_typed::<Usd>()?;
assert_eq!(typed.minor(), 500);
# Ok::<(), MoneyError>(())

Rounding and splitting

A conversion that can round always names the rule:

Rust
use autumn_web::money::{Money, Rounding, Usd};
use rust_decimal::Decimal;

let price: Decimal = "1.005".parse().unwrap();
assert_eq!(Money::<Usd>::from_decimal(price, Rounding::HalfUp)?.minor(), 101);
assert_eq!(Money::<Usd>::from_decimal(price, Rounding::HalfEven)?.minor(), 100);

// Or refuse to round.
assert!(Money::<Usd>::from_decimal_exact(price).is_err());
# Ok::<(), autumn_web::money::MoneyError>(())

Rounding covers HalfUp (the default), HalfEven, HalfDown, TowardZero, AwayFromZero, Floor and Ceiling.

allocate splits an amount by weights and loses nothing — the parts always sum back to the whole:

Rust
use autumn_web::money::{Money, Usd};

// A dollar in three.
let parts = Money::<Usd>::from_minor(100).split(3)?;
assert_eq!(parts.iter().map(|p| p.minor()).collect::<Vec<_>>(), vec![34, 33, 33]);

// A marketplace split: 70 / 20 / 10.
let shares = Money::<Usd>::from_minor(9999).allocate(&[70, 20, 10])?;
assert_eq!(Money::<Usd>::try_sum(shares)?.minor(), 9999);
# Ok::<(), autumn_web::money::MoneyError>(())

To render money for a page, hand to_decimal() to number_to_currency.

The ledger

A transaction is a set of postings. A debit is positive and a credit is negative, and an account's balance is the sum of its postings. Build postings with Posting::debit and Posting::credit, and the sign is handled for you.

Open the accounts once — at boot, or the first time you touch one:

Rust
use autumn_web::money::ledger::{self, Account};
use autumn_web::money::Usd;
use autumn_web::prelude::*;

# async fn open(conn: &mut autumn_web::db::RuntimeConnection) -> AutumnResult<()> {
ledger::ensure_account(conn, Account::new("platform:cash", Usd::currency())).await?;
ledger::ensure_account(conn, Account::new("platform:revenue", Usd::currency())).await?;

// An account that must never go below zero.
ledger::ensure_account(
    conn,
    Account::new("platform:float", Usd::currency()).disallow_negative(),
)
.await?;
# Ok(())
# }

The account identifier is yours. Pick a shape you can rebuild from your own rows, such as "platform:cash".

Then post, inside the same Db::tx as the application rows the money justifies:

Rust
use autumn_web::money::ledger::{self, IdempotencyKey, Posting, Transaction};
use autumn_web::money::{Money, Usd};
use autumn_web::prelude::*;
use scoped_futures::ScopedFutureExt as _;

# async fn charge(mut db: Db) -> AutumnResult<()> {
let amount = Money::<Usd>::from_major(25)?;
let postings = vec![
    Posting::debit("platform:cash", amount),
    Posting::credit("platform:revenue", amount),
];
let key = IdempotencyKey::derive("order:9911", &postings);
let transfer = Transaction::new(key, postings).memo("order 9911");

let outcome = db
    .tx(|conn| {
        async move {
            // ... insert or update the order row here ...
            ledger::post(conn, &transfer).await.map_err(AutumnError::from)
        }
        .scope_boxed()
    })
    .await?;

if outcome.is_replayed() {
    // The charge was already posted. Nothing was written now.
}
# Ok(())
# }

Because post takes the connection, the postings and your rows commit together. If the handler fails after the post, both roll back and the idempotency key is free again.

post must run inside a transaction. A call on a bare connection is refused with LedgerError::NotInTransaction. The account locks and the balance check only mean something inside one, and the tables are append-only — a transaction row written without its postings could never be repaired.

On Postgres the account rows are held with SELECT ... FOR UPDATE, so two concurrent posts against one account are serialized. SQLite has no row lock and Db::tx opens a deferred transaction, so two concurrent posts there can leave the second with "database is locked". A SQLite app that posts concurrently must retry the transaction — with backoff, in a bounded loop, and only while the error is the lock one (database is locked / database table is locked); retrying a different error can double-post.

On a cache=shared target the failure mode is harsher. Shared-cache table-lock conflicts return SQLITE_LOCKED without consulting the busy handler at all (this pool does not wire sqlite3_unlock_notify), so the pooled PRAGMA busy_timeout = 5000 does not bound those waits: under real contention all contenders can fail instantly in the same round, with no wait between them. A bare "retry once" is not enough there — use an exponential-backoff retry loop, or prefer a WAL-mode file database for hot write tables. Run each attempt through Db::tx_immediate, which takes the write lock up front with BEGIN IMMEDIATE, so the posting writers cannot all read first and then all fail the lock upgrade together. It is not a completion guarantee — a concurrent reader's table lock can still fail a writer — and it does not make the losers queue: under shared cache a contending BEGIN IMMEDIATE also returns SQLITE_LOCKED without consulting the busy handler, so it still needs the backoff loop. See docs/guide/sqlite-in-production.md for the production SQLite story.

The locks are sorted within one call, not across a transaction. If one transaction posts more than once over overlapping accounts, use Db::tx_with, which retries a deadlock.

A transaction may carry more than two postings. A marketplace split is one transaction:

Rust
# use autumn_web::money::ledger::Posting;
# use autumn_web::money::{Money, Usd};
# fn example() {
# let m = |c| Money::<Usd>::from_minor(c);
let postings = vec![
    Posting::debit("platform:cash", m(10_000)),
    Posting::credit("seller:1", m(7_000)),
    Posting::credit("seller:2", m(2_000)),
    Posting::credit("platform:fees", m(1_000)),
];
# let _ = postings;
# }

Idempotency

Every transaction carries an idempotency key with a UNIQUE index behind it. Submitting the same transaction twice writes one entry: the second call reads the first call's transaction and returns PostOutcome::Replayed.

Two ways to get a key:

  • IdempotencyKey::new("ch_3Ox...") — use an identifier you already have, such as a provider charge id.
  • IdempotencyKey::derive("order:9911", &postings) — idempotent by construction. The key is a hash of the namespace and the money the postings move, so the same charge built again produces the same key with nothing for the caller to remember. The postings are sorted first, so build order does not matter.

The same key for different money is a LedgerError::KeyReuse (HTTP 409), not a silent replay of the wrong result. The memo is not part of the money, so a retry may word it differently and still replay.

Balances

Rust
use autumn_web::money::ledger;
# async fn read(conn: &mut autumn_web::db::RuntimeConnection) -> Result<(), ledger::LedgerError> {
let cash = ledger::balance(conn, "platform:cash").await?;
println!("{cash}"); // 25.00 USD

// Every currency's total across the whole ledger. Each must be zero.
for total in ledger::trial_balance(conn).await? {
    assert!(total.total().is_zero());
}
# Ok(())
# }

trial_balance is the global invariant made queryable. A non-zero total is proof that something wrote around post — run it from a scheduled job or a health check.

The stored balance

Each account row stores its balance in the balance_minor column. post reads the balance from that column, so the cost of a post does not increase with the history. A trigger on the postings table keeps the column. The trigger fires for each INSERT. This includes an INSERT that does not come from post.

NULL in balance_minor means "not known". When the value is NULL, post and balance sum the postings, and post writes the sum back. balance_minor is NULL in these cases:

  • An account that existed before the migration.
  • An account that SQLite replaced with INSERT OR REPLACE.
  • An SQLite account where one step of a transaction went outside i64. The post succeeds, because the net change stays in range.

A stored balance that is not the sum of the postings is drift. A write around the trigger causes it, for example a manual UPDATE of balance_minor, or an INSERT while the trigger is off. balance_drift finds it:

Rust
use autumn_web::money::ledger;
# async fn check(conn: &mut autumn_web::db::RuntimeConnection) -> Result<(), ledger::LedgerError> {
for drift in ledger::balance_drift(conn).await? {
    eprintln!("{}: stored {}, posted {}", drift.account_id(), drift.stored(), drift.posted());
}
# Ok(())
# }

To repair an account, set balance_minor to NULL. The next post to that account sums the postings and stores the result. Like trial_balance, balance_drift reads every posting, so run it from a scheduled job.

A data-only restore (pg_dump --data-only) writes the accounts with their balances, then the postings. The trigger then adds the postings a second time. After such a restore, run UPDATE _autumn_money_accounts SET balance_minor = NULL, then balance_drift. A restore with --disable-triggers needs the same step.

What the framework enforces

RuleWhere it is enforced
Debits equal creditsTransaction::validate, before the first INSERT
One currency per transactionTransaction::validate
A transaction has a debit and a creditTransaction::validate
No posting is zero, and no amount is negativeTransaction::validate
A balance stays inside i64post, before anything is written
A posting's currency matches its accountpost, after the account row is read
The same key posts onceUNIQUE (idempotency_key), with ON CONFLICT DO NOTHING
The same key for different money is refuseda stored request hash
No negative balance where forbiddenthe balance the posting would leave, read before anything is written
The stored balance is the sum of the postingsa trigger on the postings table, on both backends; balance_drift reports a difference
Nothing is rewrittendatabase triggers on both backends, TRUNCATE and SQLite REPLACE included
An account never changes currencya trigger on both backends; only allow_negative and balance_minor change
A cancelled post never half-writesthe postings go in before their transaction row, behind a deferred foreign key

The last two matter most.

post never updates or deletes a row, and a trigger aborts an UPDATE, a DELETE or a TRUNCATE that comes from anywhere else.

An account's currency is fixed once the account exists. It is what post checks a posting against, so a change would relabel every stored minor unit and let the next posting in the new currency pass that check — two currencies in one account. set_allow_negative still works; only the currency is frozen.

INSERT OR REPLACE is the same rewrite in disguise. SQLite settles the conflict by deleting the row that is in the way, and it skips DELETE triggers for that deletion unless PRAGMA recursive_triggers is on. Autumn does not turn that pragma on: it changes the recursion semantics of every trigger the application itself declares, and an ordinary AFTER UPDATE trigger that touches its own row would start failing with too many levels of trigger recursion. The migration uses BEFORE INSERT guards on the row keys instead.

One shape is therefore not covered, and it is worth knowing about: a REPLACE that collides on the idempotency key with a fresh row id, inside a transaction that re-inserts the deleted id before COMMIT, satisfies the deferred foreign key and slips past. Like DROP TABLE, it takes deliberate multi-statement SQL aimed at framework-private tables. These triggers are a guard-rail against operational accidents — a maintenance UPDATE, a stray DELETE, a TRUNCATE — not a boundary against arbitrary SQL.

Postgres needs neither. It has no REPLACE, and it answers the same statement with ON CONFLICT DO UPDATE, which is an UPDATE the trigger above sees.

And post writes the postings before the transaction row they belong to. That looks backwards; it is what keeps a cancelled call from half-writing the books. The foreign key is deferred, so the database checks it at COMMIT — and a post whose future is dropped between the two leaves postings with no parent, which refuses the commit. The alternative order could commit a transaction row with no postings, and append-only tables could never repair that.

The ledger therefore ends up with either nothing or one complete balanced transaction. It does not tell you which: a drop after the last write, while the read-back is in flight, leaves a complete transaction that commits normally. If you race post against a timeout, treat a cancellation as indeterminate and settle it by posting the same idempotency key again — that replays if the money moved and posts if it did not. Not racing it is simpler.

Not in this slice

  • Reconciliation against an external provider.
  • FX conversion between currencies. Money<C> refuses cross-currency arithmetic; it does not convert.
  • A payment-provider client. This is the primitive underneath one. For subscription state mirrored from a provider, see billing.
  • Credit limits beyond the one disallow_negative flag per account.
  • Statements, invoices and accounting exports.