AutomationFlowsAI & RAG › Automated Invoice Payment Tracking with Ocr, Claude Ai, Slack & Notion Db

Automated Invoice Payment Tracking with Ocr, Claude Ai, Slack & Notion Db

ByHan @han on n8n.io

This workflow automates Invoice & Payment Tracking (with Approvals) across Notion and Slack. Ingest — You drop invoices/receipts (PDF/IMG/JSON) into the flow. Extract — OCR + parsing pulls out key fields (invoice no, vendor, currency, totals, receipt paid amount/date). De-dup &…

Event trigger★★★★★ complexityAI-powered92 nodesHTTP RequestChain LlmAnthropic ChatSlack TriggerNotionSlackStop And ErrorError Trigger
AI & RAG Trigger: Event Nodes: 92 Complexity: ★★★★★ AI nodes: yes Added:

This workflow corresponds to n8n.io template #7773 — we link there as the canonical source.

This workflow follows the Chainllm → HTTP Request recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "nodes": [
    {
      "id": "909b0322-55e4-4c3d-a5a9-9392d5bbf745",
      "name": "Format Check",
      "type": "n8n-nodes-base.code",
      "position": [
        -5808,
        1744
      ],
      "parameters": {
        "jsCode": "const files = $('Slack Trigger').first().json.files || [];\nconst results = [];\n\nfor (const file of files) {\n  const mimetype = file.mimetype || \"\";\n  const fileType = mimetype.split('/').pop().toLowerCase();\n  const fileName = file.name || file.title || \"unknown\";\n\n  const isImage = ['jpg', 'jpeg', 'png', 'webp'].includes(fileType);\n  const isDocument = ['pdf', 'doc', 'docx', 'txt', 'rtf', 'eml'].includes(fileType);\n  const isEmail = fileType === 'eml' || fileName.endsWith('.eml');\n\n  results.push({\n    json: {\n      ...file, // keep Slack file data like url_private_download\n      fileType,\n      mimetype,\n      isImage,\n      isDocument,\n      isEmail,\n    }\n  });\n}\n\nreturn results;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "ca8559e4-b29b-4a1a-b113-e70bba47f23a",
      "name": "Check Format",
      "type": "n8n-nodes-base.switch",
      "position": [
        -5584,
        1744
      ],
      "parameters": {
        "rules": {
          "values": [
            {
              "outputKey": "Image",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "f5d6e124-c68e-42bb-a9bb-c97d54b09143",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    },
                    "leftValue": "={{ $json.isImage }}",
                    "rightValue": "true"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "Document",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "87759194-16d1-4593-8c9b-016aa7be4e03",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    },
                    "leftValue": "={{ $json.isDocument }}",
                    "rightValue": "true"
                  }
                ]
              },
              "renameOutput": true
            }
          ]
        },
        "options": {
          "allMatchingOutputs": true
        }
      },
      "typeVersion": 3.2
    },
    {
      "id": "6543e986-8860-457d-a8de-bb052718e9c6",
      "name": "Take Binary Files for Document",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -5360,
        1840
      ],
      "parameters": {
        "url": "={{ $json.url_private_download }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file"
            }
          }
        },
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "slackApi"
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "af88d22a-5a2d-4471-9ac9-b1cb9531bd35",
      "name": "OCR Space Parse1",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -5136,
        1840
      ],
      "parameters": {
        "url": "https://api.ocr.space/parse/image",
        "method": "POST",
        "options": {
          "timeout": 100000
        },
        "sendBody": true,
        "contentType": "multipart-form-data",
        "authentication": "genericCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "filetype",
              "value": "PDF"
            },
            {
              "name": "file",
              "parameterType": "formBinaryData",
              "inputDataFieldName": "data"
            },
            {
              "name": "scale",
              "value": "true"
            },
            {
              "name": "OCREngine",
              "value": "2"
            }
          ]
        },
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "waitBetweenTries": 5000
    },
    {
      "id": "ecb68486-32af-41e3-a9a7-86c763205061",
      "name": "Code",
      "type": "n8n-nodes-base.code",
      "position": [
        -4688,
        1936
      ],
      "parameters": {
        "jsCode": "// $input.all() gives you the 3 separate OCR result items\nconst inputItems = $input.all();\n\nreturn inputItems.map((item, index) => {\n  const parsedResults = item.json.ParsedResults || [];\n\n  // Join all ParsedText per result into one string\n  const mergedText = parsedResults\n    .map(p => p.ParsedText || \"\")\n    .join(\"\\n\\n\")\n    .trim();\n\n  return {\n    json: {\n      index: index + 1,\n      mergedParsedText: mergedText\n    }\n  };\n});\n"
      },
      "typeVersion": 2
    },
    {
      "id": "acb3e33c-59de-4cf3-a008-a47af9673abb",
      "name": "Check parsing Error3",
      "type": "n8n-nodes-base.if",
      "position": [
        -4912,
        1840
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "ddbbbfbb-e05b-4ca9-b607-65ad8ac748bb",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $('OCR Space Parse1').item.json.IsErroredOnProcessing }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "96e61695-6dfd-4cf4-8064-aea9d80284f0",
      "name": "Basic LLM Chain",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "position": [
        -4240,
        1888
      ],
      "parameters": {
        "text": "=Extract the invoice fields from this OCR text and populate the EXACT schema.\n\nTARGET SCHEMA:\n{\n  \"invoice_no\": \"\",\n  \"vendor\": \"\",\n  \"issue_date\": \"YYYY-MM-DD\",\n  \"due_date\": \"YYYY-MM-DD\",\n  \"currency\": \"\",\n  \"subtotal\": 0,\n  \"tax_total\": 0,\n  \"discount_total\": 0,\n  \"discount_percent\": 0,\n  \"amount_total\": 0,\n\n  \"amount_due\": 0,          // if shown (a.k.a. Balance Due); else 0\n  \"paid_amount\": 0,         // if shown or implied; else 0\n  \"paid_date\": \"\",          // if shown; else \"\"\n  \"receipt_no\": \"\",         // if shown; else \"\"\n  \"payment_method\": \"\",     // if shown; else \"\"\n\n  \"destination_account\": \"\",\n  \"payment_ref\": \"\",\n  \"doc_says_paid\": false,\n  \"status\": \"\",\n  \"notes\": \"\",\n  \"source_file_id\": \"\",\n  \"source_file_name\": \"\",\n  \"source_file_url\": \"\",\n  \"ingestion_batch\": \"\",\n  \"line_items\": [],\n  \"attachments\": []\n}\n\nOCR TEXT:\n{{ $json.OCRResult }}\n",
        "batching": {},
        "messages": {
          "messageValues": [
            {
              "message": "=You are an extraction engine for US invoices. Output EXACTLY the JSON fields of the target schema\u2014no extra keys, no comments, no markdown. Do not invent values.\n\nGeneral rules\n- Locale: US. Normalize dates to YYYY-MM-DD.\n- Numbers: plain decimals (no symbols/commas).\n- currency: infer from symbol/labels when present (\u201c$\u201d \u21d2 \u201cUSD\u201d), else \"\".\n- If a field is unavailable: use \"\" for strings, 0 for numbers, false for booleans, [] for arrays.\n\nDiscount fields\n- If the doc shows a percent (e.g., \u201cDiscount 49%\u201d), set discount_percent to the decimal (0.49) and discount_total to 0 unless a currency discount is also printed.\n- If the doc shows a currency discount (e.g., \u201cDiscount $60.27\u201d), set discount_total to that amount; discount_percent is 0 unless the percent is explicitly printed too.\n- If both forms are printed, fill both.\n\nTotals\n- amount_total: prefer the document\u2019s explicit \u201cAmount due/Total/Total amount/Grand total\u201d.\n- If not printed but subtotal, discount_* and tax_total are present, compute:\n  amount_total = subtotal \u2212 (discount_total OR subtotal \u00d7 discount_percent) + tax_total.\n- Otherwise leave amount_total = 0.\n\nPartial payments\n- If the doc shows Amount due / Balance due:\n  \u2022 Set amount_due to that value.\n  \u2022 If amount_total is known and amount_due < amount_total, set paid_amount = amount_total \u2212 amount_due (\u2265 0).\n- If the doc shows Amount paid / Payment history:\n  \u2022 Sum payments and set paid_amount.\n  \u2022 If amount_due is not shown but amount_total is known, set amount_due = max(amount_total \u2212 paid_amount, 0).\n- If a paid date, receipt number, or payment method is explicitly shown, set paid_date, receipt_no, payment_method; otherwise leave them empty.\n- Clamp impossible values (no negatives; when amount_total is known, neither paid_amount nor amount_due may exceed it).\n\nDocument-based status\n- If amount_due == 0 AND the document states PAID (or shows \u201cAmount due $0.00\u201d): status = \"Paid (Unverified)\".\n- Else if paid_amount > 0 AND amount_due > 0: status = \"Partially Paid\".\n- Else: status = \"Unpaid\".\n- Set doc_says_paid = true only if the document itself explicitly says \u201cPAID\u201d or shows \u201cAmount due $0.00\u201d; otherwise false.\n\nLine items\n- line_items: include when clearly listed, as\n  [{\"description\": string, \"qty\": number, \"unit_price\": number, \"amount\": number}], else [].\n\nAttachments\n- attachments: [] unless explicit links are present.\n\nReturn ONLY the JSON object matching the target schema.\n"
            }
          ]
        },
        "promptType": "define"
      },
      "retryOnFail": false,
      "typeVersion": 1.7
    },
    {
      "id": "0aaa9bdf-05b2-4dc1-ad6d-4de8926ef9f1",
      "name": "Anthropic Chat Model4",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "position": [
        -4240,
        2048
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "claude-3-5-haiku-20241022",
          "cachedResultName": "Claude Haiku 3.5"
        },
        "options": {}
      },
      "credentials": {
        "anthropicApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "54187b60-dabd-42a7-b5c9-d46199dbed73",
      "name": "Cleans AI Response",
      "type": "n8n-nodes-base.code",
      "position": [
        -3888,
        1888
      ],
      "parameters": {
        "jsCode": "// n8n Code node \u2014 Run Once for All Items\n// Repair + Enforce schema + attach Slack file info\n// Adds discount_percent support + derives discount_amount & stable amount_total\n// + Partial payment normalization (amount_due, paid_amount, paid_date, receipt_no, payment_method)\n\nfunction stripCodeFences(s) {\n  return String(s || \"\")\n    .replace(/```(?:json)?/gi, \"\")\n    .replace(/```/g, \"\")\n    .trim();\n}\n\nfunction findTopLevelJson(s) {\n  const str = String(s || \"\");\n  let start = -1, depth = 0;\n  for (let i = 0; i < str.length; i++) {\n    const ch = str[i];\n    if (start === -1) {\n      if (ch === \"{\" || ch === \"[\") { start = i; depth = 1; }\n    } else {\n      if (ch === \"{\" || ch === \"[\") depth++;\n      if (ch === \"}\" || ch === \"]\") depth--;\n      if (depth === 0) return str.slice(start, i + 1);\n    }\n  }\n  return null;\n}\n\nfunction safeParseAny(x) {\n  if (Array.isArray(x)) return x;\n  if (x && typeof x === \"object\" && (x.invoice_no || x.vendor || x.line_items)) return x;\n\n  let txt = \"\";\n  if (typeof x === \"string\") txt = x;\n  else if (typeof x?.text === \"string\") txt = x.text;\n  else if (typeof x?.output === \"string\") txt = x.output;\n  else if (typeof x?.response === \"string\") txt = x.response;\n  else if (typeof x?.content === \"string\") txt = x.content;\n  else if (typeof x?.message === \"string\") txt = x.message;\n  else txt = JSON.stringify(x ?? \"\");\n\n  txt = stripCodeFences(txt);\n\n  try { return JSON.parse(txt); } catch {}\n  const block = findTopLevelJson(txt);\n  if (!block) throw new Error(\"No JSON found in LLM output\");\n  return JSON.parse(block);\n}\n\n// coercers\nfunction num(v) {\n  if (v === \"\" || v == null || (typeof v === \"number\" && !isFinite(v))) return 0;\n  if (typeof v === \"number\") return v;\n  let s = String(v).trim();\n  if (!s) return 0;\n  // keep digits , . -\n  s = s.replace(/[^\\d,.\\-]/g, \"\");\n  if (s.includes(\",\") && s.includes(\".\")) s = s.replace(/,/g, \"\");\n  else if (s.includes(\",\") && !s.includes(\".\")) {\n    if ((s.match(/,/g) || []).length === 1 && /\\d,\\d{1,2}$/.test(s)) s = s.replace(\",\", \".\");\n    else s = s.replace(/,/g, \"\");\n  }\n  const n = Number(s);\n  return isFinite(n) ? n : 0;\n}\nconst str = (v) => (v == null ? \"\" : String(v).trim());\nconst bool = (v) => v === true;\nconst two = (n) => Math.round((Number(n) || 0) * 100) / 100;\n\n// parse percent from \"49%\", \"49\", 0.49, etc. \u2192 0..1\nfunction pct(v) {\n  if (v == null || v === \"\") return 0;\n  if (typeof v === \"string\" && v.includes(\"%\")) return Math.min(Math.max(num(v) / 100, 0), 1);\n  let n = num(v);\n  if (n > 1) n = n / 100;\n  if (n < 0) n = 0;\n  if (n > 1) n = 1;\n  return n;\n}\n\nfunction normalizeOne(obj, fileUrl) {\n  const line_items = Array.isArray(obj?.line_items)\n    ? obj.line_items.map(x => ({\n        description: str(x?.description),\n        qty: num(x?.qty),\n        unit_price: num(x?.unit_price),\n        amount: num(x?.amount)\n      }))\n    : [];\n\n  // base fields from LLM\n  const base = {\n    invoice_no: str(obj?.invoice_no),\n    vendor: str(obj?.vendor),\n    issue_date: str(obj?.issue_date),\n    due_date: str(obj?.due_date),\n    currency: str(obj?.currency),\n    subtotal: num(obj?.subtotal),\n    tax_total: num(obj?.tax_total),\n\n    // discounts (raw)\n    discount_total: num(obj?.discount_total),      // fixed amount if present\n    discount_percent: pct(obj?.discount_percent),  // normalized 0..1\n\n    amount_total: num(obj?.amount_total),\n\n    // payment/receipt extras (raw; normalized below)\n    amount_due: num(obj?.amount_due),\n    paid_amount: num(obj?.paid_amount),\n    paid_date: str(obj?.paid_date),\n    receipt_no: str(obj?.receipt_no),\n    payment_method: str(obj?.payment_method),\n\n    destination_account: str(obj?.destination_account),\n    payment_ref: str(obj?.payment_ref),\n    doc_says_paid: bool(obj?.doc_says_paid),\n    status: str(obj?.status),\n    notes: str(obj?.notes),\n    source_file_id: str(obj?.source_file_id),\n    source_file_name: str(obj?.source_file_name),\n    source_file_url: str(obj?.source_file_url),\n    ingestion_batch: str(obj?.ingestion_batch),\n    line_items,\n    attachments: Array.isArray(obj?.attachments) ? obj.attachments.map(str) : []\n  };\n\n  // ---- discount math ----\n  const discount_amount =\n    base.discount_total > 0\n      ? two(base.discount_total)\n      : two(base.subtotal * (base.discount_percent || 0));\n\n  // compute fallback amount_total\n  const calc_total = two(base.subtotal - discount_amount + base.tax_total);\n\n  // choose stable amount_total for pipeline (prefer LLM if sane; else calc)\n  const provided = base.amount_total;\n  const useProvided =\n    provided > 0 && Math.abs(provided - calc_total) <= Math.max(0.01, calc_total * 0.01);\n  base.amount_total = useProvided ? two(provided) : calc_total;\n\n  // Notion percent wants 0..1\n  base.discount_percent_for_notion = +(base.discount_percent || 0).toFixed(4);\n  base.discount_amount = discount_amount; // handy for dedup & debug\n  base._amount_total_source = useProvided ? \"provided\" : \"calculated\";\n\n  // ---- partial / payment normalization ----\n  const at = Number(base.amount_total || 0);\n  let due  = Number(base.amount_due || 0);\n  let paid = Number(base.paid_amount || 0);\n\n  // Derive missing pieces when amount_total is known\n  if (at > 0) {\n    if (paid > 0 && due <= 0) due = Math.max(0, two(at - paid));\n    if (due > 0 && paid <= 0) paid = Math.max(0, two(at - due));\n    // If doc says PAID and due is zero but paid missing, assume fully paid\n    if (base.doc_says_paid && due === 0 && paid <= 0) paid = at;\n    // Clamp impossible values\n    if (paid < 0) paid = 0;\n    if (due < 0) due = 0;\n    if (paid > at) paid = at;\n    if (due > at) due = at;\n    if (paid + due > at) due = Math.max(0, two(at - paid));\n  } else {\n    // amount_total unknown \u2014 still clamp non-negatives\n    if (paid < 0) paid = 0;\n    if (due  < 0) due  = 0;\n  }\n\n  base.paid_amount = two(paid);\n  base.amount_due  = two(due);\n\n  // Receipt hints payload for later routing/Notion Receipt DB\n  base.receipt_hints = {\n    paid_amount: base.paid_amount,\n    paid_date: base.paid_date,\n    receipt_no: base.receipt_no,\n    payment_method: base.payment_method\n  };\n\n  // Nudge status if inconsistent with amounts\n  if (at > 0) {\n    if (base.amount_due === 0 && (base.doc_says_paid || base.paid_amount >= at - 0.01)) {\n      base.status = \"Paid (Unverified)\";\n    } else if (base.paid_amount > 0 && base.amount_due > 0) {\n      base.status = \"Partially Paid\";\n    } else if (base.paid_amount === 0 && base.amount_due >= at - 0.01) {\n      base.status = \"Unpaid\";\n    }\n  }\n\n  // ---- Attach Slack file URL (if available) ----\n  if (fileUrl) {\n    let fileName = \"\", fileId = \"\";\n    try {\n      const u = new URL(fileUrl);\n      const seg = u.pathname.split(\"/\").filter(Boolean);\n      fileName = decodeURIComponent(seg[seg.length - 1] || \"\");\n      const m = u.pathname.match(/-(F[A-Z0-9]+)\\//i);\n      if (m) fileId = m[1];\n    } catch {\n      const m1 = fileUrl.match(/\\/download\\/([^?\\/#]+)($|\\?)/i);\n      if (m1) fileName = decodeURIComponent(m1[1]);\n      const m2 = fileUrl.match(/-(F[A-Z0-9]+)\\//i);\n      if (m2) fileId = m2[1];\n    }\n\n    base.source_file_url = fileUrl;\n    if (!base.source_file_name) base.source_file_name = fileName;\n    if (!base.source_file_id) base.source_file_id = fileId;\n\n    const atSet = new Set(base.attachments.map(String));\n    atSet.add(fileUrl);\n    base.attachments = Array.from(atSet);\n  }\n\n  // Guard status values\n  const allowedStatus = new Set([\n    \"Unpaid\",\"Paid (Unverified)\",\"Paid (Verified)\",\n    \"Partially Paid\",\"Overdue\",\"Void/Cancelled\",\"Duplicate\"\n  ]);\n  if (!allowedStatus.has(base.status)) base.status = \"Unpaid\";\n\n  // Basic date sanity\n  const dateOk = (s) => /^(\\d{4})-(\\d{2})-(\\d{2})$/.test(s);\n  if (base.issue_date && !dateOk(base.issue_date)) base.issue_date = \"\";\n  if (base.due_date && !dateOk(base.due_date)) base.due_date = \"\";\n\n  return base;\n}\n\n// --- Collect Slack file URLs from either node (PDF or Image lanes) ---\nfunction collectSlackUrls(nodeName) {\n  try {\n    return $(nodeName).all()\n      .map(it =>\n        String(\n          it.json?.url_private_download ||\n          it.json?.file?.url_private_download || // some Slack nodes nest under file\n          \"\"\n        )\n      )\n      .filter(Boolean);\n  } catch {\n    return [];\n  }\n}\n\nconst urlsDoc = collectSlackUrls('Take Binary Files for Document'); // PDF lane\nconst urlsImg = collectSlackUrls('Take Binary Files');              // Image lane\n\n// Pick a URL for item index i; prefer same index, then first available\nfunction pickFileUrl(i) {\n  return urlsDoc[i] || urlsImg[i] || urlsDoc[0] || urlsImg[0] || \"\";\n}\n\n// --- Process ALL incoming items; each may yield one or many outputs ---\nconst outputs = [];\n\nfor (const [idx, item] of $input.all().entries()) {\n  let parsed = safeParseAny(item.json);\n\n  if (parsed && !Array.isArray(parsed) && typeof parsed === \"object\") {\n    const maybeArr = parsed.data || parsed.items || parsed.result || parsed.invoices;\n    if (Array.isArray(maybeArr)) parsed = maybeArr;\n  }\n  if (!Array.isArray(parsed)) parsed = [parsed];\n\n  const fileUrlForThisItem = pickFileUrl(idx);\n\n  for (const obj of parsed) {\n    const out = normalizeOne(obj, fileUrlForThisItem);\n    outputs.push({ json: out });\n  }\n}\n\nreturn outputs;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "0e729384-88a1-4ee3-9bd1-ccc218462dbc",
      "name": "Internal Check Duplicate Invoice",
      "type": "n8n-nodes-base.code",
      "position": [
        -3664,
        1888
      ],
      "parameters": {
        "jsCode": "// Input: many cleaned invoices (each item.json is like your sample)\n// Output: clusters of duplicates; each cluster picks one \"keep\" and lists \"drop\"\n\nconst input = $input.all().map(i => i.json);\n\n// --- Helpers ---\nconst canonVendor = (v) => {\n  if (!v) return \"\";\n  return String(v)\n    .toLowerCase()\n    .replace(/[.,'\u2019\"]/g, \" \")\n    .replace(/\\b(pt|tbk|cv|inc|ltd|llc|llp|plc|corp|co|gmbh|sarl|bv)\\b/g, \"\")\n    .replace(/\\s+/g, \" \")\n    .trim();\n};\nconst canonInv = (s) => {\n  if (!s) return \"\";\n  return String(s)\n    .toUpperCase()\n    .replace(/\\s+/g, \"\")\n    .replace(/[\u2013\u2014]/g, \"-\")\n    .replace(/[^A-Z0-9\\/\\-\\.]/g, \"\")\n    .replace(/-+/g, \"-\")\n    .trim();\n};\nconst round2 = (n) => Math.round(Number(n || 0) * 100) / 100;\n\n// --- NEW: scoring helpers so PAID wins ---\nfunction parseYmd(s) {\n  // \"YYYY-MM-DD\" -> epoch ms; invalid -> 0\n  const m = String(s || \"\").match(/^(\\d{4})-(\\d{2})-(\\d{2})/);\n  if (!m) return 0;\n  return Date.UTC(+m[1], +m[2] - 1, +m[3]);\n}\n\nfunction statusRank(status, docPaid) {\n  const s = String(status || \"\").toLowerCase();\n  if (docPaid === true || /paid\\s*\\(verified\\)/i.test(status)) return 4;\n  if (/paid/i.test(s)) return 3;              // Paid (Unverified)\n  if (/partially/i.test(s)) return 2;         // Partially Paid\n  if (/overdue/i.test(s)) return 1;           // Overdue still beats Unpaid\n  return 0;                                   // Unpaid / others\n}\n\nfunction richness(o) {\n  let r = 0;\n  if (Array.isArray(o.attachments) && o.attachments.length) r += 2;\n  if (o.source_file_id) r += 1;\n  if (o.notes) r += 0.5;\n  if (Array.isArray(o.line_items)) r += Math.min(2, o.line_items.length * 0.1);\n  return r;\n}\n\n// choose one representative to keep\nfunction chooseMaster(arr) {\n  let best = null, bestScore = -Infinity, bestWhy = \"\";\n  for (const o of arr) {\n    const paidRank = statusRank(o.status, o.doc_says_paid); // 0..4\n    const newestDate = Math.max(parseYmd(o.due_date), parseYmd(o.issue_date));\n    const rich = richness(o);\n\n    // Weights: paidness >>> recency > richness\n    const score =\n      paidRank * 100 +               // ensure any \"Paid\" beats \"Unpaid\"\n      (newestDate / 8.64e7) * 0.1 +  // days since epoch * 0.1\n      rich;                          // small tie-break\n\n    const why = `paidRank=${paidRank} doc_says_paid=${!!o.doc_says_paid} status=${o.status || \"\"} newest=${newestDate ? new Date(newestDate).toISOString().slice(0,10) : \"-\"} richness=${rich}`;\n\n    if (score > bestScore) {\n      best = o; bestScore = score; bestWhy = why;\n    }\n  }\n  best._keep_reason_debug = bestWhy;\n  best._keep_score_debug = bestScore;\n  return best || arr[0];\n}\n\n// --- Build clusters ---\nconst clustersMap = new Map(); // key -> { reason, list: [] }\n\nfor (const o of input) {\n  const vendor_canon = canonVendor(o.vendor);\n  const invoice_no_canon = canonInv(o.invoice_no);\n  const currency = (o.currency || \"\").toUpperCase().trim();\n  const amt = round2(o.amount_total);\n  const due = (o.due_date || o.issue_date || \"\").slice(0, 10);\n\n  let key, reason;\n  if (invoice_no_canon) {\n    key = `INV#${vendor_canon}|${invoice_no_canon}`;\n    reason = \"SAME_VENDOR + INVOICE_NO\";\n  } else {\n    key = `FALLBACK#${vendor_canon}|${currency}|${amt}|${due}`;\n    reason = \"NO_INVOICE_NO \u2192 SAME_VENDOR + CURRENCY + AMOUNT + DATE\";\n  }\n\n  if (!clustersMap.has(key)) clustersMap.set(key, { reason, list: [] });\n  clustersMap.get(key).list.push({\n    ...o,\n    _vendor_canon: vendor_canon,\n    _invoice_no_canon: invoice_no_canon,\n    _fallback_key: !invoice_no_canon\n  });\n}\n\n// --- Emit only duplicate clusters (>1) ---\nconst results = [];\nfor (const [key, { reason, list }] of clustersMap.entries()) {\n  if (list.length <= 1) continue;\n\n  const amountSet = Array.from(new Set(list.map(x => round2(x.amount_total))));\n  const currencySet = Array.from(new Set(list.map(x => (x.currency || \"\").toUpperCase().trim())));\n\n  // annotate if amounts differ\n  let reasonDetailed = reason;\n  if (reason.startsWith(\"SAME_VENDOR\") && amountSet.length > 1) {\n    reasonDetailed += \" (amount differs across copies)\";\n  }\n\n  // --- NEW: count paid vs unpaid; prefer paid in keep\n  const paidCount = list.filter(x => x.doc_says_paid || /^paid/i.test(String(x.status || \"\"))).length;\n  if (paidCount > 0) reasonDetailed += \" | keep prefers PAID copy\";\n\n  const keep = chooseMaster(list);\n  const drop = list.filter(x => x !== keep);\n\n  results.push({\n    json: {\n      isDuplicate: true,\n      cluster_key: key,\n      reason: reasonDetailed,\n      count: list.length,\n      amounts: amountSet,\n      currencies: currencySet,\n      paid_count: paidCount,                 // debug/metrics\n      keep,                                  // should now be one of C/D (paid)\n      drop,\n      entries: list\n    }\n  });\n}\n\n// No duplicates? Emit a simple flag.\nif (results.length === 0) {\n  return [{ json: { isDuplicate: false, info: \"No duplicate invoices found.\" } }];\n}\n\nreturn results;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "dbcea768-1818-4a07-932c-3161e06342b5",
      "name": "Slack Trigger",
      "type": "n8n-nodes-base.slackTrigger",
      "position": [
        -6032,
        1744
      ],
      "parameters": {
        "options": {},
        "trigger": [
          "message"
        ],
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": "C09701BLY2Z",
          "cachedResultName": "expenses"
        }
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "0202318a-016b-4130-930c-bd092f18c492",
      "name": "Merge with Original Data",
      "type": "n8n-nodes-base.code",
      "position": [
        -3440,
        1888
      ],
      "parameters": {
        "jsCode": "// n8n Code node \u2014 Merge originals with dedup clusters and tag each original\n// Upstream nodes:\n//   - \"Cleans AI Response\"  -> contains ORIGINAL parsed items (A, B, C)\n//   - Input to this node    -> contains cluster objects from \"Internal Check Duplicate Invoice\"\n// Output: one item per original, with dedup_* fields added\n\n// ---- helpers ----\nfunction canonVendor(v) {\n  return String(v || '')\n    .toLowerCase()\n    .replace(/[^a-z0-9\\s]/g, ' ')     // strip punctuation (ASCII-safe)\n    .replace(/\\b(pt|tbk|inc|ltd|llc)\\b/g, '')\n    .replace(/\\s+/g, ' ')\n    .trim();\n}\nfunction canonInvoiceNo(inv) {\n  return String(inv || '')\n    .toUpperCase()\n    .replace(/\\s+/g, '')\n    .replace(/[^A-Z0-9\\/\\-.]/g, '');\n}\nfunction clusterKeyFromItem(it) {\n  const vendor = canonVendor(it.vendor);\n  const inv = canonInvoiceNo(it.invoice_no);\n  if (inv) return `INV#${vendor}|${inv}`;\n  const currency = String(it.currency || '').toUpperCase();\n  const amt = Number(it.amount_total || it.subtotal || 0) || 0;\n  const date = String(it.due_date || it.issue_date || '');\n  return `FALLBACK#${vendor}|${currency}|${amt}|${date}`;\n}\n// Stable-ish signature to match items across steps\nfunction sig(o) {\n  return [\n    o.source_file_id || '',\n    o.source_file_url || '',\n    String(o.vendor || '').trim(),\n    String(o.invoice_no || '').trim(),\n    String(o.issue_date || '').trim(),\n    String(o.due_date || '').trim(),\n    String(o.currency || '').trim().toUpperCase(),\n    String(Number(o.amount_total ?? o.subtotal ?? 0) || 0)\n  ].join('||');\n}\n\n// ---- 1) load originals (A, B, C) ----\nconst originals = $('Cleans AI Response').all().map(i => i.json);\n\n// ---- 2) load and normalize clusters from THIS node input ----\nconst rawIn = $input.all().map(i => i.json);\nconst clusters = [];\nfor (const x of rawIn) {\n  if (Array.isArray(x)) {\n    for (const c of x) if (c && c.cluster_key) clusters.push(c);\n  } else if (x && x.cluster_key) {\n    clusters.push(x);\n  }\n}\n// If your duplicate checker sometimes emits {clusters:[...]}, support that too\nfor (const x of rawIn) {\n  if (x && Array.isArray(x.clusters)) {\n    for (const c of x.clusters) if (c && c.cluster_key) clusters.push(c);\n  }\n}\n\n// ---- 3) index clusters (by cluster_key) ----\nconst clusterIndex = new Map();  // cluster_key -> { meta, keepSig, dropSigSet, keepUsed }\nfor (const c of clusters) {\n  const meta = {\n    cluster_key: c.cluster_key,\n    reason: c.reason || '',\n    count: Number(c.count || (c.entries ? c.entries.length : 1)) || 1,\n    amounts: c.amounts || [],\n    currencies: c.currencies || []\n  };\n  const keepSig = c.keep ? sig(c.keep) : null;\n  const dropSigSet = new Set((c.drop || []).map(sig));\n  clusterIndex.set(c.cluster_key, { meta, keepSig, dropSigSet, keepUsed: false });\n}\n\n// Try to find matching cluster for an original item\nfunction findClusterForOriginal(item) {\n  const s = sig(item);\n  for (const [ck, rec] of clusterIndex) {\n    if ((rec.keepSig && rec.keepSig === s) || rec.dropSigSet.has(s)) return [ck, rec];\n  }\n  const ck = clusterKeyFromItem(item);\n  return [ck, clusterIndex.get(ck) || null];\n}\n\n// ---- 4) merge labels onto originals ----\nconst out = [];\n\nfor (const item of originals) {\n  const [ck, rec] = findClusterForOriginal(item);\n\n  if (!rec) {\n    out.push({\n      json: {\n        ...item,\n        dedup_cluster_key: ck || '',\n        dedup_role: 'unique',\n        dedup_count: 1,\n        dedup_reason: 'no_cluster_match'\n      }\n    });\n    continue;\n  }\n\n  const { meta, keepSig, dropSigSet } = rec;\n  const s = sig(item);\n  let role;\n\n  if (meta.count <= 1) {\n    role = 'unique';\n  } else if (keepSig && s === keepSig && !rec.keepUsed) {\n    role = 'keep';           // first original that matches the keepSig\n    rec.keepUsed = true;     // ensure only one \"keep\" per cluster\n  } else {\n    role = 'drop';           // everything else in that cluster is a duplicate\n  }\n\n  out.push({\n    json: {\n      ...item,\n      dedup_cluster_key: meta.cluster_key,\n      dedup_role: role,                 // \"keep\" | \"drop\" | \"unique\"\n      dedup_count: meta.count,\n      dedup_reason: meta.reason,\n      dedup_amounts: meta.amounts,\n      dedup_currencies: meta.currencies\n    }\n  });\n}\n\nreturn out;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "123fc9c7-f6d3-4be5-b02a-d6c65faa5bc9",
      "name": "Merge Data Value into One Key",
      "type": "n8n-nodes-base.code",
      "position": [
        -4464,
        1888
      ],
      "parameters": {
        "jsCode": "// Code node \u2014 Run once for all items\n// Preceded by a Merge (Wait for all) so ALL items from both paths arrive here.\n\nconst out = [];\n\nfunction get(v) {\n  return typeof v === 'string' ? v.trim() : '';\n}\n\nfor (const it of $input.all()) {\n  const j = it.json || {};\n\n  // Your two possible sources per item\n  const fromPdf   = get(j.mergedParsedText);                 // Error3 (PDF path)\n  const fromImage = get(j?.ParsedResults?.[0]?.ParsedText);  // Error2 (image path)\n\n  const OCRResult = fromPdf || fromImage || '';\n  const OCRSource = fromPdf\n    ? 'Error3.mergedParsedText'\n    : fromImage\n    ? 'Error2.ParsedResults[0].ParsedText'\n    : 'none';\n\n  // Keep any file metadata you already have on the item\n  out.push({\n    json: {\n      ...j,\n      OCRResult,\n      OCRSource\n    }\n  });\n}\n\nreturn out;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "88aae252-885b-4433-af13-88fea42ad24e",
      "name": "Decide Fate for Data",
      "type": "n8n-nodes-base.if",
      "position": [
        -3216,
        1888
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "9cb0bdab-37f9-4536-8b75-de7e3bdd4a76",
              "operator": {
                "type": "string",
                "operation": "notEquals"
              },
              "leftValue": "={{ $json.dedup_role }}",
              "rightValue": "drop"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "c3b08864-40e6-4c71-b0ad-f46231351a92",
      "name": "Create Query for DB",
      "type": "n8n-nodes-base.code",
      "position": [
        -2992,
        1312
      ],
      "parameters": {
        "jsCode": "// Run ONCE FOR ALL ITEMS\nconst out = [];\n\nfunction windowDates(anchor) {\n  if (!anchor) return null;\n  const d = new Date(anchor + \"T00:00:00Z\");\n  if (isNaN(d)) return null;\n  const s = new Date(d); s.setUTCDate(s.getUTCDate() - 30);\n  const e = new Date(d); e.setUTCDate(e.getUTCDate() + 30);\n  return [s.toISOString().slice(0,10), e.toISOString().slice(0,10)];\n}\n\nfor (const { json: j } of $input.all()) {\n  const haveInvoiceNo = !!j?.invoice_no;\n\n  // Anchor date:\n  // - If we know the invoice number, anchor to invoice-level dates\n  // - Otherwise, paid_date is acceptable for finding the invoice by heuristics\n  const anchor = haveInvoiceNo\n    ? (j?.issue_date || j?.due_date || \"\")\n    : (j?.receipt_hints?.paid_date || j?.issue_date || j?.due_date || \"\");\n  const dates = windowDates(anchor);\n\n  const and = [];\n\n  if (haveInvoiceNo) {\n    // Prefer canon properties if your DB has them; else fall back to title field\n    if (j.invoice_no_canon) {\n      and.push({ property: \"Invoice No (Canon)\", rich_text: { equals: String(j.invoice_no_canon) } });\n      if (j.vendor_canon) {\n        and.push({ property: \"Vendor (Canon)\", rich_text: { equals: String(j.vendor_canon) } });\n      }\n    } else {\n      and.push({ property: \"Invoice No\", title: { equals: String(j.invoice_no) } });\n    }\n    if (j.currency) {\n      and.push({ property: \"Currency\", select: { equals: String(j.currency) } });\n    }\n\n    // IMPORTANT: no Amount Total filter when Invoice No is present\n    // (If you want a belt-and-suspenders check, you can add a WIDE band on amount_total instead,\n    // but never use the receipt paid amount.)\n\n  } else {\n    // Fallback: vendor + currency + invoice total band + date\n    if (j.vendor_canon) {\n      and.push({ property: \"Vendor (Canon)\", rich_text: { equals: String(j.vendor_canon) } });\n    } else if (j.vendor) {\n      and.push({ property: \"Vendor\", rich_text: { equals: String(j.vendor) } });\n    }\n    if (j.currency) {\n      and.push({ property: \"Currency\", select: { equals: String(j.currency) } });\n    }\n    const total = Number(j?.amount_total);\n    if (!Number.isNaN(total) && total > 0) {\n      const eps = Math.max(5, total * 0.03); // \u00b1 $5 or 3%\n      and.push(\n        { property: \"Amount Total\", number: { greater_than_or_equal_to: +(total - eps).toFixed(2) } },\n        { property: \"Amount Total\", number: { less_than_or_equal_to:   +(total + eps).toFixed(2) } },\n      );\n    }\n  }\n\n  if (dates) {\n    const [start, end] = dates;\n    and.push({\n      or: [\n        { property: \"Issue Date\", date: { on_or_after: start, on_or_before: end } },\n        { property: \"Due Date\",   date: { on_or_after: start, on_or_before: end } },\n      ]\n    });\n  }\n\n  const queryBody = { filter: { and }, page_size: haveInvoiceNo ? 10 : 5 };\n  out.push({ json: { ...j, queryBody } });\n}\n\nreturn out;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "de2b4a0f-eaba-4d8c-ac76-220f14725f10",
      "name": "Check DB Invoice",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -2768,
        1312
      ],
      "parameters": {
        "url": "https://api.notion.com/v1/databases/24df7557957380739611da8371404254/query",
        "method": "POST",
        "options": {},
        "jsonBody": "={{ $json.queryBody }}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Notion-Version",
              "value": "2022-06-28"
            }
          ]
        },
        "nodeCredentialType": "notionApi"
      },
      "credentials": {
        "notionApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "067db3c6-fc83-46a2-8ad4-0b8cec9ab83e",
      "name": "Merge Items Invoice",
      "type": "n8n-nodes-base.code",
      "position": [
        -2544,
        1312
      ],
      "parameters": {
        "jsCode": "// After \"Check DB Invoice\" (HTTP). Mode: Run once for all items. Single output.\n\n// Originals aligned by index (from your query-prep node)\nconst originals = $('Create Query for DB').all().map(i => i.json);\n\n// Toggle if you want partial flows to auto-route (true) or go to manual_review (false)\nconst ENABLE_PARTIAL = true;\n\n// ---------- Notion helpers ----------\nconst getTitle  = (p, name) => (p?.properties?.[name]?.title || [])\n  .map(t => t.plain_text || '').join('');\nconst getRich   = (p, name) => (p?.properties?.[name]?.rich_text || [])\n  .map(t => t.plain_text || '').join('');\nconst getSelect = (p, name) => p?.properties?.[name]?.select?.name || '';\nconst getNumber = (p, name) => (\n  typeof p?.properties?.[name]?.number === 'number'\n    ? p.properties[name].number\n    : null\n);\nconst getDate   = (p, name) => p?.properties?.[name]?.date?.start || '';\n\nconst isPaidName = (s) => /^paid/i.test(String(s || ''));\n\n// quick equality for \u201cno-change/dup\u201d check\nfunction coreSame(src, db) {\n  const eq = (x,y)=> String(x||'') === String(y||'');\n  const numEq=(x,y)=> {\n    const nx=Number(x??NaN), ny=Number(y??NaN);\n    if (Number.isNaN(nx) && Number.isNaN(ny)) return true;\n    return Math.abs(nx - ny) < 0.001;\n  };\n  return (\n    eq(src.vendor, db.vendor) &&\n    eq(src.invoice_no, db.invoice_no) &&\n    eq(src.currency, db.currency) &&\n    numEq(src.amount_total, db.amount_total) &&\n    eq(src.issue_date, db.issue_date) &&\n    eq(src.due_date, db.due_date)\n  );\n}\n\nconst out = [];\nconst tolAbs = 0.01;   // absolute amount tolerance\nconst tolPct = 0.01;   // 1% relative tolerance\n\n$input.all().forEach((it, idx) => {\n  const src   = originals[idx] || {};\n  const http  = it.json || {};\n  const pages = Array.isArray(http.results) ? http.results : [];\n  const manyMatches = pages.length > 1;\n  const page  = pages[0] || null;\n\n  // ---------- DB snapshot ----------\n  const db = page ? {\n    page_id:     page.id,\n    invoice_no:  getTitle(page, 'Invoice No'),\n    vendor:      getRich(page,  'Vendor'),\n    currency:    getSelect(page,'Currency'),\n    amount_total:getNumber(page,'Amount Total'),\n    issue_date:  getDate(page,  'Issue Date'),\n    due_date:    getDate(page,  'Due Date'),\n    status:      getSelect(page,'Status'),\n  } : null;\n  const dbPaid = db ? isPaidName(db.status) : false;\n\n  // ---------- mismatches ----------\n  const vendorMismatch   = !!(db && db.vendor && src.vendor && db.vendor !== src.vendor);\n  const currencyMismatch = !!(db && db.currency && src.currency && db.currency !== src.currency);\n\n  const inAmt = Number(src.amount_total ?? NaN);\n  const dbAmt = Number(db?.amount_total ?? NaN);\n  const amountMismatch =\n    db && Number.isFinite(inAmt) && Number.isFinite(dbAmt) &&\n    (Math.abs(inAmt - dbAmt) > tolAbs) &&\n    (Math.abs(inAmt - dbAmt) / Math.max(1, dbAmt) > tolPct);\n\n  const fallbackAmbiguous = !src.invoice_no && manyMatches;\n\n  // ---------- derive payment signals from AMOUNTS (ignore labels) ----------\n  const statusStr = String(src.status || '');\n  const totalIn   = Number(src.amount_total ?? NaN);\n  const paidAmtIn = Number(\n    (src?.receipt_hints?.paid_amount ?? src.paid_amount) ?? NaN\n  );\n  const dueIn     = Number(src.amount_due ?? NaN);\n\n  // some payment if: positive paid OR \u201cpartial\u201d in status OR doc_says_paid flag\n  let hasSomePayment =\n    (Number.isFinite(paidAmtIn) && paidAmtIn > tolAbs) ||\n    /partial/i.test(statusStr) ||\n    src.doc_says_paid === true;\n\n  // full payment primarily by amounts; labels only if amounts missing\n  let isFullPayment =\n    (Number.isFinite(dueIn)   && dueIn <= tolAbs) ||\n    (Number.isFinite(totalIn) && Number.isFinite(paidAmtIn) && (paidAmtIn >= totalIn - tolAbs));\n\n  // if due is positive, it cannot be full\u2014even if the doc says \u201cPAID\u201d\n  if (Number.isFinite(dueIn) && dueIn > tolAbs) isFullPayment = false;\n\n  // keep compatibility names used later in logic\n  const incomingPaid    = hasSomePayment;\n  const partialDetected = hasSomePayment && !isFullPayment;\n\n  // ---------- decide route ----------\n  let next_action = 'archive';\n  let reason = '';\n\n  if (!page) {\n    // No DB page found \u2192 choose create_* route\n    if (hasSomePayment) {\n      if (isFullPayment) {\n        next_action = 'create_paid';\n        reason = 'no_db_match_paid';\n      } else {\n        next_action = ENABLE_PARTIAL ? 'create_partial' : 'manual_review';\n        reason = ENABLE_PARTIAL ? 'no_db_match_partial_payment' : 'partial_needs_review';\n      }\n    } else {\n      next_action = 'create_unpaid';\n      reason = 'no_db_match_unpaid';\n    }\n  } else {\n    // Have a DB page\n    if (manyMatches || vendorMismatch || currencyMismatch || fallbackAmbiguous) {\n      next_action = 'manual_review';\n      reason = manyMatches\n        ? 'multiple_db_matches'\n        : vendorMismatch\n          ? 'vendor_mismatch'\n          : currencyMismatch\n            ? 'currency_mismatch'\n            : 'fallback_ambiguous';\n    } else if (hasSomePayment) {\n      // There is some payment in the incoming doc\n      if (isFullPayment) {\n        // mark paid if DB not yet paid; else archive duplicate payment\n        if (!dbPaid) {\n          next_action = 'update_mark_paid';\n          reason = 'mark_paid';\n        } else {\n          next_action = 'archive';\n          reason = 'duplicate_payment_already_paid';\n        }\n      } else {\n        // partial payment path\n        if (ENABLE_PARTIAL) {\n          next_action = 'update_partial';\n          reason = 'partial_payment_detected';\n        } else {\n          next_action = 'manual_review';\n          reason = 'partial_needs_review';\n        }\n      }\n    } else if (amountMismatch) {\n      next_action = 'manual_review';\n      reason = 'amount_mismatch';\n    } else {\n      // no payment transition\n      const same = coreSame(src, db);\n      next_action = 'archive';\n      reason = same ? 'duplicate_no_change' : 'no_payment_transition';\n    }\n  }\n\n  out.push({\n    json: {\n      ...src,\n      db_found:  !!page,\n      db_page_id: db?.page_id || '',\n      db_status:  db?.status   || '',\n      next_action,\n      reason,\n      _checks: {\n        manyMatches,\n        vendorMismatch,\n        currencyMismatch,\n        amountMismatch,\n        fallbackAmbiguous,\n        hasSomePayment,\n        isFullPayment,\n        dbPaid\n      }\n    }\n  });\n});\n\nreturn out;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "1e7efdbb-f58c-4cfa-9a74-22cda80b58c7",
      "name": "Send to Source File Invoice 1",
      "type": "n8n-nodes-base.notion",
      "position": [
        -1872,
        736
      ],
      "parameters": {
        "title": "={{ $json.source_file_id }}",
        "options": {},
        "resource": "databasePage",
        "databaseId": {
          "__rl": true,
          "mode": "list",
          "value": "238f7557-9573-8062-b50d-fe9db34ec410",
          "cachedResultUrl": "https://www.notion.so/238f755795738062b50dfe9db34ec410",
          "cachedResultName": "Source File"
        },
        "propertiesUi": {
          "propertyValues": [
            {
              "key": "File ID|title",
              "title": "={{ $json.source_file_id }}"
            },
            {
              "key": "Filename|rich_text",
              "textContent": "={{ $json.source_file_name }}"
            },
            {
              "key": "File URL|url",
              "urlValue": "={{ $json.source_file_url }}"
            },
            {
              "key": "Summary|rich_text",
              "text": {
                "text": [
                  {
                    "text": "={{ $json.line_items_rich_text[0].text.content }}",
                    "annotationUi": {}
                  }
                ]
              },
              "richText": true
            }
          ]
        }
      },
      "credentials": {
        "notionApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "3b8e7c65-8d40-4636-bfb0-8091365e584e",
      "name": "Prepare Line Items before send",
      "type": "n8n-nodes-base.code",
      "position": [
        -2320,
        1312
      ],
      "parameters": {
        "jsCode": "// n8n Code node \u2014 prepare line items + compute ONE field for Notion (\"Paid Amount\")\nconst out = [];\n\nfunction two(n){ return Math.round((Number(n)||0)*100)/100; }\nfunction chunk(str, size=1800){ const a=[]; for(let i=0;i<str.length;i+=size)a.push(str.slice(i,i+size)); return a; }\nfunction num(x){ if(x===null||x===undefined||x===\"\") return NaN; const n=Number(String(x).replace(/[^0-9.-]/g,\"\")); return isNaN(n)?NaN:n; }\nfunction clamp(n,min,max){ return Math.max(min, Math.min(max, n)); }\nfunction approxEq(a,b,eps=0.01){ return Math.abs(a-b)<=eps; }\n\nfor (const item of $input.all()) {\n  const j = item.json || {};\n  const ccy = (j.currency || '').toUpperCase();\n  const items = Array.isArray(j.line_items) ? j.line_items : [];\n\n  // Build line-items text (unchanged)\n  const lines = items.map(li => {\n    const desc=(li?.description||'').trim();\n    const qty = two(li?.qty);\n    const up  = two(li?.unit_price);\n    const amt = two(li?.amount || qty*up);\n    const tail = ccy ? ` ${ccy}` : '';\n    return `${qty} \u00d7 ${desc} @ ${up}${tail} = ${amt}${tail}`;\n  });\n  const text = lines.join('\\n') || '';\n  const rich = chunk(text).map(s => ({ type:'text', text:{ content:s }}));\n\n  // Minimal compute for Paid Amount\n  const total = num(j.amount_total);\n  const due   = num(j.amount_due);\n  const delta = num(j?.receipt_hints?.paid_amount ?? j.paid_amount);       // new receipt (e.g., 200)\n  const prev  = num(j.db_paid_amount_prev ?? j.invoice?.paid_amount ?? 0); // previous paid if you pass it\n\n  // Prefer set-to from doc (Total - Due); fallback to prev + delta\n  let newPaid = Number.isFinite(total) && Number.isFinite(due)\n    ? two(clamp(total - due, 0, total))\n    : (Number.isFinite(delta) ? two(clamp(prev + delta, 0, Number.isFinite(total)? total : Infinity)) : NaN);\n\n  // If still NaN, don't emit the field to avoid bad writes\n  const outJson = {\n    ...j,\n    line_items_text: text,\n    line_items_rich_text: rich,\n  };\n  if (Number.isFinite(newPaid)) outJson.notion_paid_amount = newPaid;\n\n  // (Optional tiny extra: status, only if you want it \u2014 safe to ignore)\n  if (Number.isFinite(total) && Number.isFinite(newPaid)) {\n    outJson.notion_status =\n      approxEq(newPaid, 0)      ? \"Unpaid\" :\n      approxEq(newPaid, total)  ? \"Paid (Verified)\" :\n                                  \"Partially Paid\";\n  }\n\n  out.push({ json: outJson });\n}\n\nreturn out;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "fa73399a-c133-48b3-87fe-922961f4d448",
      "name": "Decide Fate",
      "type": "n8n-nodes-base.switch",
      "position": [
        -2096,
        1232
      ],
      "parameters": {
        "rules": {
          "values": [
            {
              "outputKey": "Create Unpaid",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "18f4cc4b-ad0f-4782-a59d-a66dbffc6475",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.next_action }}",
                    "rightValue": "create_unpaid"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "Create Paid",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "d8bb0418-0798-428a-b379-8cf8accf5ea9",
                    "operator": {
                      "name": "filter.operator.equals",
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.next_action }}",
                    "rightValue": "create_paid"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "Update to Paid",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "4009916c-000e-435d-ada3-6a7d1fbc595c",
                    "operator": {
                      "name": "filter.operator.equals",
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.next_action }}",
                    "rightValue": "update_mark_paid"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "Archive",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "172dad2a-7a92-493d-977d-56c568d5c360",
                    "operator": {
                      "name": "filter.operator.equals",
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.next_action }}",
                    "rightValue": "archive"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "New Create Partial",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "a14d6d5c-6eac-49e8-8380-f85cf565a542",
                    "operator": {
                      "name": "filter.operator.equals",
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.next_action }}",
                    "rightValue": "create_partial"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "Update Partial Payment",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "b9d0f364-e7a4-48b8-94fc-6053b51f6e14",
                    "operator": {
                      "name": "filter.operator.equals",
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.next_action }}",
                    "rightValue": "update_partial"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "Manual Review",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "4d8986d1-3896-42bf-9d6f-9adb48ac3987",
                    "operator": {
                      "name": "filter.operator.equals",
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.next_action }}",
                    "rightValue": "manual_review"
                  }
                ]
              },
              "renameOutput": true
            }
          ]
        },
        "options": {}
      },
      "typeVersion": 3.2
    },
    {
      "id": "f90f09ea-6016-429b-915f-1565bcff1d97",
      "name": "Create new Invoice Unpaid",
      "type": "n8n-nodes-base.notion",
      "position": [
        -1648,
        736
      ],
      "parameters": {
        "options": {},
        "resource": "databasePage",
        "databaseId": {
          "__rl": true,
          "mode": "list",
          "value": "24df7557-9573-8073-9611-da8371404254",
          "cachedResultUrl": "https://www.notion.so/24df7557957380739611da8371404254",
          "cachedResultName": "Clearflow Invoice (Ledger)"
        },
        "propertiesUi": {
          "propertyValues": [
            {
              "key": "Attachments|rich_text",
              "textContent": "={{ $('Decide Fate').item.json.attachments[0] }}"
            },
            {
              "key": "Currency|select",
              "selectValue": "={{ $('Decide Fate').item.json.currency }}"
            },
            {
              "key": "Destination Account|rich_text",
              "textContent": "={{ $('Decide Fate').item.json.destination_account }}"
            },
            {
              "key": "Discount Percent|number",
              "numberValue": "={{ $('Decide Fate').item.json.discount_percent_for_notion }}"
            },
            {
              "key": "Due Date|date",
              "date": "={{ $('Decide Fate').item.json.due_date }}"
            },
            {
              "key": "Invoice No|title",
              "title": "={{ $('Decide Fate').item.json.invoice_no }}"
            },
            {
              "key": "Issue Date|date",
              "date": "={{ $('Decide Fate').item.json.issue_date }}"
            },
            {
              "key": "Line Items|rich_text",
              "textContent": "={{ $('Decide Fate').item.json.line_items_rich_text[0].text.content }}"
            },
            {
              "key": "Notes|rich_text",
              "textContent": "={{ $('Decide Fate').item.json.notes }}"
            },
            {
              "key": "Paid Amount|number",
              "numberValue": "={{ $('Decide Fate').item.json.paid_amount }}"
            },
            {
              "key": "Payment Ref|rich_text",
              "textContent": "={{ $('Decide Fate').item.json.payment_ref }}"
            },
            {
              "key": "Recipient Account|rich_text",
              "textContent": "={{ $('Decide Fate').item.json.destination_account }}"
            },
            {
              "key": "Status|select",
              "selectValue": "={{ $('Decide Fate').item.json.status }}"
            },
            {
              "key": "Subtotal|number",
              "numberValue": "={{ $('Decide Fate').item.json.subtotal }}"
            },
            {
              "key": "Tax Total|number",
              "numberValue": "={{ $('Decide Fate').item.json.tax_total }}"
            },
            {
              "key": "Vendor|rich_text",
              "textContent": "={{ $('Decide Fate').item.json.vendor }}"
            },
            {
              "key": "=Source File|relation",
              "relationValue": [
                "={{ $json.id }}"
              ]
            }
          ]
        }
      },
      "credentials": {
        "notionApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "8f25494a-6b7c-45e5-99fc-7ab5e2e0d085",
      "name": "Add Receipt into Cashflow",
      "type": "n8n-nodes-base.notion",
      "position": [
        -1872,
        1312
      ],
      "parameters": {
        "options": {},
        "resource": "databasePage",
        "databaseId": {
          "__rl": true,
          "mode": "list",
          "value": "238f7557-9573-80a2-96c8-e638a56411d2",
          "cachedResultUrl": "https://www.notion.so/238f7557957380a296c8e638a56411d2",
          "cachedResultName": "Cashflow (Ledger)"
        },
        "propertiesUi": {
          "propertyValues": [
            {
              "key": "Cashflow ID|title",
              "title": "={{ $json.receipt_no || 'cf_' + Date.now() + '_' + Math.floor(Math.random() * 1000) }}"
            },
            {
              "key": "Amount (Net)|number",
              "numberValue": "={{ $json.paid_amount }}"
            },
            {
              "key": "Currency|select",
              "selectValue": "={{ $json.currency }}"
            },
            {
              "key": "Fee|number",
              "numberValue": "={{ $json.tax_total }}"
      

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

How this works

This workflow automatically tracks invoices and payments by extracting details from uploaded files and routing them through approval steps in Slack before updating records in Notion. It suits finance teams and freelancers who handle mixed invoice formats and need reliable data capture without manual entry. The key step is the OCR extraction combined with Claude AI parsing, which pulls invoice numbers, vendor details, amounts and dates directly from PDFs or images before any further processing.

Use it when invoices arrive via email or uploads and you need structured records in Notion with Slack notifications for review. Avoid it for high-volume environments where custom validation rules exceed the current parsing logic or when real-time bank feeds are already in place. A common variation replaces the Slack approval with direct database updates for fully automated flows.

About this workflow

This workflow automates Invoice & Payment Tracking (with Approvals) across Notion and Slack. Ingest — You drop invoices/receipts (PDF/IMG/JSON) into the flow. Extract — OCR + parsing pulls out key fields (invoice no, vendor, currency, totals, receipt paid amount/date). De-dup &…

Source: https://n8n.io/workflows/7773/ — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

AI & RAG

Agent - Twitter Reply Guy. Uses slackTrigger, chainLlm, slack, outputParserStructured. Event-driven trigger; 26 nodes.

Slack Trigger, Chain Llm, Slack +6
AI & RAG

The Google Meet AI Assistant workflow provides intelligent meeting analysis by integrating Google Calendar, Google Drive, AssemblyAI transcription, Anthropic Claude AI, Slack, and Notion. It automates

Google Calendar, Google Drive, Stop And Error +5
AI & RAG

Content - Newsletter Agent. Uses formTrigger, chainLlm, outputParserStructured, httpRequest. Event-driven trigger; 87 nodes.

Form Trigger, Chain Llm, Output Parser Structured +7
AI & RAG

This template attempts to replicate OpenAI's DeepResearch feature which, at time of writing, is only available to their pro subscribers.

Output Parser Structured, OpenAI Chat, Form Trigger +8
AI & RAG

My workflow 53. Uses formTrigger, httpRequest, lmChatOpenAi, form. Event-driven trigger; 74 nodes.

Form Trigger, HTTP Request, OpenAI Chat +15