pub struct Journal<const P: u8 = 2> { /* private fields */ }Expand description
A ledger kept in memory and written synchronously: the rule core over an in-memory state.
Every write builds an operation, lets the rule core decide it against the
journal’s current state, and applies the outcome completely or not at
all. A refused or already-applied operation leaves the journal
byte-identical, whatever its history. Ledger<S> has the same methods,
async, over a store.
Master data is written by the journal’s actor (as_actor,
system by default). Names (account paths, period ids) are taken as text
and validated by the core, so a malformed one is a
Rejection, never a panic.
The scale P is fixed per journal: Journal::new gives P = 2;
other scales come from with_precision.
Implementations§
Source§impl<const P: u8> Journal<P>
impl<const P: u8> Journal<P>
Sourcepub fn with_precision(ledger: LedgerId) -> Self
pub fn with_precision(ledger: LedgerId) -> Self
A new journal of scale P (at most 18 decimal places).
use doubleentry::{Journal, LedgerId};
let books = Journal::<4>::with_precision(LedgerId::new("example.com/fx")?);
assert_eq!(books.head().size, 1); // the genesis recordSourcepub fn from_log<'a>(
ledger: &LedgerId,
leaves: impl IntoIterator<Item = ReplayLeaf<'a>>,
) -> Result<Self, Error>
pub fn from_log<'a>( ledger: &LedgerId, leaves: impl IntoIterator<Item = ReplayLeaf<'a>>, ) -> Result<Self, Error>
A journal rebuilt from its leaves and their side data, replaying each
through the rule core (so the result has passed the same checks as an
audit).
use doubleentry::{AccountKind, Journal, LedgerId};
use doubleentry::date;
let mut books = Journal::new(LedgerId::new("example.com/acme")?);
books.open("Assets:Cash", AccountKind::Asset, date!("2026-01-01"))?;
let restored: Journal = Journal::from_log(books.id(), books.leaves())?;
assert_eq!(restored.head(), books.head());§Errors
Error::Integrity at the first leaf the core would not have
written, or when the log has no genesis.
Sourcepub fn with_salt_source(
self,
source: impl Fn() -> MemoSalt + Send + Sync + 'static,
) -> Self
pub fn with_salt_source( self, source: impl Fn() -> MemoSalt + Send + Sync + 'static, ) -> Self
The same journal, drawing memo salts from source instead of the
operating system — for deterministic tests and simulations. A salt
must be unpredictable for memo erasure to protect anything.
Sourcepub fn as_actor(self, actor: impl AsRef<str>) -> Self
pub fn as_actor(self, actor: impl AsRef<str>) -> Self
The same journal, recording actor on master data from now on. An
invalid actor is refused by the next master-data write.
Sourcepub fn declare_currency(
&mut self,
code: Currency,
exponent: u8,
effective: Date,
) -> Result<(), Error>
pub fn declare_currency( &mut self, code: Currency, exponent: u8, effective: Date, ) -> Result<(), Error>
Declares a currency with its number of decimal places (at most P),
or raises it. Declared currencies are the only ones entries may use.
§Errors
Error::Rejected: 107, 123, 309, 310, 424.
Sourcepub fn open(
&mut self,
path: impl AsRef<str>,
kind: AccountKind,
on: Date,
) -> Result<AccountHandle, Error>
pub fn open( &mut self, path: impl AsRef<str>, kind: AccountKind, on: Date, ) -> Result<AccountHandle, Error>
Opens an account at path of kind from on: no class, no end, no
limit. Returns its handle; opening the same account again returns the
same handle and writes nothing.
§Errors
Error::Rejected: 103, 107, 309, 420, 421, 428.
Sourcepub fn register(&mut self, spec: AccountSpec) -> Result<AccountHandle, Error>
pub fn register(&mut self, spec: AccountSpec) -> Result<AccountHandle, Error>
Registers an account with its full master data.
§Errors
Error::Rejected: 103, 107, 122, 309, 420, 421, 428.
Sourcepub fn set_limit<A>(
&mut self,
account: A,
limit: BalanceLimit,
effective: Date,
) -> Result<(), Error>
pub fn set_limit<A>( &mut self, account: A, limit: BalanceLimit, effective: Date, ) -> Result<(), Error>
Sets an account’s balance limit from effective on. The latest limit
applies to every entry, whatever its booking date.
§Errors
Error::Rejected: 103, 107, 302, 309, 310.
Sourcepub fn set_class<A>(
&mut self,
account: A,
class: Option<&str>,
effective: Date,
) -> Result<(), Error>
pub fn set_class<A>( &mut self, account: A, class: Option<&str>, effective: Date, ) -> Result<(), Error>
Sets (or removes) an account’s class; refused once it has postings.
§Errors
Error::Rejected: 103, 107, 302, 309, 310, 422.
Sourcepub fn close_account<A>(
&mut self,
account: A,
last_day: Date,
) -> Result<(), Error>
pub fn close_account<A>( &mut self, account: A, last_day: Date, ) -> Result<(), Error>
Closes an account: last_day is the last day it can be posted to.
§Errors
Error::Rejected: 103, 107, 122, 302, 309, 310, 423.
Sourcepub fn set_policy(
&mut self,
policy: LedgerPolicy,
effective: Date,
) -> Result<(), Error>
pub fn set_policy( &mut self, policy: LedgerPolicy, effective: Date, ) -> Result<(), Error>
Sourcepub fn define_period(
&mut self,
id: impl AsRef<str>,
start: Date,
end: Date,
) -> Result<(), Error>
pub fn define_period( &mut self, id: impl AsRef<str>, start: Date, end: Date, ) -> Result<(), Error>
Defines the next period of the calendar: the first may start on any day, every later one the day after the last one ends.
§Errors
Error::Rejected: 107, 121, 425, 426.
Sourcepub fn define_months(
&mut self,
year: i32,
months: RangeInclusive<u8>,
) -> Result<(), Error>
pub fn define_months( &mut self, year: i32, months: RangeInclusive<u8>, ) -> Result<(), Error>
Defines one period per month of year, ids YYYY-MM, back to back —
sugar over define_period. Each month is one
period record; months already defined identically are skipped, so a
retry after a failure completes the calendar.
use doubleentry::{Journal, LedgerId};
let mut books = Journal::new(LedgerId::new("example.com/acme")?);
books.define_months(2026, 1..=12)?;
books.define_months(2026, 1..=12)?; // already defined: writes nothing
assert_eq!(books.head().size, 13);§Errors
Error::Rejected: 107 for a month outside 1..=12 (or a year whose
days cannot be represented), and those of
define_period.
Sourcepub fn seal_period(
&mut self,
id: impl AsRef<str>,
) -> Result<SealReceipt<P>, Error>
pub fn seal_period( &mut self, id: impl AsRef<str>, ) -> Result<SealReceipt<P>, Error>
Seals a period: its seal record commits each account’s balance through the period’s end. Sealing a sealed period returns its receipt and writes nothing.
§Errors
Error::Rejected: 107, 111, 311, 427, 500.
Sourcepub fn seal_through(
&mut self,
id: impl AsRef<str>,
) -> Result<Vec<SealReceipt<P>>, Error>
pub fn seal_through( &mut self, id: impl AsRef<str>, ) -> Result<Vec<SealReceipt<P>>, Error>
Seals every period up to and including id that has no seal yet,
earliest first — one seal_period each — and
returns the receipts of the periods it sealed, id’s last (only
id’s when nothing earlier was open). Each seal is its own write: a
failure keeps the seals before it, and calling again continues.
use doubleentry::prelude::*;
let mut books = Journal::new(LedgerId::new("example.com/acme")?);
books.define_months(2026, 1..=12)?;
// March alone is refused: January and February are open.
let refused = books.seal_period("2026-03").unwrap_err();
assert_eq!(refused.code(), Some(427));
assert!(refused.to_string().contains("seal from 2026-01 on"));
let sealed = books.seal_through("2026-03")?;
let periods: Vec<String> = sealed.iter().map(|r| r.period.to_string()).collect();
assert_eq!(periods, ["2026-01", "2026-02", "2026-03"]);
assert_eq!(books.seal_through("2026-03")?.len(), 1); // sealed: its receipt§Errors
Those of seal_period but 427.
Sourcepub fn close_year<A>(
&self,
key: &str,
on: Date,
equity: A,
) -> Result<Vec<Entry<P>>, Error>
pub fn close_year<A>( &self, key: &str, on: Date, equity: A, ) -> Result<Vec<Entry<P>>, Error>
The year-end closing drafts: every income and expense slice through
on zeroed into equity, chunked to the posting and leaf limits
under the keys {key}/1, {key}/2, … Record them in order with
record. Nothing to close gives no drafts.
§Errors
Error::Rejected: 100, 103, 111, 113, 302, 303, 429, 430.
Sourcepub fn record(&mut self, entry: Entry<P>) -> Result<Recorded, Error>
pub fn record(&mut self, entry: Entry<P>) -> Result<Recorded, Error>
Records an entry.
A retry of a recorded draft returns the original receipt with
replayed == true and writes nothing — even after its period was
sealed or its account closed. “The same draft” means the same postings
(an account named by path or by handle is the same account), memo
text and capture time. A memo without a salt
gets one from the journal’s salt source; retries are unaffected.
§Errors
Error::Rejected with the rejection of the highest precedence;
Error::Backend when the operating system has no randomness for a
memo salt.
Sourcepub fn record_batch(
&mut self,
drafts: impl IntoIterator<Item = Entry<P>>,
) -> Result<Vec<Recorded>, Error>
pub fn record_batch( &mut self, drafts: impl IntoIterator<Item = Entry<P>>, ) -> Result<Vec<Recorded>, Error>
Records drafts atomically, in order: each draft is checked against
the state the drafts before it leave, and either every draft is
recorded or none is. Returns one receipt per draft.
A draft already recorded — by an earlier write, or earlier in the
same batch — replays as record would, so recording
an identical batch again returns the original receipts, each with
replayed == true, and writes nothing.
use doubleentry::prelude::*;
let mut books = Journal::new(LedgerId::new("example.com/payroll")?);
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
books.define_months(2026, 3..=3)?;
books.open("Assets:Bank", AccountKind::Asset, date!("2026-01-01"))?;
books.open("Expenses:Wages", AccountKind::Expense, date!("2026-01-01"))?;
books.set_limit("Assets:Bank", BalanceLimit::NoCreditBalance, date!("2026-01-01"))?;
let pay = |key: &str, eur| {
Entry::new(key, date!("2026-03-31"))
.debit("Expenses:Wages", eur, EUR)
.credit("Assets:Bank", eur, EUR)
};
let fund = Entry::new("fund", date!("2026-03-01"))
.debit("Assets:Bank", amount!("3000.00"), EUR)
.credit("Expenses:Wages", amount!("3000.00"), EUR);
// The second wage would overdraw the bank: nothing of the batch is recorded.
let run = [fund.clone(), pay("alice", amount!("2000.00")), pay("bob", amount!("1500.00"))];
match books.record_batch(run) {
Err(Error::BatchRejected { index: 2, rejection: Rejection::LimitExceeded { .. } }) => {}
other => panic!("expected draft 2 refused, got {other:?}"),
}
assert!(books.entry_by_key("fund").is_none());
let run = [fund, pay("alice", amount!("2000.00")), pay("bob", amount!("1000.00"))];
let first = books.record_batch(run.clone())?;
let again = books.record_batch(run)?; // a retry of the whole batch
assert!(again.iter().zip(&first).all(|(a, f)| a.replayed && a.id == f.id));§Errors
Error::BatchRejected naming the first refused draft and its
rejection; Error::Backend when the operating system has no
randomness for a memo salt.
Sourcepub fn reversal(
&self,
id: EntryId,
key: &str,
on: Date,
) -> Result<Entry<P>, Error>
pub fn reversal( &self, id: EntryId, key: &str, on: Date, ) -> Result<Entry<P>, Error>
The draft that reverses entry id: its postings mirrored (sides
swapped), booked on on under key. It checks no rule; record it
with record.
§Errors
Error::Query when no entry has the id.
Sourcepub fn clear(
&mut self,
key: &str,
debit: PostingRef,
credit: PostingRef,
amount: Amount<P>,
on: Date,
) -> Result<u64, Error>
pub fn clear( &mut self, key: &str, debit: PostingRef, credit: PostingRef, amount: Amount<P>, on: Date, ) -> Result<u64, Error>
Clears amount of a debit posting against a credit posting of the
same balance; returns the clearing record’s position (replays
included).
§Errors
Error::Rejected: 100, 120, 200, 300, 301, 410–415, 419.
Sourcepub fn reset_clearing(
&mut self,
key: &str,
clearing: u64,
on: Date,
) -> Result<u64, Error>
pub fn reset_clearing( &mut self, key: &str, clearing: u64, on: Date, ) -> Result<u64, Error>
Resets the clearing applied at position clearing; returns the reset
record’s position.
§Errors
Error::Rejected: 100, 200, 300, 301, 416–418.
Sourcepub fn erase_memo(&mut self, id: EntryId) -> Result<(), Error>
pub fn erase_memo(&mut self, id: EntryId) -> Result<(), Error>
Erases entry id’s memo text and salt (GDPR Art. 17): no leaf, hash
or proof changes. The stored fingerprint of its draft goes too, so a
later submission of its key is a key conflict and a guessed memo
cannot be confirmed. Erasing again, or an entry without a memo,
changes nothing. When to erase is the caller’s decision (the end of
the retention period).
§Errors
Error::Query when no entry has the id.
Sourcepub fn entry(&self, id: EntryId) -> Option<EntryRecord<P>>
pub fn entry(&self, id: EntryId) -> Option<EntryRecord<P>>
The entry with id id, as recorded.
let sale = books.record(
Entry::new("sale-1", date!("2026-03-15"))
.debit("Assets:Cash", amount!("10.00"), EUR)
.credit("Income:Sales", amount!("10.00"), EUR)
.memo("for the customer"),
)?;
assert_eq!(books.entry(sale.id).map(|entry| entry.parts().postings.len()), Some(2));Sourcepub fn entry_by_key(&self, key: impl AsRef<str>) -> Option<EntryRecord<P>>
pub fn entry_by_key(&self, key: impl AsRef<str>) -> Option<EntryRecord<P>>
The entry recorded under idempotency key key.
use doubleentry::{AccountKind, Entry, Journal, LedgerId, amount, date, currency::EUR};
let mut books = Journal::new(LedgerId::new("example.com/acme")?);
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
books.open("Assets:Cash", AccountKind::Asset, date!("2026-01-01"))?;
books.open("Income:Sales", AccountKind::Income, date!("2026-01-01"))?;
books.define_months(2026, 3..=3)?;
let recorded = books.record(
Entry::new("invoice-1", date!("2026-03-15"))
.debit("Assets:Cash", amount!("10.00"), EUR)
.credit("Income:Sales", amount!("10.00"), EUR),
)?;
assert_eq!(books.entry_by_key("invoice-1"), books.entry(recorded.id));
assert_eq!(books.entry_by_key("invoice-2"), None);Sourcepub fn account<A>(&self, account: A) -> Option<Account>
pub fn account<A>(&self, account: A) -> Option<Account>
The master data in force of an account, by path or handle.
use doubleentry::{AccountKind, BalanceLimit, Journal, LedgerId, date};
let mut books = Journal::new(LedgerId::new("example.com/acme")?);
let wallet = books.open("Liabilities:Wallet", AccountKind::Liability, date!("2026-01-01"))?;
books.set_limit(wallet, BalanceLimit::NoDebitBalance, date!("2026-01-01"))?;
let account = books.account("Liabilities:Wallet").expect("opened");
assert_eq!((account.handle, account.limit), (wallet, BalanceLimit::NoDebitBalance));
assert!(books.account("Liabilities:Other").is_none());Sourcepub fn memo(&self, id: EntryId) -> Option<String>
pub fn memo(&self, id: EntryId) -> Option<String>
The memo text of entry id; None without a memo, once erased, or
for an unknown entry.
let sale = books.record(
Entry::new("sale-1", date!("2026-03-15"))
.debit("Assets:Cash", amount!("10.00"), EUR)
.credit("Income:Sales", amount!("10.00"), EUR)
.memo("for the customer"),
)?;
assert_eq!(books.memo(sale.id).as_deref(), Some("for the customer"));
books.erase_memo(sale.id)?;
assert_eq!(books.memo(sale.id), None);Sourcepub fn exponent(&self, currency: Currency) -> Option<u8>
pub fn exponent(&self, currency: Currency) -> Option<u8>
The declared exponent of currency — the decimal places its amounts
have and reports print (0 for JPY) — or None while undeclared.
Write an amount with it through
Amount::display_at.
use doubleentry::prelude::*;
let mut books = Journal::new(LedgerId::new("example.com/acme")?);
books.declare_currency(JPY, 0, date!("2026-01-01"))?;
assert_eq!(books.exponent(JPY), Some(0));
assert_eq!(books.exponent(EUR), None);Sourcepub fn audit(&self) -> Result<AuditReport, Error>
pub fn audit(&self) -> Result<AuditReport, Error>
Replays the whole log through the rule core and compares the result with the journal’s state.
§Errors
Error::Integrity at the first difference.
Sourcepub fn head_at(&self, size: u64) -> Result<TreeHead, Error>
pub fn head_at(&self, size: u64) -> Result<TreeHead, Error>
The tree head the log had at size records: what a proof at that
size verifies against.
let sale = books.record(
Entry::new("sale-1", date!("2026-03-15"))
.debit("Assets:Cash", amount!("10.00"), EUR)
.credit("Income:Sales", amount!("10.00"), EUR)
.memo("for the customer"),
)?;
let then = books.head();
books.define_months(2026, 4..=4)?;
assert_eq!(books.head_at(then.size)?, then);§Errors
Error::Query for a size beyond the head.
Sourcepub fn prove_entry(&self, id: EntryId) -> Result<InclusionProof, Error>
pub fn prove_entry(&self, id: EntryId) -> Result<InclusionProof, Error>
Sourcepub fn prove_entry_at(
&self,
id: EntryId,
size: u64,
) -> Result<InclusionProof, Error>
pub fn prove_entry_at( &self, id: EntryId, size: u64, ) -> Result<InclusionProof, Error>
The proof that entry id is in the log as it was at size records,
against head_at(size).
use doubleentry::{AccountKind, Entry, Journal, LedgerId, amount, date, currency::EUR};
let mut books = Journal::new(LedgerId::new("example.com/acme")?);
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
books.open("Assets:Cash", AccountKind::Asset, date!("2026-01-01"))?;
books.open("Income:Sales", AccountKind::Income, date!("2026-01-01"))?;
books.define_months(2026, 3..=3)?;
let sale = books.record(
Entry::new("sale-1", date!("2026-03-15"))
.debit("Assets:Cash", amount!("10.00"), EUR)
.credit("Income:Sales", amount!("10.00"), EUR),
)?;
let then = books.head();
books.define_months(2026, 4..=4)?;
let proof = books.prove_entry_at(sale.id, then.size)?;
assert_eq!(books.head_at(then.size)?, then);
assert_eq!((proof.index, proof.size), (sale.position, then.size));
// The old head is a prefix of the new one.
books.prove_consistency(then.size, books.head().size)?.verify(&then, &books.head())?;§Errors
Error::Query when no entry had the id at that size, or for a size
beyond the head.
Sourcepub fn prove_consistency(
&self,
old_size: u64,
new_size: u64,
) -> Result<ConsistencyProof, Error>
pub fn prove_consistency( &self, old_size: u64, new_size: u64, ) -> Result<ConsistencyProof, Error>
The proof that the log at new_size records extends the log at
old_size: nothing recorded by old_size was changed or removed.
let sale = books.record(
Entry::new("sale-1", date!("2026-03-15"))
.debit("Assets:Cash", amount!("10.00"), EUR)
.credit("Income:Sales", amount!("10.00"), EUR)
.memo("for the customer"),
)?;
let then = books.head();
books.define_months(2026, 4..=4)?;
let proof = books.prove_consistency(then.size, books.head().size)?;
assert!(proof.verify(&then, &books.head()).is_ok());§Errors
Error::Query for an old size of 0 or above the new one, or a new
size beyond the head.
Sourcepub fn leaves(&self) -> impl Iterator<Item = ReplayLeaf<'_>>
pub fn leaves(&self) -> impl Iterator<Item = ReplayLeaf<'_>>
Every leaf with its side data (memo text and salt, draft fingerprint),
in position order — what from_log takes.
Sourcepub fn hold(&self, id: EntryId) -> Option<HoldState<P>>
pub fn hold(&self, id: EntryId) -> Option<HoldState<P>>
The hold opened by entry id and where it stands: open, or posted,
voided or expired by the entry that resolved it. None when id is
not a hold.
use doubleentry::prelude::*;
use doubleentry::{HoldStatus, PostAmount};
let mut books = Journal::new(LedgerId::new("example.com/payments")?);
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
books.open("Liabilities:Wallets:alice", AccountKind::Liability, date!("2026-01-01"))?;
books.open("Liabilities:Card:clearing", AccountKind::Liability, date!("2026-01-01"))?;
books.define_months(2026, 11..=11)?;
let hold = books.record(
Entry::open_hold("auth-4711", date!("2026-11-20"))
.debit("Liabilities:Wallets:alice", amount!("100.00"), EUR)
.credit("Liabilities:Card:clearing", amount!("100.00"), EUR),
)?;
let void = books.record(Entry::void_hold("void-4711", date!("2026-11-21"), hold.id))?;
let state = books.hold(hold.id).ok_or("no hold")?;
assert_eq!(state.status, HoldStatus::Voided { by: void.id });
assert!(books.hold(void.id).is_none()); // a resolution is no holdSourcepub fn holds_due(
&self,
as_of: Date,
page: PageRead<HoldCursor>,
) -> Page<HoldState<P>, HoldCursor>
pub fn holds_due( &self, as_of: Date, page: PageRead<HoldCursor>, ) -> Page<HoldState<P>, HoldCursor>
The open holds that may be expired on as_of — their expiry date is
on or before it — ordered by (expiry date, position), one page at a
time: what a scheduler books an Entry::expire_hold for. Nothing
expires by itself, and no clock is read. A limit outside
1..=1000 is clamped into it.
use doubleentry::prelude::*;
use doubleentry::PageRead;
let mut books = Journal::new(LedgerId::new("example.com/payments")?);
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
books.open("Liabilities:Wallets:alice", AccountKind::Liability, date!("2026-01-01"))?;
books.open("Liabilities:Card:clearing", AccountKind::Liability, date!("2026-01-01"))?;
books.define_months(2026, 11..=12)?;
let hold = books.record(
Entry::open_hold("auth-4711", date!("2026-11-20"))
.expires(date!("2026-11-30"))
.debit("Liabilities:Wallets:alice", amount!("100.00"), EUR)
.credit("Liabilities:Card:clearing", amount!("100.00"), EUR),
)?;
assert!(books.holds_due(date!("2026-11-29"), PageRead::first(50)).items.is_empty());
let due = books.holds_due(date!("2026-11-30"), PageRead::first(50));
assert_eq!(due.items.iter().map(|h| h.id).collect::<Vec<_>>(), [hold.id]);
books.record(Entry::expire_hold("expire-4711", date!("2026-11-30"), hold.id))?;
assert!(books.holds_due(date!("2026-12-31"), PageRead::first(50)).items.is_empty());Sourcepub fn balance<A>(
&self,
account: A,
currency: Currency,
query: &BalanceQuery,
) -> Result<Balance<P>, Error>
pub fn balance<A>( &self, account: A, currency: Currency, query: &BalanceQuery, ) -> Result<Balance<P>, Error>
The balance of account in currency: the gross debit, credit and
count of the postings query selects.
use doubleentry::prelude::*;
let mut books = Journal::new(LedgerId::new("example.com/acme")?);
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
books.open("Assets:Bank", AccountKind::Asset, date!("2026-01-01"))?;
books.open("Income:Sales", AccountKind::Income, date!("2026-01-01"))?;
books.define_months(2026, 3..=3)?;
let sale = books.record(
Entry::new("sale-1", date!("2026-03-15"))
.debit("Assets:Bank", amount!("100.00"), EUR)
.credit("Income:Sales", amount!("100.00"), EUR),
)?;
assert_eq!(books.balance("Assets:Bank", EUR, &BalanceQuery::all())?.net(), amount!("100.00"));
let before = BalanceQuery::through_position(sale.position - 1);
assert!(books.balance("Assets:Bank", EUR, &before)?.is_zero());§Errors
Error::Query for an invalid query or an account not registered
in the prefix read; Error::Integrity for a pinned head this log
does not have.
Sourcepub fn trial_balance(
&self,
query: &BalanceQuery,
) -> Result<TrialBalance<P>, Error>
pub fn trial_balance( &self, query: &BalanceQuery, ) -> Result<TrialBalance<P>, Error>
The trial balance: one row per (account, currency) with a posting
query selects.
use doubleentry::prelude::*;
let mut books = Journal::new(LedgerId::new("example.com/acme")?);
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
books.open("Assets:Bank", AccountKind::Asset, date!("2026-01-01"))?;
books.open("Income:Sales", AccountKind::Income, date!("2026-01-01"))?;
books.define_months(2026, 3..=3)?;
books.record(
Entry::new("sale-1", date!("2026-03-15"))
.debit("Assets:Bank", amount!("100.00"), EUR)
.credit("Income:Sales", amount!("100.00"), EUR),
)?;
let trial = books.trial_balance(&BalanceQuery::through(date!("2026-03-31")))?;
assert!(trial.is_balanced() && trial.rows().len() == 2);§Errors
As balance, and Error::Integrity for a stored
account record that does not decode.
Sourcepub fn statement<A>(
&self,
account: A,
currency: Currency,
query: &BalanceQuery,
) -> Result<Statement<P>, Error>
pub fn statement<A>( &self, account: A, currency: Currency, query: &BalanceQuery, ) -> Result<Statement<P>, Error>
The statement of account in currency over query’s window: the
opening balance, every line in date order with a running balance,
and the closing balance.
use doubleentry::prelude::*;
let mut books = Journal::new(LedgerId::new("example.com/acme")?);
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
books.open("Assets:Bank", AccountKind::Asset, date!("2026-01-01"))?;
books.open("Income:Sales", AccountKind::Income, date!("2026-01-01"))?;
books.define_months(2026, 3..=3)?;
books.record(
Entry::new("sale-1", date!("2026-03-15"))
.debit("Assets:Bank", amount!("100.00"), EUR)
.credit("Income:Sales", amount!("100.00"), EUR),
)?;
let march = books.statement("Assets:Bank", EUR, &BalanceQuery::period("2026-03"))?;
assert_eq!((march.opening.net(), march.closing.net()), (amount!("0.00"), amount!("100.00")));§Errors
As balance.
Sourcepub fn rollup(
&self,
currency: Currency,
query: &BalanceQuery,
) -> Result<Rollup<P>, Error>
pub fn rollup( &self, currency: Currency, query: &BalanceQuery, ) -> Result<Rollup<P>, Error>
The trial balance of currency folded up the account tree.
use doubleentry::prelude::*;
let mut books = Journal::new(LedgerId::new("example.com/acme")?);
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
books.open("Assets:Bank:Main", AccountKind::Asset, date!("2026-01-01"))?;
books.open("Income:Sales", AccountKind::Income, date!("2026-01-01"))?;
books.define_months(2026, 3..=3)?;
books.record(
Entry::new("sale-1", date!("2026-03-15"))
.debit("Assets:Bank:Main", amount!("100.00"), EUR)
.credit("Income:Sales", amount!("100.00"), EUR),
)?;
let sheet = books.rollup(EUR, &BalanceQuery::all())?;
assert_eq!(sheet.roots().count(), 2); // Assets, Income§Errors
As trial_balance, and Error::Query when
a subtree total does not fit an amount.
Sourcepub fn open_items<A>(
&self,
account: A,
currency: Currency,
query: &BalanceQuery,
) -> Result<Vec<OpenItem<P>>, Error>
pub fn open_items<A>( &self, account: A, currency: Currency, query: &BalanceQuery, ) -> Result<Vec<OpenItem<P>>, Error>
The open items of account in currency: the postings query
selects with an amount not offset by clearings (those dated on or
before the window’s end, and not reset by then), by position.
use doubleentry::prelude::*;
let mut books = Journal::new(LedgerId::new("example.com/acme")?);
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
books.open("Assets:Receivables", AccountKind::Asset, date!("2026-01-01"))?;
books.open("Income:Sales", AccountKind::Income, date!("2026-01-01"))?;
books.open("Assets:Bank", AccountKind::Asset, date!("2026-01-01"))?;
books.define_months(2026, 3..=3)?;
let invoice = books.record(
Entry::new("invoice-1", date!("2026-03-01"))
.debit("Assets:Receivables", amount!("119.00"), EUR)
.credit("Income:Sales", amount!("119.00"), EUR),
)?;
let payment = books.record(
Entry::new("payment-1", date!("2026-03-10"))
.debit("Assets:Bank", amount!("100.00"), EUR)
.credit("Assets:Receivables", amount!("100.00"), EUR),
)?;
books.clear(
"clear-1",
PostingRef::at(invoice.id, 0),
PostingRef::at(payment.id, 1),
amount!("100.00"),
date!("2026-03-10"),
)?;
let open = books.open_items("Assets:Receivables", EUR, &BalanceQuery::all())?;
assert_eq!(open.len(), 1);
assert_eq!(open[0].residual, amount!("19.00"));§Errors
As balance.
Sourcepub fn check_assertion(
&self,
assertion: &BalanceAssertion<P>,
) -> Result<AssertionOutcome<P>, Error>
pub fn check_assertion( &self, assertion: &BalanceAssertion<P>, ) -> Result<AssertionOutcome<P>, Error>
Checks one balance assertion.
use doubleentry::prelude::*;
use doubleentry::{report::{AssertAt, BalanceAssertion}};
let mut books = Journal::new(LedgerId::new("example.com/acme")?);
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
books.open("Assets:Bank", AccountKind::Asset, date!("2026-01-01"))?;
books.open("Income:Sales", AccountKind::Income, date!("2026-01-01"))?;
books.define_months(2026, 3..=3)?;
let sale = books.record(
Entry::new("sale-1", date!("2026-03-15"))
.debit("Assets:Bank", amount!("100.00"), EUR)
.credit("Income:Sales", amount!("100.00"), EUR),
)?;
let claim = BalanceAssertion::new("Assets:Bank", EUR, AssertAt::Position(sale.position), amount!("100.00"));
assert!(books.check_assertion(&claim)?.is_held());§Errors
As balance — a position beyond the head included —
never for a difference.
Sourcepub fn check_assertions(
&self,
assertions: &[BalanceAssertion<P>],
) -> Result<Vec<AssertionOutcome<P>>, Error>
pub fn check_assertions( &self, assertions: &[BalanceAssertion<P>], ) -> Result<Vec<AssertionOutcome<P>>, Error>
Checks every assertion: one outcome each, in their order.
use doubleentry::prelude::*;
use doubleentry::{report::{AssertAt, BalanceAssertion, DateBasis}};
let mut books = Journal::new(LedgerId::new("example.com/acme")?);
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
books.open("Assets:Bank", AccountKind::Asset, date!("2026-01-01"))?;
books.open("Income:Sales", AccountKind::Income, date!("2026-01-01"))?;
books.define_months(2026, 3..=3)?;
books.record(
Entry::new("sale-1", date!("2026-03-15"))
.debit("Assets:Bank", amount!("100.00"), EUR)
.credit("Income:Sales", amount!("100.00"), EUR),
)?;
let day = |date| AssertAt::EndOfDay { date, basis: DateBasis::Booking };
let outcomes = books.check_assertions(&[
BalanceAssertion::new("Assets:Bank", EUR, day(date!("2026-03-14")), amount!("0.00")),
BalanceAssertion::new("Assets:Bank", EUR, day(date!("2026-03-15")), amount!("90.00")),
])?;
assert_eq!(outcomes.iter().map(|outcome| outcome.is_held()).collect::<Vec<_>>(), [true, false]);§Errors
The first error of check_assertion.
Sourcepub fn export(
&self,
scope: ExportScope,
head: Option<TreeHead>,
) -> Result<Export<'_, P>, Error>
pub fn export( &self, scope: ExportScope, head: Option<TreeHead>, ) -> Result<Export<'_, P>, Error>
The GoBD export view of scope, pinned to head (or to the head
now): every row, in log order. See export for the
rows and how a recipient verifies them.
use doubleentry::export::{ExportRow, ExportScope};
use doubleentry::prelude::*;
let mut books = Journal::new(LedgerId::new("example.com/acme")?);
books.declare_currency(JPY, 0, date!("2026-01-01"))?;
books.open("Assets:Bank", AccountKind::Asset, date!("2026-01-01"))?;
books.open("Income:Sales", AccountKind::Income, date!("2026-01-01"))?;
books.define_months(2026, 3..=3)?;
books.record(
Entry::new("sale-1", date!("2026-03-15"))
.debit("Assets:Bank", amount!("500.00"), JPY)
.credit("Income:Sales", amount!("500.00"), JPY),
)?;
let postings: Vec<(i128, u8)> = books
.export(ExportScope::All, None)?
.filter_map(|row| match row {
Ok(ExportRow::Posting(posting)) => Some((posting.minor_units, posting.exponent)),
_ => None,
})
.collect();
assert_eq!(postings, [(500, 0), (500, 0)]); // yen have no minor unit§Errors
Error::Query for a period id that is no label or names no period
in the prefix, or a head beyond the log; Error::Integrity for a
head this log does not have. A row is an error only for a record the
rule core would not have written.
Source§impl<const P: u8> Journal<P>
impl<const P: u8> Journal<P>
Sourcepub fn checkpoint(&self, key: &LogKey) -> Result<SignedCheckpoint, Error>
pub fn checkpoint(&self, key: &LogKey) -> Result<SignedCheckpoint, Error>
The ledger’s head as a C2SP checkpoint, signed by key (feature
witness).
use doubleentry::transparency::LogKey;
use doubleentry::{Journal, LedgerId};
let ledger = LedgerId::new("example.com/ledgers/acme")?;
let books = Journal::new(ledger.clone());
let key = LogKey::from_seed(&ledger, [7; 32]);
let signed = books.checkpoint(&key)?;
assert_eq!(signed.checkpoint().head(), books.head());
signed.verify(&[key.vkey()])?;§Errors
Error::Transparency with
KeyNameMismatch when
the key is not this ledger’s.
Sourcepub fn prove_balance<A>(
&self,
account: A,
currency: Currency,
layer: Layer,
period: impl AsRef<str>,
) -> Result<SealedBalanceProof<P>, Error>
pub fn prove_balance<A>( &self, account: A, currency: Currency, layer: Layer, period: impl AsRef<str>, ) -> Result<SealedBalanceProof<P>, Error>
The proof of account’s balance in (currency, layer) as sealed
for period, against head: the seal’s inclusion,
the registration that names the handle, and the balance leaf at the
handle in the seal’s tree (or no path when the seal has no tree for
(currency, layer): the balance is zero). It reads the account’s leaf
and the O(log² accounts) stored nodes of its path, never every
leaf of the period, and is verified before it is handed out.
use doubleentry::prelude::*;
let mut books = Journal::new(LedgerId::new("example.com/acme")?);
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
books.open("Assets:Cash", AccountKind::Asset, date!("2026-01-01"))?;
books.open("Income:Sales", AccountKind::Income, date!("2026-01-01"))?;
books.define_months(2026, 3..=3)?;
books.record(
Entry::new("sale-1", date!("2026-03-15"))
.debit("Assets:Cash", amount!("10.00"), EUR)
.credit("Income:Sales", amount!("10.00"), EUR),
)?;
books.seal_period("2026-03")?;
let proof = books.prove_balance("Assets:Cash", EUR, Layer::Settled, "2026-03")?;
let verified = proof.verify(&books.head())?;
assert_eq!(verified.account.to_string(), "Assets:Cash");
assert_eq!(verified.balance.net(), amount!("10.00"));§Errors
Error::Query: an invalid or unknown period
(QueryError::NotSealed before its seal), or an account unknown or
registered after the seal (QueryError::UnknownAccount).
Sourcepub fn bundle_record(
&self,
position: u64,
checkpoint: &SignedCheckpoint,
) -> Result<ProofBundle<P>, Error>
pub fn bundle_record( &self, position: u64, checkpoint: &SignedCheckpoint, ) -> Result<ProofBundle<P>, Error>
A tlog-proof bundle of the record at position, at checkpoint’s
size (which may be below the head).
use doubleentry::transparency::{LogKey, Proven};
use doubleentry::{Journal, LedgerId, record::Record};
let ledger = LedgerId::new("example.com/ledgers/acme")?;
let books = Journal::new(ledger.clone());
let signed = books.checkpoint(&LogKey::from_seed(&ledger, [7; 32]))?;
let bundle = books.bundle_record(0, &signed)?;
let Proven::Record(genesis) = bundle.verify_inclusion()? else { panic!() };
assert!(matches!(genesis.record, Record::Genesis(_)));§Errors
Error::Transparency with ForeignCheckpoint for another log’s
checkpoint, CheckpointMismatch for a size this ledger has not
reached or another root at that size; Error::Query for a
position not below the checkpoint’s size.
Sourcepub fn bundle_entry(
&self,
id: EntryId,
checkpoint: &SignedCheckpoint,
) -> Result<ProofBundle<P>, Error>
pub fn bundle_entry( &self, id: EntryId, checkpoint: &SignedCheckpoint, ) -> Result<ProofBundle<P>, Error>
A tlog-proof bundle of entry id, at checkpoint’s size. Its
verification names the entry and recomputes its id.
§Errors
As bundle_record, and
QueryError::UnknownEntry when no entry had the id at that size.
Sourcepub fn bundle_balance<A>(
&self,
account: A,
currency: Currency,
layer: Layer,
period: impl AsRef<str>,
checkpoint: &SignedCheckpoint,
) -> Result<ProofBundle<P>, Error>
pub fn bundle_balance<A>( &self, account: A, currency: Currency, layer: Layer, period: impl AsRef<str>, checkpoint: &SignedCheckpoint, ) -> Result<ProofBundle<P>, Error>
A tlog-proof bundle of account’s sealed balance in (currency,
layer) for period, at checkpoint’s size: one file that names
the account and proves its balance and nothing about any other
account (layout: transparency).
§Errors
As bundle_record and
prove_balance, at the checkpoint’s size.
Sourcepub fn tile(&self, id: TileId) -> Result<Vec<u8>, Error>
pub fn tile(&self, id: TileId) -> Result<Vec<u8>, Error>
The bytes of hash tile id (tlog-tiles): its 32-byte hashes, read
from stored nodes. Hashes only — nothing about any record but its
leaf hash.
use doubleentry::transparency::TileId;
use doubleentry::{Journal, LedgerId};
let books = Journal::new(LedgerId::new("example.com/acme")?);
for tile in TileId::changed(0, books.head().size) {
assert_eq!(books.tile(tile)?.len(), 32 * usize::from(tile.width()));
}§Errors
Error::Query for a tile beyond the head; Error::Transparency
with WrongTileKind for an entry bundle.
Sourcepub fn tile_entries(&self, id: TileId) -> Result<Vec<u8>, Error>
pub fn tile_entries(&self, id: TileId) -> Result<Vec<u8>, Error>
The bytes of entry bundle id (tlog-tiles): the leaf bytes of its
records, each prefixed by its big-endian 16-bit length.
This discloses every record in the range — postings, accounts, amounts, documents — in full; only memo text stays out (a leaf commits to it by a salted hash). Publish entry bundles only where the books may be read.
§Errors
Error::Query for a bundle beyond the head;
Error::Transparency with WrongTileKind for a hash tile.
Trait Implementations§
Auto Trait Implementations§
impl<const P: u8 = 2> !RefUnwindSafe for Journal<P>
impl<const P: u8 = 2> !UnwindSafe for Journal<P>
impl<const P: u8> Freeze for Journal<P>
impl<const P: u8> Send for Journal<P>
impl<const P: u8> Sync for Journal<P>
impl<const P: u8> Unpin for Journal<P>
impl<const P: u8> UnsafeUnpin for Journal<P>where
Arc<MemState<P>>: UnsafeUnpin,
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
Source§impl<T> CloneToUninit for Twhere
T: Clone,
impl<T> CloneToUninit for Twhere
T: Clone,
Source§impl<T> Instrument for T
impl<T> Instrument for T
Source§fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
Source§fn in_current_span(self) -> Instrumented<Self> ⓘ
fn in_current_span(self) -> Instrumented<Self> ⓘ
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left is true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left(&self) returns true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read more