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 money value
- Rounding and splitting
- The ledger
- Idempotency
- Balances
- What the framework enforces
- Not in this slice
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:
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:
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:
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:
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:
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:
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:
# 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
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:
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
| Rule | Where it is enforced |
|---|---|
| Debits equal credits | Transaction::validate, before the first INSERT |
| One currency per transaction | Transaction::validate |
| A transaction has a debit and a credit | Transaction::validate |
| No posting is zero, and no amount is negative | Transaction::validate |
A balance stays inside i64 | post, before anything is written |
| A posting's currency matches its account | post, after the account row is read |
| The same key posts once | UNIQUE (idempotency_key), with ON CONFLICT DO NOTHING |
| The same key for different money is refused | a stored request hash |
| No negative balance where forbidden | the balance the posting would leave, read before anything is written |
| The stored balance is the sum of the postings | a trigger on the postings table, on both backends; balance_drift reports a difference |
| Nothing is rewritten | database triggers on both backends, TRUNCATE and SQLite REPLACE included |
| An account never changes currency | a trigger on both backends; only allow_negative and balance_minor change |
A cancelled post never half-writes | the 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_negativeflag per account. - Statements, invoices and accounting exports.