Document Intake and Routing
Module 17 · business · intermediate
learner.json
In n8n, copy this file and paste it onto the canvas, or use Import from File.
{
"name": "17 - Document Intake and Routing",
"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": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "a1",
"leftValue": "={{ $(\"New Document\").first().json.query.dry_run !== \"true\" && $(\"New Document\").first().json.query.ai !== \"false\" && $json.text_length >= 20 }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m17-useai",
"name": "Use AI?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1100,
120
]
},
{
"parameters": {
"text": "={{ $json.text }}",
"attributes": {
"attributes": [
{
"name": "doc_type",
"description": "Exactly one of: invoice, receipt, contract, bank_statement, purchase_order, tax_form, other",
"required": true
},
{
"name": "confidence",
"type": "number",
"description": "0 to 1: how sure you are about doc_type",
"required": true
},
{
"name": "counterparty",
"description": "The other company or person this document is from or with (vendor, bank, contracting party). Not our own firm.",
"required": false
},
{
"name": "document_date",
"description": "Main date of the document as YYYY-MM-DD (invoice date, signing/effective date, statement end date)",
"required": false
},
{
"name": "reference",
"description": "Invoice number, agreement number, receipt number, or masked account number",
"required": false
},
{
"name": "amount",
"type": "number",
"description": "Total amount or monthly fee as a plain number, if any",
"required": false
},
{
"name": "summary",
"description": "One sentence describing the document",
"required": true
}
]
},
"options": {
"systemPromptTemplate": "You classify and index incoming business documents for an office. Use only information in the document. If unsure about the type, use 'other' with low confidence."
}
},
"id": "m17-ai",
"name": "AI Classify",
"type": "@n8n/n8n-nodes-langchain.informationExtractor",
"typeVersion": 1.2,
"position": [
1320,
40
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"model": {
"__rl": true,
"mode": "list",
"value": "gpt-5-mini",
"cachedResultName": "gpt-5-mini"
},
"options": {}
},
"id": "m17-model",
"name": "OpenAI Chat Model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.3,
"position": [
1320,
260
]
},
{
"parameters": {
"jsCode": "// Combine rules (+ AI when it ran) into one routing decision: type, fields, new filename, target folder.\nconst s = $('Settings (edit me)').first().json;\nconst req = $('New Document').first().json;\nconst r = $('Rule Classifier').first().json.rules;\n// AI Classify runs with \"On Error: Continue\": if it fails (no key, outage), fall back to the rules.\nconst aiRan = $('AI Classify').isExecuted;\nconst aiItem = aiRan ? $input.first().json : null;\nconst ai = aiItem && !aiItem.error && aiItem.output ? aiItem.output : null;\nconst aiError = aiItem && !ai ? (typeof aiItem.error === 'string' ? aiItem.error : aiItem.error?.message ?? 'AI step returned no output (check the OpenAI credential)') : null;\n\nconst LABELS = { invoice: 'Invoice', receipt: 'Receipt', contract: 'Contract', bank_statement: 'Bank Statement', purchase_order: 'Purchase Order', tax_form: 'Tax Form', other: 'Document' };\nconst pickType = (t) => (LABELS[t] ? t : 'other');\n\nconst docType = ai ? pickType(String(ai.doc_type ?? '').toLowerCase()) : r.doc_type;\nconst confidence = ai ? Math.max(0, Math.min(1, Number(ai.confidence) || 0)) : r.confidence;\nconst fields = {\n document_date: (ai && ai.document_date) || r.fields.document_date,\n counterparty: (ai && ai.counterparty) || r.fields.counterparty,\n reference: (ai && ai.reference) || r.fields.reference,\n amount: ai && ai.amount != null && ai.amount !== '' ? Number(ai.amount) : r.fields.amount,\n};\n\nconst textEmpty = $('Rule Classifier').first().json.text_length < 20;\nconst needsReview = textEmpty || docType === 'other' || confidence < s.review_threshold;\nconst reason = textEmpty ? 'no text layer (scanned?)' : docType === 'other' ? 'unknown document type' : confidence < s.review_threshold ? `low confidence (${confidence})` : null;\n\n// \"2026-09-15 - Invoice - Northwind Office Supply LLC - NW-55821.pdf\"\nconst clean = (x) => String(x ?? '').replace(/[\\\\/:*?\"<>|\\n\\r]+/g, ' ').replace(/\\s+/g, ' ').trim().slice(0, 60);\nconst originalName = Object.values($('New Document').first().binary ?? {})[0]?.fileName ?? 'document.pdf';\nconst filename = needsReview\n ? `REVIEW - ${clean(originalName.replace(/\\.pdf$/i, ''))}.pdf`\n : [fields.document_date ?? DateTime.now().toISODate(), LABELS[docType], clean(fields.counterparty), clean(fields.reference)].filter(Boolean).join(' - ') + '.pdf';\nconst folderKey = needsReview ? 'needs_review' : docType;\n\nreturn [{\n json: {\n doc_type: docType,\n type_label: LABELS[docType],\n confidence,\n method: ai ? 'ai' : 'rules',\n ai_error: aiError,\n needs_review: needsReview,\n review_reason: reason,\n fields,\n summary: ai?.summary ?? null,\n filename,\n folder_key: folderKey,\n folder_id: s.folders[folderKey],\n original_name: originalName,\n dry_run: req.query?.dry_run === 'true',\n },\n}];\n"
},
"id": "m17-decide",
"name": "Decide Route",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1540,
120
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "d1",
"leftValue": "={{ $json.dry_run }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m17-dry",
"name": "Dry Run?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1760,
120
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"id": "m17-respond-dry",
"name": "Respond Decision",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1980,
0
]
},
{
"parameters": {
"jsCode": "// Put the original upload back on the item (as \"data\", with the new filename) for the Drive upload.\n// Binary data doesn't survive Code/AI nodes on its own, so we read it from the webhook node.\nconst decision = $input.first().json;\nconst original = Object.values($('New Document').first().binary ?? {})[0];\nreturn [{ json: decision, binary: { data: { ...original, fileName: decision.filename } } }];\n"
},
"id": "m17-attach",
"name": "Attach File",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1980,
220
]
},
{
"parameters": {
"inputDataFieldName": "data",
"name": "={{ $json.filename }}",
"driveId": {
"__rl": true,
"mode": "list",
"value": "My Drive"
},
"folderId": {
"__rl": true,
"mode": "id",
"value": "={{ $json.folder_id }}"
},
"options": {}
},
"id": "m17-upload",
"name": "Upload to Drive",
"type": "n8n-nodes-base.googleDrive",
"typeVersion": 3,
"position": [
2200,
220
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "// Report what happened. The upload runs with \"On Error: Continue\"; success requires a file id.\nconst decision = $('Decide Route').first().json;\nconst up = $input.first().json;\nconst uploaded = !up.error && Boolean(up.id);\nconst err = typeof up.error === 'string' ? up.error : up.error?.message ?? null;\nreturn [{\n json: {\n ...decision,\n uploaded,\n file_id: uploaded ? up.id : null,\n file_link: uploaded ? up.webViewLink ?? null : null,\n upload_error: uploaded ? null : err ?? 'no file id returned (check the Google Drive credential and folder ids)',\n },\n}];\n"
},
"id": "m17-result",
"name": "Upload Result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2420,
220
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"id": "m17-respond",
"name": "Respond Result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
2640,
140
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r1",
"leftValue": "={{ $json.needs_review || !$json.uploaded }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m17-review",
"name": "Needs a Human?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
2640,
320
]
},
{
"parameters": {},
"id": "m17-alert",
"name": "Notify Office (Exercise 1)",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
2860,
320
]
}
],
"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": [
[
{
"node": "Use AI?",
"type": "main",
"index": 0
}
]
]
},
"Use AI?": {
"main": [
[
{
"node": "AI Classify",
"type": "main",
"index": 0
}
],
[
{
"node": "Decide Route",
"type": "main",
"index": 0
}
]
]
},
"OpenAI Chat Model": {
"ai_languageModel": [
[
{
"node": "AI Classify",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"AI Classify": {
"main": [
[
{
"node": "Decide Route",
"type": "main",
"index": 0
}
]
]
},
"Decide Route": {
"main": [
[
{
"node": "Dry Run?",
"type": "main",
"index": 0
}
]
]
},
"Dry Run?": {
"main": [
[
{
"node": "Respond Decision",
"type": "main",
"index": 0
}
],
[
{
"node": "Attach File",
"type": "main",
"index": 0
}
]
]
},
"Attach File": {
"main": [
[
{
"node": "Upload to Drive",
"type": "main",
"index": 0
}
]
]
},
"Upload to Drive": {
"main": [
[
{
"node": "Upload Result",
"type": "main",
"index": 0
}
]
]
},
"Upload Result": {
"main": [
[
{
"node": "Respond Result",
"type": "main",
"index": 0
},
{
"node": "Needs a Human?",
"type": "main",
"index": 0
}
]
]
},
"Needs a Human?": {
"main": [
[
{
"node": "Notify Office (Exercise 1)",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"settings": {
"executionOrder": "v1"
}
}