Skip to main content

Crate doubleentry

Crate doubleentry 

Source
Expand description

An immutable, tamper-evident double-entry bookkeeping engine.

A ledger is one entity’s books, kept as one append-only RFC 6962 SHA-256 Merkle log of typed records: a genesis record, account events, currency declarations, policy events, period definitions, entries, clearings and seals. Every record has exactly one canonical byte form, so a position in the log, a leaf hash and a proof mean the same thing to every implementation.

Every write is decided by one pure function, the rule core: it takes the draft and a read-only view of the ledger at the position the record will be appended, and returns either the record to write or one Rejection with a stable code. No façade and no store implements a rule; Journal::audit and Ledger::audit replay the whole log through the same core.

§Where things are

  • The crate root — what bookkeeping code names: the two façades with the same methods, Journal (sync, in memory) and Ledger (async, over a store); the one Error and its Rejection; the values (exact Amounts, Currency codes, validated names such as AccountPath and Label, the derived EntryId); the Entry draft; BalanceQuery; the TreeHead proofs verify against.
  • currency — the currency constants (EUR, JPY, …).
  • report — what the reads return: TrialBalance, Statement, Rollup, OpenItems and balance assertions.
  • export — the GoBD export view: verifiable flat rows.
  • error — the payloads Error’s variants carry.
  • log — proofs and the RFC 6962 hashing an independent verifier re-implements; record — the eight record kinds and their canonical bytes.
  • prelude — the everyday vocabulary in one import.
  • store — where a ledger is kept: the Store contract, MemoryStore, the SQL store, and the rule core’s protocol for implementers of another store.
  • transparency — C2SP checkpoints, witness requests and replies, policies, the auditor’s verifier, proof bundles and tiles.
  • testing (feature testing) — the conformance suite every store passes.

The user guide covers the same ground: Start, Concepts, Reporting, Operating and Assurance.

§Example

use doubleentry::{AccountKind, Entry, Journal, LedgerId, Rejection, amount, date, currency::EUR};

let mut books = Journal::new(LedgerId::new("example.com/acme")?).as_actor("setup");
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_period("2026-03", date!("2026-03-01"), date!("2026-03-31"))?;

let invoice = Entry::new("invoice-2026-0001", date!("2026-03-15"))
    .debit("Assets:Cash", amount!("1190.00"), EUR)
    .credit("Income:Sales", amount!("1190.00"), EUR);
let recorded = books.record(invoice.clone())?;
assert!(!recorded.replayed);
assert!(books.record(invoice)?.replayed); // a retry changes nothing

// An unbalanced draft is refused with a stable code.
let refused = books.record(
    Entry::new("invoice-2026-0002", date!("2026-03-16"))
        .debit("Assets:Cash", amount!("10.00"), EUR)
        .credit("Income:Sales", amount!("9.00"), EUR),
);
assert_eq!(refused.err().as_ref().and_then(|e| e.rejection()).map(Rejection::code), Some(106));

books.seal_period("2026-03")?;
books.audit()?;

§A guide

  1. Set up the books — declare_currency (with its exponent), define_months (or define_period), open accounts by path, and optionally set_limit. Every setup call is idempotent — repeating it with the same definition writes nothing, a different one is refused — so a service runs it on every start.
  2. Draft and record — Entry builders are infallible; a malformed draft is refused by record with a Rejection and a stable code. The idempotency key is the entry’s identity: a retry of the same draft returns the original Recorded with replayed == true. record_batch records many drafts atomically, in order, in one transaction.
  3. Read — balance and the reports take a BalanceQuery (a log prefix, a date window by booking or value date, a period, dimension filters); both façades answer them alike.
  4. Close and prove — seal_period commits every balance through the period’s end; periods are sealed in calendar order, and seal_through seals every open period up to one in a single call; prove_entry, prove_consistency and prove_balance give proofs that verify against a TreeHead alone; transparency signs heads and lets independent witnesses cosign them.
  5. Keep it durable — the same calls on a Ledger over a store (MemoryStore, SQLite, PostgreSQL), each .awaited; the store runs the rule core inside its write transaction. audit replays the log and compares.

§Errors

Every operation of either façade returns Result, whose error is the one Error. Match on its variant to decide what to do:

Erroris_transientmay_have_appliedWhat the caller does
Rejected(r)falsefalseshow r and its code; fix the input; never retry unchanged
BatchRejected { index, rejection }falsefalsenothing of the batch was written; fix draft index (or drop it) and record the batch again
Value(e), Amount(e)falsefalsefix the value or the arithmetic at the call site (never from a ledger operation)
Query(e)falsefalsefix the question: the entry, period, window or position asked for
Transparency(e)falsefalsethe artefact is refused: a forged, stale or foreign checkpoint, bundle or reply; never retry it unchanged
Integrity(e)falsefalsestop writing; investigate position e.position(); restore from a backup and audit
Backend(b), transient, definitetruefalseretry later with the same draft
Backend(b), outcome unknowneithertrueretry with the same draft: it applies once, or comes back with replayed == true
Backend(b), permanent, definitefalsefalsedo not retry automatically: an operator problem (configuration, permissions)
Config(e)falsefalsethe store holds another ledger, scale or schema: fix the URL, schema name, ledger id or P — the data is fine

{:?} on an Error prints its Display line, stable code included, so fn main() -> doubleentry::Result<()> reports rejected: [427] ….

use doubleentry::prelude::*;

match books.record(entry) {
    Ok(recorded) if recorded.replayed => {} // a retry: nothing written
    Ok(_) => {}
    Err(Error::Rejected(rejection)) => eprintln!("refused: {rejection}"),
    Err(error) if error.is_transient() => return Err(error), // retry later, same draft
    Err(error) => return Err(error),
}

§Feature flags

All features are off by default.

  • serde — Enable serde Serialize / Deserialize on all public types.

    Serde is a transport concern only. The canonical encoding used for hashing is independent of it and is never routed through serde.

  • postgres — SqlStore<Postgres>, on sqlx. Enables no TLS stack: for TLS, add sqlx = { version = "0.9", features = ["tls-rustls-aws-lc-rs"] } (or tls-rustls-ring-webpki, tls-native-tls) to your application. One schema, doubleentry/1; no migrations.

  • sqlite — SqlStore<Sqlite>, on sqlx, with SQLite bundled. One schema, doubleentry/1; no migrations.

  • witness — Ed25519 for transparency: LogKey signs checkpoints; cosignatures and policies are verified; WitnessSession and Verifier work. The C2SP text formats parse and render without it. No HTTP: requests and replies are bytes.

  • testing — doubleentry::testing: the public conformance suite for Store implementations and, with witness, TestWitness. Adds tokio.

  • tracing — tracing spans per façade operation and per SQL store write. Never an amount, memo or account path.

Modules§

currency
The currency codes as constants — use doubleentry::currency::JPY;. The prelude brings the five most common; any other ISO 4217 code is Currency::new or the currency! literal.
error
The payloads Error’s variants carry. What each variant means and whether to retry it: the crate-level table.
export
The GoBD export view: the books as verifiable flat rows (§ 147 Abs. 6 AO, GoBD Rz. 94, 165–167).
log
The log as an independent verifier sees it: RFC 6962 SHA-256 hashing, inclusion and consistency proofs against a TreeHead, the seal BalanceTrees and their proofs, and the leaves a journal is replayed from. Bookkeeping code rarely names these: the façades’ prove_* methods return the proofs, which verify themselves.
prelude
The everyday vocabulary in one import: the façades, the one error, the values and drafts of drafting, recording and reading, the five common currencies and the two literal macros.
record
The log’s record kinds and their canonical byte form — what an exporter or an independent verifier decodes; bookkeeping code rarely names them.
report
Every read, once: balances, the trial balance, statements, the rollup, open items and balance assertions.
store
Where a ledger is kept: the Store contract, the in-memory MemoryStore and the one SQL implementation SqlStore.
testingtesting
The public conformance suite (feature testing): whether a Store keeps the contract is decided here, not by a reviewer — with the canaries of BrokenStore (reads ignoring through, a missing open-holds read, stale values, …) each caught by some check, and independent_handles for a lock that must hold across processes.
transparency
Checkpoints others can cosign and auditors can check: the C2SP transparency formats over the ledger’s RFC 6962 log.

Macros§

amount
A compile-checked Amount literal; P is inferred from context.
currency
A compile-checked Currency code.
date
A Date from a YYYY-MM-DD literal, checked at compile time.

Structs§

Account
An account’s master data in force: what Journal::account and Ledger::account return. The latest version of each field — the window, limit and class every later entry is checked against.
AccountHandle
An account’s number in its ledger: the count of registrations before it.
AccountPath
An account’s place in the account tree: 1–16 :-separated segments.
AccountSpec
An account to register: path, kind and first open day, with optional class, last open day and balance limit. Built infallibly; the path and class are validated when the account is registered.
Amount
An exact amount of money: an i128 count of 10⁻ᴾ units.
AuditReport
What a passing audit covered.
Balance
The gross T-account of one (account, currency, layer): debits and credits kept apart, and the number of postings.
BalanceQuery
The question every balance read answers: which postings count.
CaptureTime
When the caller says an entry was captured: Unix seconds and the UTC offset in minutes — a caller’s claim, never read from a clock.
Currency
A currency code: 1–16 characters, each A–Z or 0–9.
Date
The calendar date every booking date, value date and effective date is (re-exported from time, so date! needs no dependency of yours). Date in the proleptic Gregorian calendar.
Dimensions
At most eight axis → value labels on a posting, in ascending axis order.
Document
A source document an entry is based on (an invoice, a receipt, a bank statement line).
Entry
An entry draft: what a caller submits to be recorded.
EntryId
An entry’s identity: 16 bytes derived from the ledger and the entry’s IdempotencyKey, never generated.
EntryRecord
Record 0x06: a committed entry. Built only through EntryRecord::new, which applies the decoder’s checks, so every value has one canonical form.
HoldState
A hold and where it stands, derived from its records.
IdempotencyKey
The caller’s identity for an entry — typically the source transaction id: 1–128 bytes of UTF-8 without control characters, compared byte-exactly (never normalised).
Journal
A ledger kept in memory and written synchronously: the rule core over an in-memory state.
Label
A short name: 1–64 Unicode scalar values in NFC, no whitespace at either edge, no control character and no invisible character (ValueError::InvisibleCharacter).
Ledger
A ledger kept in a Store and written asynchronously: the rule core run by the store inside its write transaction, under the ledger’s lock.
LedgerId
A ledger’s identity: 1–255 bytes, each printable ASCII (0x21–0x7E) except +.
LedgerPolicy
The ledger-wide policy: dimensions every posting must carry, and how far a value date may drift from the booking date.
MemoSalt
The 16-byte salt of a memo commitment, kept beside the leaf with the text.
Page
One page of a paged read.
PageRead
A page request: an exclusive cursor, a limit and an optional bound.
Posting
One movement in a draft: an account, a side, a positive amount, a currency and optional dimensions.
PostingRef
One posting of one entry: the entry’s id and the posting’s index.
Rate
An exchange rate recorded on an entry: mantissa × 10^exponent units of quote per unit of base. Data only — the crate converts nothing.
Recorded
The receipt of a recorded entry.
SealReceipt
The receipt of a sealed period.
TreeHead
A log’s size and root: the only thing proofs verify against.

Enums§

AccountKind
What an account is, fixed at registration.
AccountRef
How a draft names an account: by path, or by handle.
BalanceLimit
Which balance an account may not reach.
Error
Everything a ledger operation can fail with.
HoldStatus
Where a hold stands.
Layer
The balancing layer beside the currency: settled movements, or pending ones.
PostAmount
How much of a hold a HoldAction::Post settles.
Rejection
Why the rule core refused a write.
ResolutionKind
How a hold was resolved.
Rounding
How a result that falls between two representable amounts is rounded.
Side
The side of a posting. Magnitudes are positive; the side is never a sign.

Functions§

validate
Every structural violation of a draft, in precedence order — what a form would list before submitting. Reads no state; an empty list does not mean the draft will be recorded. A memo without a salt is not listed: the façade that records the draft supplies the salt.

Type Aliases§

HoldCursor
A hold page cursor: (expiry date, position); the date is None for HoldRead::OpenByAccount.
PeriodId
A period’s id ("2026-03", "FY2026"): a Label.
Result
The crate’s result type.