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.
def validate_journal(lines: Sequence[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
@dataclass(frozen=True)
class JournalLine:
"""One line of a double-entry journal: a debit or a credit to one account."""
#: account code, e.g. "4000" or "1200-01"
account: str
#: zero on a credit line
debit: Money
#: zero on a debit line
credit: Money
JournalProblemCode = Literal["empty", "invalid-account", "currency-mismatch", "negative-amount", "zero-line", "both-sides", "unbalanced"]
@dataclass(frozen=True)
class JournalProblem:
"""One thing wrong with the journal."""
#: 1-based line number; null for the journal as a whole
line: Optional[int]
code: JournalProblemCode
#: the same words in every language
message: str
@dataclass(frozen=True)
class JournalCheck:
"""The verdict, the totals it was reached on, and every problem found."""
valid: bool
debit_total: Money
credit_total: Money
#: debits less credits; zero when balanced
difference: Money
#: line problems in line order, then journal problems
problems: List[JournalProblem]
Your code names it in one line, in the file that uses it
from fune.finance.ledger.journal_validate import validate_journal # 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.
from typing import List, Optional, Sequence
from .finance_ledger_journal_validate_types import JournalCheck, JournalLine, JournalProblem
from .money_add import add_money ← from money.add ^1.0.0 · built alongside by fune
from .money_amount import money ← from money.amount ^1.0.0 · built alongside by fune
def _is_alnum(ch: str) -> bool:
return ("0" <= ch <= "9") or ("A" <= ch <= "Z") or ("a" <= ch <= "z")
def _is_account_code(account: str) -> bool:
"""1-20 ASCII letters and digits, in groups joined by a single - . or /"""
if len(account) < 1 or len(account) > 20:
return False
previous_was_alnum = False
for ch in account:
if _is_alnum(ch):
previous_was_alnum = True
elif ch in "-./" and previous_was_alnum:
previous_was_alnum = False
else:
return False
return previous_was_alnum
def validate_journal(lines: Sequence[JournalLine], currency: str) -> JournalCheck:
"""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.
"""
debit_total = money(0, currency)
credit_total = money(0, currency)
problems: List[JournalProblem] = []
def report(line: Optional[int], code: str, message: str) -> None:
problems.append(JournalProblem(line=line, code=code, message=message))
if len(lines) == 0:
report(None, "empty", "the journal has no lines")
for index, entry in enumerate(lines):
n = index + 1
if not _is_account_code(entry.account):
report(n, "invalid-account", 'line %d: account "%s" is not a valid account code' % (n, entry.account))
foreign = None
if entry.debit.currency != currency:
foreign = entry.debit.currency
elif entry.credit.currency != currency:
foreign = entry.credit.currency
if foreign is not None:
report(n, "currency-mismatch", "line %d: amount is in %s, not the journal currency %s" % (n, foreign, currency))
continue
if entry.debit.minor < 0 or entry.credit.minor < 0:
report(n, "negative-amount", "line %d: debit and credit must not be negative" % (n,))
continue
if entry.debit.minor == 0 and entry.credit.minor == 0:
report(n, "zero-line", "line %d: debit and credit are both zero" % (n,))
elif entry.debit.minor != 0 and entry.credit.minor != 0:
report(n, "both-sides", "line %d: has both a debit and a credit" % (n,))
debit_total = add_money(debit_total, entry.debit)
credit_total = add_money(credit_total, entry.credit)
difference = money(debit_total.minor - credit_total.minor, currency)
if difference.minor != 0:
report(None, "unbalanced", "debits do not equal credits")
return JournalCheck(
valid=len(problems) == 0,
debit_total=debit_total,
credit_total=credit_total,
difference=difference,
problems=problems,
)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Python 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 Python implementation. Install it without the registry with fune add ./finance.ledger.journal-validate-1.0.0-python.fune, or fetch it from a terminal with fune pull finance.ledger.journal-validate@1.0.0:python.
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 |