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
validateJournal(lines ×3, GBP)→ valid true, debit total £120.00, credit total £120.00, difference £0.00, problems a balanced sale: debtor against sales and VATvalidateJournal(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 muchvalidateJournal(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.
export function validateJournal(lines: readonly JournalLine[], currency: string): 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. */
export interface JournalLine {
/** account code, e.g. "4000" or "1200-01" */
readonly account: string;
/** zero on a credit line */
readonly debit: Money;
/** zero on a debit line */
readonly credit: Money;
}
export type JournalProblemCode = "empty" | "invalid-account" | "currency-mismatch" | "negative-amount" | "zero-line" | "both-sides" | "unbalanced";
/** One thing wrong with the journal. */
export interface JournalProblem {
/** 1-based line number; null for the journal as a whole */
readonly line: number | null;
readonly code: JournalProblemCode;
/** the same words in every language */
readonly message: string;
}
/** The verdict, the totals it was reached on, and every problem found. */
export interface JournalCheck {
readonly valid: boolean;
readonly debitTotal: Money;
readonly creditTotal: Money;
/** debits less credits; zero when balanced */
readonly difference: Money;
/** line problems in line order, then journal problems */
readonly problems: readonly JournalProblem[];
}
Your code names it in one line, in the file that uses it
import { validateJournal } from "#fune/finance.ledger.journal-validate@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { addMoney } from "./money_add.ts"; ← from money.add ^1.0.0 · built alongside by fune
import { money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { type JournalCheck, type JournalLine, type JournalProblem, type JournalProblemCode } from "./finance_ledger_journal_validate_types.ts";
function isAlnum(code: number): boolean {
return (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122);
}
/** 1-20 ASCII letters and digits, in groups joined by a single - . or / */
function isAccountCode(account: string): boolean {
if (account.length < 1 || account.length > 20) return false;
let previousWasAlnum = false;
for (let i = 0; i < account.length; i += 1) {
const code = account.charCodeAt(i);
if (isAlnum(code)) {
previousWasAlnum = true;
} else if ((code === 45 || code === 46 || code === 47) && previousWasAlnum) {
previousWasAlnum = false;
} else {
return false;
}
}
return previousWasAlnum;
}
/**
* 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.
*/
export function validateJournal(lines: readonly JournalLine[], currency: string): JournalCheck {
let debitTotal = money(0, currency);
let creditTotal = money(0, currency);
const problems: JournalProblem[] = [];
const report = (line: number | null, code: JournalProblemCode, message: string): void => {
problems.push({ line, code, message });
};
if (lines.length === 0) {
report(null, "empty", "the journal has no lines");
}
lines.forEach((entry, index) => {
const n = index + 1;
if (!isAccountCode(entry.account)) {
report(n, "invalid-account", `line ${n}: account "${entry.account}" is not a valid account code`);
}
const foreign = entry.debit.currency !== currency ? entry.debit.currency : entry.credit.currency !== currency ? entry.credit.currency : null;
if (foreign !== null) {
report(n, "currency-mismatch", `line ${n}: amount is in ${foreign}, not the journal currency ${currency}`);
return;
}
if (entry.debit.minor < 0 || entry.credit.minor < 0) {
report(n, "negative-amount", `line ${n}: debit and credit must not be negative`);
return;
}
if (entry.debit.minor === 0 && entry.credit.minor === 0) {
report(n, "zero-line", `line ${n}: debit and credit are both zero`);
} else if (entry.debit.minor !== 0 && entry.credit.minor !== 0) {
report(n, "both-sides", `line ${n}: has both a debit and a credit`);
}
debitTotal = addMoney(debitTotal, entry.debit);
creditTotal = addMoney(creditTotal, entry.credit);
});
const difference = money(debitTotal.minor - creditTotal.minor, currency);
if (difference.minor !== 0) {
report(null, "unbalanced", "debits do not equal credits");
}
return { valid: problems.length === 0, debitTotal, creditTotal, difference, problems };
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the TypeScript 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. 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 TypeScript implementation. Install it without the registry with fune add ./finance.ledger.journal-validate-1.0.0-typescript.fune, or fetch it from a terminal with fune pull finance.ledger.journal-validate@1.0.0:typescript.
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 |