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