Functional Weave
Code in Rust

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.

pub fn validate_journal(lines: &[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

/// 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(…)
impl/rust.rs · 143 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.

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
Download for Rust finance.ledger.journal-validate-1.0.0-rust.fune · 22,855 bytes sha256 fcb2e8b763a75483c3f68f9e334cc413ac0e934c42455d0dbc5707d95da233d7

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.

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