Document Intake and Routing
Module 17 · business · intermediate
rules.js
// Free, instant classification: keyword scores per document type + regex field extraction.
// Good enough for clear documents; the AI step handles the rest.
const TYPES = {
invoice: ['invoice', 'bill to', 'amount due', 'total due', 'due date', 'remit', 'net 30', 'invoice number'],
receipt: ['receipt', 'paid by', 'thank you', 'change due', 'cashier', 'card ending'],
contract: ['agreement', 'by and between', 'governing law', 'termination', 'witness whereof', 'effective date', 'confidential', 'parties'],
bank_statement: ['statement period', 'opening balance', 'closing balance', 'account number', 'account holder', 'debit', 'credit'],
purchase_order: ['purchase order', 'po number', 'ship to', 'vendor', 'order date'],
tax_form: ['form w-9', 'form 1099', 'taxpayer identification', 'internal revenue service', 'tax year', 'form w-2'],
};
const MONTHS = 'January|February|March|April|May|June|July|August|September|October|November|December';
const ourNames = $('Settings (edit me)').first().json.our_names.map((n) => n.toLowerCase());
const isUs = (name) => ourNames.some((n) => name.toLowerCase().includes(n));
const text = String($input.first().json.text ?? '');
const lower = text.toLowerCase();
// --- classify
const scores = Object.fromEntries(Object.entries(TYPES).map(([t, words]) => [t, words.filter((w) => lower.includes(w)).length]));
const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]);
const [[topType, top], [, second]] = ranked;
const confidence = top === 0 ? 0 : Math.round((top / (top + second)) * Math.min(1, top / 3) * 100) / 100;
const docType = top >= 2 ? topType : 'other';
// --- extract fields
const toISO = (s) => {
const d = [DateTime.fromISO(s), DateTime.fromFormat(s, 'LLLL d, yyyy'), DateTime.fromFormat(s, 'MM/dd/yyyy')].find((x) => x.isValid);
return d ? d.toISODate() : null;
};
const dateRe = new RegExp(`(\\d{4}-\\d{2}-\\d{2}|(?:${MONTHS}) \\d{1,2}, \\d{4}|\\d{2}/\\d{2}/\\d{4})`);
// Statements: use the period END date; otherwise the first labeled date, else the first date
const periodEnd = text.match(new RegExp(`statement period[^\\n]*?to\\s+${dateRe.source}`, 'i'));
const labeled = text.match(new RegExp(`(?:invoice date|date|as of|dated)[:\\s]+${dateRe.source}`, 'i'));
const anyDate = text.match(dateRe);
const documentDate = toISO((periodEnd ?? labeled ?? anyDate)?.[1] ?? '') ?? null;
// Amount: closing balance (statements) > last "total" that isn't a subtotal > largest $ amount
const num = (m) => Number(m.replace(/,/g, ''));
const closing = text.match(/closing balance[^\d$\n]*\$?\s?([\d,]+\.\d{2})/i);
const totals = [...text.matchAll(/(?<!sub)total(?: due)?[^\d$\n]*\$?\s?([\d,]+\.\d{2})/gi)];
const amounts = [...text.matchAll(/\$\s?([\d,]+\.\d{2})/g)].map((m) => num(m[1]));
const amount = closing ? num(closing[1]) : totals.length ? num(totals.at(-1)[1]) : amounts.length ? Math.max(...amounts) : null;
const refMatch = text.match(/(?:invoice (?:number|no\.?|#)|agreement no\.?|contract no\.?|receipt #|po number|account number)[:\s#]*([A-Z0-9][A-Z0-9-]{2,})/i);
// Counterparty: for contracts, the party that isn't us; otherwise the letterhead (first line that isn't us)
const between = text.match(/by and between ([^("\n]+?)\s*\([^)]*\)\s*and\s+([^("\n]+?)\s*\(/i);
const firstLine = text.split('\n').map((l) => l.trim()).find((l) => l.length > 2 && !isUs(l)) ?? '';
const raw = docType === 'contract' && between ? [between[1], between[2]].map((x) => x.trim()).find((x) => !isUs(x)) ?? between[1] : firstLine;
// "NORTHWIND OFFICE SUPPLY LLC" → "Northwind Office Supply LLC"
const SUFFIXES = ['LLC', 'LLP', 'INC', 'INC.', 'LTD', 'GMBH', 'CO.', 'PLC'];
const tidy = (n) => (n === n.toUpperCase() ? n.split(' ').map((w) => (SUFFIXES.includes(w) ? w : w.charAt(0) + w.slice(1).toLowerCase())).join(' ') : n);
const counterparty = tidy(raw.slice(0, 60));
return [{
json: {
text_length: text.length,
text: text.slice(0, 6000),
rules: {
doc_type: docType,
confidence,
scores,
fields: { document_date: documentDate, counterparty, reference: refMatch?.[1] ?? null, amount },
},
},
}];