finance.bank-reconcile-match@1.0.0
impl/typescript.ts
3,801 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 { daysBetween } from "./dates_days_between.ts"; ← from dates.days-between ^1.0.0 · built alongside by fune
import { roundDiv } from "./math_round_div.ts"; ← from math.round-div ^1.0.0 · built alongside by fune
import { type ReconcileLine, type ReconcileMatch, type Reconciliation } from "./finance_bank_reconcile_match_types.ts";
function reduce(reference: string): string {
let out = "";
for (let i = 0; i < reference.length; i += 1) {
const code = reference.charCodeAt(i);
if ((code >= 48 && code <= 57) || (code >= 65 && code <= 90)) out += reference[i];
else if (code >= 97 && code <= 122) out += String.fromCharCode(code - 32);
}
return out;
}
function bigrams(text: string): string[] {
const out: string[] = [];
for (let i = 0; i + 1 < text.length; i += 1) out.push(text.slice(i, i + 2));
return out;
}
/** Sørensen-Dice on adjacent character pairs, 0 to 10000, rounded down. */
function similarity(a: string, b: string): number {
const x = reduce(a);
const y = reduce(b);
if (x.length > 0 && x === y) return 10000;
const left = bigrams(x);
const right = bigrams(y);
if (left.length === 0 || right.length === 0) return 0;
const used = right.map(() => false);
let shared = 0;
for (const pair of left) {
const at = right.findIndex((other, i) => !used[i] && other === pair);
if (at >= 0) {
used[at] = true;
shared += 1;
}
}
return roundDiv(2 * shared * 10000, left.length + right.length, "down");
}
function checkIds(lines: readonly ReconcileLine[], side: string): void {
const seen = new Set<string>();
for (const line of lines) {
if (seen.has(line.id)) throw new RangeError(`duplicate ${side} id "${line.id}"`);
seen.add(line.id);
}
}
/**
* Pair bank lines with ledger entries: equal amount, dates within the window,
* references alike enough, best pairs first and ties broken by input order.
*/
export function matchBankLines(
bankLines: readonly ReconcileLine[],
ledgerEntries: readonly ReconcileLine[],
dateWindowDays: number,
minSimilarity: number,
): Reconciliation {
if (!Number.isInteger(dateWindowDays) || dateWindowDays < 0) {
throw new RangeError(`dateWindowDays must be 0 or more, received ${dateWindowDays}`);
}
if (!Number.isInteger(minSimilarity) || minSimilarity < 0 || minSimilarity > 10000) {
throw new RangeError(`minSimilarity must be 0 to 10000, received ${minSimilarity}`);
}
checkIds(bankLines, "bank line");
checkIds(ledgerEntries, "ledger entry");
const candidates: { b: number; l: number; days: number; score: number }[] = [];
bankLines.forEach((bank, b) => {
ledgerEntries.forEach((entry, l) => {
const days = daysBetween(entry.date, bank.date);
if (bank.amount.currency !== entry.amount.currency || bank.amount.minor !== entry.amount.minor) return;
if (Math.abs(days) > dateWindowDays) return;
const score = similarity(bank.reference, entry.reference);
if (score < minSimilarity) return;
candidates.push({ b, l, days, score });
});
});
candidates.sort((p, q) => q.score - p.score || Math.abs(p.days) - Math.abs(q.days) || p.b - q.b || p.l - q.l);
const bankMatch: (ReconcileMatch | null)[] = bankLines.map(() => null);
const ledgerUsed = ledgerEntries.map(() => false);
for (const c of candidates) {
if (bankMatch[c.b] !== null || ledgerUsed[c.l]) continue;
bankMatch[c.b] = { bankId: bankLines[c.b].id, ledgerId: ledgerEntries[c.l].id, dayDifference: c.days, similarity: c.score };
ledgerUsed[c.l] = true;
}
const matches: ReconcileMatch[] = [];
const unmatchedBank: string[] = [];
bankLines.forEach((bank, b) => {
const m = bankMatch[b];
if (m === null) unmatchedBank.push(bank.id);
else matches.push(m);
});
const unmatchedLedger = ledgerEntries.filter((_, l) => !ledgerUsed[l]).map((e) => e.id);
return { matches, unmatchedBank, unmatchedLedger };
}