Functional Weave
Code in Python

finance.ledger.journal-validate@1.0.0

impl/typescript.ts

3,000 bytes · the TypeScript 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.

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 };
}