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