Functional Weave
Code in Python

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 VAT
  • validate_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 much
  • validate_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
linesJournalLine[]the journal's lines, in the order they were entered
currencystringthe journal's currency; every amount must be in it
returnsJournalCheck

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
impl/python.py · 74 lines · open · raw

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
Download for Python finance.ledger.journal-validate-1.0.0-python.fune · 20,799 bytes sha256 ea7098b8bffcb82edfa22cdac12c6f9a194e09599024c67763b8412bc8e9680a

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md1,922
impl/python.py3,049
impl/rust.rs5,040
impl/typescript.ts3,000
vectors.json10,391