Skip to content

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