Document Intake and Routing
Module 17 · business · intermediate
starter.json
In n8n, copy this file and paste it onto the canvas, or use Import from File.
{
"name": "17 - Document Intake and Routing (starter)",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "docs/intake",
"responseMode": "responseNode",
"options": {}
},
"id": "m17-webhook",
"name": "New Document",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
200
],
"webhookId": "course-17-docs-intake"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "s1",
"name": "review_threshold",
"value": 0.6,
"type": "number"
},
{
"id": "s2",
"name": "folders",
"value": "={{ { invoice: 'FOLDER_ID_INVOICES', receipt: 'FOLDER_ID_RECEIPTS', contract: 'FOLDER_ID_CONTRACTS', bank_statement: 'FOLDER_ID_BANK_STATEMENTS', purchase_order: 'FOLDER_ID_PURCHASE_ORDERS', tax_form: 'FOLDER_ID_TAX', needs_review: 'FOLDER_ID_NEEDS_REVIEW' } }}",
"type": "object"
},
{
"id": "s3",
"name": "our_names",
"value": "={{ [\"Harbor Law Group\"] }}",
"type": "array"
}
]
},
"includeOtherFields": true,
"options": {}
},
"id": "m17-settings",
"name": "Settings (edit me)",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
220,
200
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "p1",
"leftValue": "={{ Object.values($('New Document').first().binary ?? {}).some(f => f.mimeType === 'application/pdf') }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m17-haspdf",
"name": "Has PDF?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
440,
200
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { error: 'upload one PDF as multipart form-data, e.g. curl -F file=@document.pdf' } }}",
"options": {
"responseCode": 400
}
},
"id": "m17-400",
"name": "Respond No PDF",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
660,
380
]
},
{
"parameters": {
"operation": "pdf",
"binaryPropertyName": "={{ Object.keys($('New Document').first().binary).find(k => $('New Document').first().binary[k].mimeType === 'application/pdf') }}",
"options": {
"joinPages": true,
"maxPages": 5
}
},
"id": "m17-extract",
"name": "Extract PDF Text",
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
660,
120
]
},
{
"parameters": {
"jsCode": "// Free, instant classification: keyword scores per document type + regex field extraction.\n// Good enough for clear documents; the AI step handles the rest.\nconst TYPES = {\n invoice: ['invoice', 'bill to', 'amount due', 'total due', 'due date', 'remit', 'net 30', 'invoice number'],\n receipt: ['receipt', 'paid by', 'thank you', 'change due', 'cashier', 'card ending'],\n contract: ['agreement', 'by and between', 'governing law', 'termination', 'witness whereof', 'effective date', 'confidential', 'parties'],\n bank_statement: ['statement period', 'opening balance', 'closing balance', 'account number', 'account holder', 'debit', 'credit'],\n purchase_order: ['purchase order', 'po number', 'ship to', 'vendor', 'order date'],\n tax_form: ['form w-9', 'form 1099', 'taxpayer identification', 'internal revenue service', 'tax year', 'form w-2'],\n};\nconst MONTHS = 'January|February|March|April|May|June|July|August|September|October|November|December';\n\nconst ourNames = $('Settings (edit me)').first().json.our_names.map((n) => n.toLowerCase());\nconst isUs = (name) => ourNames.some((n) => name.toLowerCase().includes(n));\nconst text = String($input.first().json.text ?? '');\nconst lower = text.toLowerCase();\n\n// --- classify\nconst scores = Object.fromEntries(Object.entries(TYPES).map(([t, words]) => [t, words.filter((w) => lower.includes(w)).length]));\nconst ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]);\nconst [[topType, top], [, second]] = ranked;\nconst confidence = top === 0 ? 0 : Math.round((top / (top + second)) * Math.min(1, top / 3) * 100) / 100;\nconst docType = top >= 2 ? topType : 'other';\n\n// --- extract fields\nconst toISO = (s) => {\n const d = [DateTime.fromISO(s), DateTime.fromFormat(s, 'LLLL d, yyyy'), DateTime.fromFormat(s, 'MM/dd/yyyy')].find((x) => x.isValid);\n return d ? d.toISODate() : null;\n};\nconst dateRe = new RegExp(`(\\\\d{4}-\\\\d{2}-\\\\d{2}|(?:${MONTHS}) \\\\d{1,2}, \\\\d{4}|\\\\d{2}/\\\\d{2}/\\\\d{4})`);\n// Statements: use the period END date; otherwise the first labeled date, else the first date\nconst periodEnd = text.match(new RegExp(`statement period[^\\\\n]*?to\\\\s+${dateRe.source}`, 'i'));\nconst labeled = text.match(new RegExp(`(?:invoice date|date|as of|dated)[:\\\\s]+${dateRe.source}`, 'i'));\nconst anyDate = text.match(dateRe);\nconst documentDate = toISO((periodEnd ?? labeled ?? anyDate)?.[1] ?? '') ?? null;\n\n// Amount: closing balance (statements) > last \"total\" that isn't a subtotal > largest $ amount\nconst num = (m) => Number(m.replace(/,/g, ''));\nconst closing = text.match(/closing balance[^\\d$\\n]*\\$?\\s?([\\d,]+\\.\\d{2})/i);\nconst totals = [...text.matchAll(/(?<!sub)total(?: due)?[^\\d$\\n]*\\$?\\s?([\\d,]+\\.\\d{2})/gi)];\nconst amounts = [...text.matchAll(/\\$\\s?([\\d,]+\\.\\d{2})/g)].map((m) => num(m[1]));\nconst amount = closing ? num(closing[1]) : totals.length ? num(totals.at(-1)[1]) : amounts.length ? Math.max(...amounts) : null;\n\nconst refMatch = text.match(/(?:invoice (?:number|no\\.?|#)|agreement no\\.?|contract no\\.?|receipt #|po number|account number)[:\\s#]*([A-Z0-9][A-Z0-9-]{2,})/i);\n// Counterparty: for contracts, the party that isn't us; otherwise the letterhead (first line that isn't us)\nconst between = text.match(/by and between ([^(\"\\n]+?)\\s*\\([^)]*\\)\\s*and\\s+([^(\"\\n]+?)\\s*\\(/i);\nconst firstLine = text.split('\\n').map((l) => l.trim()).find((l) => l.length > 2 && !isUs(l)) ?? '';\nconst raw = docType === 'contract' && between ? [between[1], between[2]].map((x) => x.trim()).find((x) => !isUs(x)) ?? between[1] : firstLine;\n// \"NORTHWIND OFFICE SUPPLY LLC\" → \"Northwind Office Supply LLC\"\nconst SUFFIXES = ['LLC', 'LLP', 'INC', 'INC.', 'LTD', 'GMBH', 'CO.', 'PLC'];\nconst tidy = (n) => (n === n.toUpperCase() ? n.split(' ').map((w) => (SUFFIXES.includes(w) ? w : w.charAt(0) + w.slice(1).toLowerCase())).join(' ') : n);\nconst counterparty = tidy(raw.slice(0, 60));\n\nreturn [{\n json: {\n text_length: text.length,\n text: text.slice(0, 6000),\n rules: {\n doc_type: docType,\n confidence,\n scores,\n fields: { document_date: documentDate, counterparty, reference: refMatch?.[1] ?? null, amount },\n },\n },\n}];\n"
},
"id": "m17-rules",
"name": "Rule Classifier",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
120
]
},
{
"parameters": {
"content": "## Your task\nUpload a sample with `?dry_run=true` and read **Rule Classifier**: type, confidence, fields.\n1. **Use AI?** → **AI Classify** (Information Extractor + OpenAI, *On Error: Continue*) for doc_type, confidence, counterparty, date, reference, amount, summary.\n2. **Decide Route** (Code): AI result, else rules; needs_review if type is other or confidence < threshold; build the new filename and pick the folder.\n3. Dry run → respond. Otherwise **Attach File** (re-attach the original PDF with the new name) → **Google Drive** upload to the folder (*On Error: Continue*) → report → respond; alert a human for reviews/failures.\n\nSamples: assets/samples/*.pdf · Test: `./test/test.sh`",
"height": 260,
"width": 420
},
"id": "starter-task-note",
"name": "Your Task",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
0,
-340
]
}
],
"connections": {
"New Document": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Settings (edit me)": {
"main": [
[
{
"node": "Has PDF?",
"type": "main",
"index": 0
}
]
]
},
"Has PDF?": {
"main": [
[
{
"node": "Extract PDF Text",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond No PDF",
"type": "main",
"index": 0
}
]
]
},
"Extract PDF Text": {
"main": [
[
{
"node": "Rule Classifier",
"type": "main",
"index": 0
}
]
]
},
"Rule Classifier": {
"main": [
[]
]
}
},
"pinData": {},
"settings": {
"executionOrder": "v1"
}
}