Functional Weave
Code in Python

finance.ledger.journal-validate@1.0.0

impl/python.py

3,049 bytes · the Python implementation · view 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,
    )