finance.ledger.journal-validate
Check a double-entry journal: debits equal credits, no zero or two-sided lines, one currency, well-formed account codes.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 16 tests, run in TypeScript, Python and Rust.
What it does
The checks a ledger makes before it posts a journal. It reports every problem rather than stopping at the first, because the person fixing a 40-line journal wants the whole list, and it returns the totals it checked, so "unbalanced by how much" is answered without a second pass.
A journal is valid when it has no problems:
For example
validate_journal(lines ×3, GBP)→ valid true, debit total £120.00, credit total £120.00, difference £0.00, problems a balanced sale: debtor against sales and VATvalidate_journal(lines ×2, GBP)→ valid false, debit total £100.00, credit total £99.99, difference £0.01, problems ×1 a penny out is unbalanced, and the difference says by how muchvalidate_journal(lines ×2, GBP)→ valid false, debit total £90.00, credit total £100.00, difference -£10.00, problems ×1 credits exceeding debits give a negative difference
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
pub fn validate_journal(lines: &[JournalLine], currency: &str) -> JournalCheck
| lines | JournalLine[] | the journal's lines, in the order they were entered |
| currency | string | the journal's currency; every amount must be in it |
| returns | JournalCheck |
The types it declares, generated into your project
/// One line of a double-entry journal: a debit or a credit to one account.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JournalLine {
/// account code, e.g. "4000" or "1200-01"
pub account: String,
/// zero on a credit line
pub debit: Money,
/// zero on a debit line
pub credit: Money,
}
// JournalProblemCode is a string in Rust, one of: "empty", "invalid-account", "currency-mismatch", "negative-amount", "zero-line", "both-sides", "unbalanced".
// Parameters take it as &str and results hold it as String.
/// One thing wrong with the journal.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JournalProblem {
/// 1-based line number; null for the journal as a whole
pub line: Option<i64>,
pub code: String,
/// the same words in every language
pub message: String,
}
/// The verdict, the totals it was reached on, and every problem found.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JournalCheck {
pub valid: bool,
pub debit_total: Money,
pub credit_total: Money,
/// debits less credits; zero when balanced
pub difference: Money,
/// line problems in line order, then journal problems
pub problems: Vec<JournalProblem>,
}
Your code names it in one line, in the file that uses it
fune!(finance.ledger.journal-validate@^1); // then call validate_journal(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::money_add::add_money; ← from money.add ^1.0.0 · built alongside by fune
use super::money_amount::{money, money_from_value, money_to_value}; ← from money.amount ^1.0.0 · built alongside by fune
/// 1-20 ASCII letters and digits, in groups joined by a single - . or /
fn is_account_code(account: &str) -> bool {
let bytes = account.as_bytes();
if bytes.is_empty() || bytes.len() > 20 {
return false;
}
let mut previous_was_alnum = false;
for &b in bytes {
if b.is_ascii_alphanumeric() {
previous_was_alnum = true;
} else if (b == b'-' || b == b'.' || b == b'/') && previous_was_alnum {
previous_was_alnum = false;
} else {
return false;
}
}
previous_was_alnum
}
/// Check a journal before it is posted, reporting every problem at once.
///
/// A line in the wrong currency or with a negative amount is left out of the
/// totals, since adding it would make them meaningless; the other problems
/// leave the line's amounts in.
///
/// # Panics
/// Panics only if `currency` is not an ISO 4217 code.
pub fn validate_journal(lines: &[JournalLine], currency: &str) -> JournalCheck {
let mut debit_total = money(0, currency);
let mut credit_total = money(0, currency);
let mut problems: Vec<JournalProblem> = Vec::new();
let mut report = |line: Option<i64>, code: &str, message: String| {
problems.push(JournalProblem {
line,
code: code.to_string(),
message,
});
};
if lines.is_empty() {
report(None, "empty", "the journal has no lines".to_string());
}
for (index, entry) in lines.iter().enumerate() {
let n = index as i64 + 1;
if !is_account_code(&entry.account) {
report(
Some(n),
"invalid-account",
format!("line {}: account \"{}\" is not a valid account code", n, entry.account),
);
}
let foreign = if entry.debit.currency != currency {
Some(entry.debit.currency.clone())
} else if entry.credit.currency != currency {
Some(entry.credit.currency.clone())
} else {
None
};
if let Some(foreign) = foreign {
report(
Some(n),
"currency-mismatch",
format!("line {}: amount is in {}, not the journal currency {}", n, foreign, currency),
);
continue;
}
if entry.debit.minor < 0 || entry.credit.minor < 0 {
report(
Some(n),
"negative-amount",
format!("line {}: debit and credit must not be negative", n),
);
continue;
}
if entry.debit.minor == 0 && entry.credit.minor == 0 {
report(Some(n), "zero-line", format!("line {}: debit and credit are both zero", n));
} else if entry.debit.minor != 0 && entry.credit.minor != 0 {
report(Some(n), "both-sides", format!("line {}: has both a debit and a credit", n));
}
debit_total = add_money(&debit_total, &entry.debit);
credit_total = add_money(&credit_total, &entry.credit);
}
let difference = money(debit_total.minor - credit_total.minor, currency);
if difference.minor != 0 {
report(None, "unbalanced", "debits do not equal credits".to_string());
}
JournalCheck {
valid: problems.is_empty(),
debit_total,
credit_total,
difference,
problems,
}
}
pub fn journal_line_from_value(v: &Value) -> JournalLine {
JournalLine {
account: v.get("account").as_str().to_string(),
debit: money_from_value(v.get("debit")),
credit: money_from_value(v.get("credit")),
}
}
pub fn journal_check_to_value(check: &JournalCheck) -> Value {
Value::obj(vec![
("valid", Value::Bool(check.valid)),
("debitTotal", money_to_value(&check.debit_total)),
("creditTotal", money_to_value(&check.credit_total)),
("difference", money_to_value(&check.difference)),
(
"problems",
Value::Arr(
check
.problems
.iter()
.map(|p| {
Value::obj(vec![
(
"line",
match p.line {
Some(n) => Value::Int(n),
None => Value::Null,
},
),
("code", Value::str(&p.code)),
("message", Value::str(&p.message)),
])
})
.collect(),
),
),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let lines: Vec<JournalLine> = args[0].as_arr().iter().map(journal_line_from_value).collect();
journal_check_to_value(&validate_journal(&lines, args[1].as_str()))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add finance.ledger.journal-validate
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./finance.ledger.journal-validate-1.0.0-rust.fune, or fetch it from a terminal with fune pull finance.ledger.journal-validate@1.0.0:rust.
The whole function, every language, is one file too: finance.ledger.journal-validate-1.0.0.fune, 29,164 bytes, sha256 cd6200f3b41261bc67aaa8970bd4f13bee5b41b6d10c97e212f12d124df7d8e0. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before finance.ledger.journal-validate
after — your function gets the result and the arguments, and returns the final result.
// fune: after finance.ledger.journal-validate
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace money.add in finance.ledger.journal-validate
// fune: replace money.amount in finance.ledger.journal-validate
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show finance.ledger.journal-validate --steps.
// fune: step finance.ledger.journal-validate after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| a balanced sale: debtor against sales and VAT | lines ×3, GBP | → | valid true, debit total £120.00, credit total £120.00, difference £0.00, problems |
| a penny out is unbalanced, and the difference says by how much | lines ×2, GBP | → | valid false, debit total £100.00, credit total £99.99, difference £0.01, problems ×1 |
| credits exceeding debits give a negative difference | lines ×2, GBP | → | valid false, debit total £90.00, credit total £100.00, difference -£10.00, problems ×1 |
| an empty journal is not valid | , GBP | → | valid false, debit total £0.00, credit total £0.00, difference £0.00, problems ×1 |
| a single line cannot balance | lines ×1, GBP | → | valid false, debit total £1.00, credit total £0.00, difference £1.00, problems ×1 |
| a zero line is reported even when the journal balances | lines ×3, GBP | → | valid false, debit total £5.00, credit total £5.00, difference £0.00, problems ×1 |
| a line with both a debit and a credit is reported, and counted | lines ×3, GBP | → | valid false, debit total £6.00, credit total £6.00, difference £0.00, problems ×1 |
| a negative debit is reported and left out of the totals | lines ×3, GBP | → | valid false, debit total £10.00, credit total £10.00, difference £0.00, problems ×1 |
| a line in another currency is reported and left out, which unbalances the rest | lines ×2, GBP | → | valid false, debit total £10.00, credit total £0.00, difference £10.00, problems ×2 |
| the credit side's currency is checked too | lines ×2, GBP | → | valid false, debit total £0.00, credit total £10.00, difference -£10.00, problems ×2 |
Show the other 6 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| well-formed account codes: groups joined by - . or / | lines ×4, GBP | → | valid true, debit total £3.00, credit total £3.00, difference £0.00, problems |
| malformed account codes are each reported but still counted | lines ×7, GBP | → | valid false, debit total £6.00, credit total £6.00, difference £0.00, problems ×6 |
| a twenty-character code is the longest allowed | lines ×2, GBP | → | valid true, debit total £1.00, credit total £1.00, difference £0.00, problems |
| one line can have two problems | lines ×3, GBP | → | valid false, debit total £1.00, credit total £1.00, difference £0.00, problems ×2 |
| a yen journal | lines ×2, JPY | → | valid true, debit total ¥150,000, credit total ¥150,000, difference ¥0, problems |
| the journal currency must be an ISO code | lines ×1, gbp | → | error: ISO 4217 |
More from the author
| code | problem | |---|---| | `empty` | the journal has no lines | | `invalid-account` | the account code is not well-formed (below) | | `currency-mismatch` | a debit or credit is not in the journal currency; the line is left out of the totals | | `negative-amount` | a debit or credit is negative (a negative debit is a credit: post it as one); left out of the totals | | `zero-line` | debit and credit are both zero | | `both-sides` | a line has both a debit and a credit; split it into two lines | | `unbalanced` | total debits do not equal total credits |
Line numbers are 1-based, as a journal screen shows them. One line can have more than one problem (a malformed account on a zero line), and each is listed. Messages are fixed text and identical in every language; they do not format amounts, because the totals are in the result.
**Account codes.** This checks the shape of a code, not that the account exists in a chart of accounts, which is the caller's data. A code is 1 to 20 ASCII letters and digits, optionally in groups separated by a single `-`, `.` or `/`: `4000`, `1200-01`, `SALES.UK`, `6100/2` are well-formed; `4 000`, `-4000`, `4000-`, `40--00` and the empty string are not.
An invalid account code does not exclude the line from the totals, because its amount is still a real amount. A wrong currency or a negative amount does, because adding it would produce a total that means nothing.
Only the currency argument itself can make this throw (it must be an ISO 4217 code); everything wrong with the journal is reported, not thrown.
Files
| Path | Bytes |
|---|---|
| README.md | 1,922 |
| impl/python.py | 3,049 |
| impl/rust.rs | 5,040 |
| impl/typescript.ts | 3,000 |
| vectors.json | 10,391 |