AutomationFlowsAI & RAG › Create Interactive Bank Expense Reports From Statements with Openai Gpt-4o

Create Interactive Bank Expense Reports From Statements with Openai Gpt-4o

ByIncrementors @incrementors on n8n.io

This workflow accepts a bank statement upload (CSV or text-based PDF), parses transactions, uses OpenAI to categorize merchants and generate narrative insights, and returns an interactive HTML expense report with category totals, monthly breakdowns, recurring-charge detection,…

Event trigger★★★★☆ complexityAI-powered19 nodesForm TriggerAgentOpenAI ChatForm
AI & RAG Trigger: Event Nodes: 19 Complexity: ★★★★☆ AI nodes: yes Added:

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

This workflow follows the Agent → Form 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
{
  "id": "26FwxKugNh4q4DF0",
  "name": "Bank Statement to Expense Breakdown",
  "tags": [],
  "nodes": [
    {
      "id": "33ebc7c0-db16-4f9d-b824-6f0dbbae9e55",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -336,
        208
      ],
      "parameters": {
        "width": 572,
        "height": 1048,
        "content": "## Bank Statement to Expense Breakdown\n\nBank Statement to Expense Breakdown turns any exported statement into a full expense report with no manual work. Upload a CSV or text-based PDF and get back category totals, monthly trends, repeating charges with their annualised cost, and an AI-written commentary. It handles US, UK, Indian and European formats automatically and needs only one credential to run.\n\n### How it works\n1. **Upload Statement** accepts a CSV or text-based PDF through a simple web form.\n2. **Is it a PDF?** routes the file to the correct text extractor. Both paths merge before parsing.\n3. **Parse Transactions** detects delimiters, date formats and column layouts automatically, then outputs a clean transaction list.\n4. **Categorize Payees** sends only the unique payee list to the OpenAI model, which assigns a spending category and a readable name to each merchant.\n5. **Calculate Totals** and **Insights Agent** run all arithmetic in code, then ask the AI to write a short narrative reading of the numbers.\n6. **Build Report Page** assembles an interactive HTML report with charts, tables, a CSV download and the AI commentary, then **Show Report** serves it as the form response.\n\n### Setup steps\n1. Open the **OpenAI Chat Model** node and connect your OpenAI credential.\n2. Click **Activate** to enable the workflow.\n3. Copy the production URL from the **Upload Statement** node, open it in a browser and upload your first statement.\n\n### Customization\n- Swap **OpenAI Chat Model** for Anthropic, Gemini, Groq or Ollama. Both agents share the same model node, so nothing else changes.\n- Scanned or photographed PDFs have no text to extract. Export a CSV from your bank instead.\n- Edit the category list inside the **Build Category Prompt** node to add or rename spending categories."
      },
      "typeVersion": 1
    },
    {
      "id": "a7730aa0-6ccf-4deb-ae99-a6246b3d6798",
      "name": "Section 1 - Trigger",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        304,
        560
      ],
      "parameters": {
        "color": 7,
        "width": 720,
        "height": 506,
        "content": "## 1. Trigger and file detection\n\nA form lets users upload a bank statement. The IF node checks whether the file is a PDF or a CSV, then routes it to the correct text extractor."
      },
      "typeVersion": 1
    },
    {
      "id": "73c67a2b-4b70-403a-ac59-14c115ede23c",
      "name": "Section 2 - Extract",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1072,
        400
      ],
      "parameters": {
        "color": 7,
        "width": 540,
        "height": 986,
        "content": "## 2. Extract text from file\n\nThe PDF or CSV text is read from the binary file. Both extraction branches rejoin at the Merge node, passing a single raw text string downstream."
      },
      "typeVersion": 1
    },
    {
      "id": "71d06ca3-bf6b-4278-b7db-6506dadb375b",
      "name": "Section 3 - AI Pipeline",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1712,
        528
      ],
      "parameters": {
        "color": 7,
        "width": 1550,
        "height": 810,
        "content": "## 3. Parse, categorize and calculate\n\nThe parser reads transactions and builds a deduplicated payee list. The OpenAI model assigns spending categories. Code nodes compute totals, monthly trends and recurring charges. A second AI agent writes the plain-language commentary."
      },
      "typeVersion": 1
    },
    {
      "id": "bd6eaf15-6f1f-4188-a807-b30ea5e57df9",
      "name": "Section 4 - Report",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3376,
        576
      ],
      "parameters": {
        "color": 7,
        "width": 530,
        "height": 394,
        "content": "## 4. Build and serve report\n\nA Code node assembles an HTML page with category bars, monthly tables, recurring charges and a CSV download. The Form node returns it directly as the workflow response."
      },
      "typeVersion": 1
    },
    {
      "id": "3d9a7379-6a4c-4275-b947-902697529fb6",
      "name": "Upload Statement",
      "type": "n8n-nodes-base.formTrigger",
      "position": [
        544,
        768
      ],
      "parameters": {
        "options": {},
        "formTitle": "Bank Statement to Expense Breakdown",
        "formFields": {
          "values": [
            {
              "fieldType": "file",
              "fieldLabel": "Statement File",
              "multipleFiles": false,
              "requiredField": true,
              "acceptFileTypes": ".csv,.txt,.pdf"
            }
          ]
        },
        "responseMode": "lastNode",
        "formDescription": "Upload a bank statement exported as CSV, or a text based PDF. You will get a categorised expense report with monthly totals and repeating charges."
      },
      "typeVersion": 2.2
    },
    {
      "id": "f7cefb30-a110-44e9-b3c3-1e96e7950770",
      "name": "Is it a PDF?",
      "type": "n8n-nodes-base.if",
      "position": [
        800,
        768
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": false,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "is-pdf",
              "operator": {
                "type": "string",
                "operation": "contains"
              },
              "leftValue": "={{ $binary.Statement_File ? $binary.Statement_File.mimeType : '' }}",
              "rightValue": "pdf"
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.2
    },
    {
      "id": "6aa2e0ab-5564-4fec-9898-218f8b0a3008",
      "name": "Extract PDF Text",
      "type": "n8n-nodes-base.extractFromFile",
      "position": [
        1200,
        624
      ],
      "parameters": {
        "options": {},
        "operation": "pdf",
        "binaryPropertyName": "Statement_File"
      },
      "typeVersion": 1
    },
    {
      "id": "f4561989-51e6-4ee6-bf29-ab1ed8b0693f",
      "name": "Extract CSV Text",
      "type": "n8n-nodes-base.extractFromFile",
      "position": [
        1200,
        896
      ],
      "parameters": {
        "options": {},
        "operation": "text",
        "binaryPropertyName": "Statement_File"
      },
      "typeVersion": 1
    },
    {
      "id": "f882f990-5c18-485c-9404-99877a1dd0a1",
      "name": "Combine File Branches",
      "type": "n8n-nodes-base.merge",
      "position": [
        1456,
        752
      ],
      "parameters": {},
      "typeVersion": 3
    },
    {
      "id": "bf4e4c1d-f74d-4cd5-aa06-6b716031e0ec",
      "name": "Parse Transactions",
      "type": "n8n-nodes-base.code",
      "position": [
        1776,
        752
      ],
      "parameters": {
        "jsCode": "const item = $input.first().json;\n\nlet text = item.text ?? item.data ?? '';\nif (typeof text !== 'string') text = String(text || '');\ntext = text.replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n').trim();\n\nif (!text) {\n  throw new Error('The uploaded file appears to be empty or could not be read. If it is a scanned PDF, the text cannot be extracted. Export a CSV from your bank instead.');\n}\n\nconst currencySymbol = (text.match(/[\u20b9$\u00a3\u20ac]/) || [''])[0];\n\n// ---------- helpers ----------\nconst detectDelimiter = (sampleLines) => {\n  const cands = [',', ';', '\\t', '|'];\n  let best = ',', bestScore = -1;\n  cands.forEach(d => {\n    const counts = sampleLines.map(l => l.split(d).length - 1).filter(c => c > 0);\n    if (counts.length < 2) return;\n    // prefer the delimiter that appears consistently on most lines\n    const avg = counts.reduce((a, b) => a + b, 0) / counts.length;\n    const score = counts.length * avg;\n    if (score > bestScore) { bestScore = score; best = d; }\n  });\n  return best;\n};\n\nconst parseCsvLine = (line, delim) => {\n  const out = [];\n  let cur = '', inQ = false;\n  for (let i = 0; i < line.length; i++) {\n    const ch = line[i];\n    if (ch === '\"') {\n      if (inQ && line[i + 1] === '\"') { cur += '\"'; i++; }\n      else inQ = !inQ;\n    } else if (ch === delim && !inQ) {\n      out.push(cur.trim()); cur = '';\n    } else cur += ch;\n  }\n  out.push(cur.trim());\n  return out;\n};\n\nconst toNumber = (raw) => {\n  if (raw === null || raw === undefined) return null;\n  let s = String(raw).trim();\n  if (!s) return null;\n\n  let neg = false;\n  if (/^\\(.*\\)$/.test(s)) { neg = true; s = s.slice(1, -1); }\n  if (/(^|\\s)(dr|debit)\\b/i.test(s)) neg = true;\n  if (/(^|\\s)(cr|credit)\\b/i.test(s)) neg = false;\n  if (s.trim().startsWith('-')) neg = true;\n\n  s = s.replace(/[\u20b9$\u00a3\u20ac]/g, '').replace(/\\b(dr|cr|debit|credit|inr|usd|gbp|eur)\\b/gi, '').trim();\n  s = s.replace(/[+\\-]/g, '').trim();\n\n  // 1.234,56 (european) vs 1,234.56\n  const lastComma = s.lastIndexOf(',');\n  const lastDot = s.lastIndexOf('.');\n  if (lastComma > lastDot) s = s.replace(/\\./g, '').replace(',', '.');\n  else s = s.replace(/,/g, '');\n\n  s = s.replace(/[^0-9.]/g, '');\n  if (!s || s === '.') return null;\n\n  const n = parseFloat(s);\n  if (!isFinite(n)) return null;\n  return neg ? -Math.abs(n) : Math.abs(n);\n};\n\nconst parseDate = (raw, order) => {\n  if (!raw) return null;\n  const s = String(raw).trim();\n\n  let m = s.match(/^(\\d{4})[-/.](\\d{1,2})[-/.](\\d{1,2})/);\n  if (m) return { y: +m[1], mo: +m[2], d: +m[3] };\n\n  m = s.match(/^(\\d{1,2})[-/.](\\d{1,2})[-/.](\\d{2,4})/);\n  if (m) {\n    let a = +m[1], b = +m[2], y = +m[3];\n    if (y < 100) y += 2000;\n    let d, mo;\n    if (a > 12) { d = a; mo = b; }\n    else if (b > 12) { d = b; mo = a; }\n    else if (order === 'mdy') { mo = a; d = b; }\n    else { d = a; mo = b; }\n    if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;\n    return { y, mo, d };\n  }\n\n  const months = { jan:1,feb:2,mar:3,apr:4,may:5,jun:6,jul:7,aug:8,sep:9,oct:10,nov:11,dec:12 };\n  m = s.match(/^(\\d{1,2})[-\\s]([a-z]{3})[a-z]*[-\\s](\\d{2,4})/i);\n  if (m) {\n    let y = +m[3]; if (y < 100) y += 2000;\n    return { y, mo: months[m[2].toLowerCase()], d: +m[1] };\n  }\n  m = s.match(/^([a-z]{3})[a-z]*\\s+(\\d{1,2}),?\\s+(\\d{4})/i);\n  if (m) return { y: +m[3], mo: months[m[1].toLowerCase()], d: +m[2] };\n\n  return null;\n};\n\n// Work out whether the file uses DD/MM or MM/DD, once, for the whole file.\n// Statements are normally in date order, so we pick the reading that produces\n// the most chronological sequence, and fall back to hard evidence if present.\nconst detectDateOrder = (rawDates) => {\n  const pairs = [];\n  rawDates.forEach(s => {\n    const m = String(s).trim().match(/^(\\d{1,2})[-/.](\\d{1,2})[-/.](\\d{2,4})/);\n    if (m) pairs.push([+m[1], +m[2], +m[3] < 100 ? +m[3] + 2000 : +m[3]]);\n  });\n  if (!pairs.length) return 'dmy';\n\n  if (pairs.some(p => p[0] > 12)) return 'dmy';\n  if (pairs.some(p => p[1] > 12)) return 'mdy';\n\n  const score = (order) => {\n    let ok = 0, prev = null;\n    pairs.forEach(p => {\n      const mo = order === 'mdy' ? p[0] : p[1];\n      const d = order === 'mdy' ? p[1] : p[0];\n      const v = p[2] * 10000 + mo * 100 + d;\n      if (prev !== null && v >= prev) ok++;\n      prev = v;\n    });\n    return ok;\n  };\n  return score('mdy') > score('dmy') ? 'mdy' : 'dmy';\n};\n\nconst fmtDate = (o) => o\n  ? o.y + '-' + String(o.mo).padStart(2, '0') + '-' + String(o.d).padStart(2, '0')\n  : '';\n\nconst cleanDesc = (s) => String(s || '')\n  .replace(/\\s+/g, ' ')\n  .replace(/^[\"']|[\"']$/g, '')\n  .trim();\n\n// ---------- CSV path ----------\nconst lines = text.split('\\n').map(l => l.trim()).filter(Boolean);\nconst delim = detectDelimiter(lines.slice(0, 20));\nconst delimCount = (l) => (l.split(delim).length - 1);\nconst looksCsv = lines.length > 2 && lines.slice(0, 15).filter(l => delimCount(l) >= 2).length >= 3;\n\nlet transactions = [];\nlet parseMode = '';\nlet dateOrder = 'dmy';\n\nif (looksCsv) {\n  parseMode = 'csv';\n\n  const AMT_HEADER = /amount|amt|debit|credit|withdraw|deposit|paid\\s*out|paid\\s*in|money\\s*out|money\\s*in|betrag|value|dr|cr/;\n  const DATE_HEADER = /date|datum|posted|txn|fecha/;\n\n  // find the header row: the first row whose cells include a date-ish and amount-ish word\n  let headerIdx = -1, header = [];\n  for (let i = 0; i < Math.min(lines.length, 25); i++) {\n    const cells = parseCsvLine(lines[i], delim).map(c => c.toLowerCase());\n    if (cells.some(c => DATE_HEADER.test(c)) && cells.some(c => AMT_HEADER.test(c))) {\n      headerIdx = i; header = cells; break;\n    }\n  }\n\n  const findCol = (patterns, exclude) => {\n    for (const p of patterns) {\n      for (let i = 0; i < header.length; i++) {\n        if (exclude && exclude.test(header[i])) continue;\n        if (p.test(header[i])) return i;\n      }\n    }\n    return -1;\n  };\n\n  let iDate = -1, iDesc = -1, iAmt = -1, iDebit = -1, iCredit = -1, iType = -1;\n\n  if (headerIdx >= 0) {\n    iDate = findCol([/^date$/, /txn.*date/, /transaction date/, /value date/, /posted/, /datum/, /date/]);\n    iDesc = findCol([/description/, /particular/, /narration/, /details/, /merchant/, /payee/, /remark/, /memo/, /beschreibung/, /reference/]);\n    iDebit = findCol([/^debit$/, /debit amount/, /withdraw/, /paid\\s*out/, /money\\s*out/, /debit/], /indicator|type|balance/);\n    iCredit = findCol([/^credit$/, /credit amount/, /deposit/, /paid\\s*in/, /money\\s*in/, /credit/], /indicator|type|balance/);\n    iAmt = findCol([/^amount$/, /^amt$/, /^betrag$/, /transaction amount/, /amount/], /balance/);\n    iType = findCol([/^type$/, /dr.*cr/, /cr.*dr/, /indicator/]);\n  }\n\n  const dataStart = headerIdx >= 0 ? headerIdx + 1 : 0;\n\n  // decide DD/MM vs MM/DD once for the whole file\n  const rawDateSamples = [];\n  for (let i = dataStart; i < lines.length; i++) {\n    const cells = parseCsvLine(lines[i], delim);\n    if (iDate >= 0 && cells[iDate]) rawDateSamples.push(cells[iDate]);\n    else cells.forEach(c => { if (/^\\d{1,2}[-/.]\\d{1,2}[-/.]\\d{2,4}/.test(String(c).trim())) rawDateSamples.push(c); });\n  }\n  dateOrder = detectDateOrder(rawDateSamples);\n\n  for (let i = dataStart; i < lines.length; i++) {\n    const cells = parseCsvLine(lines[i], delim);\n    if (cells.length < 2) continue;\n\n    let dateObj = null, desc = '', amount = null;\n\n    if (headerIdx >= 0) {\n      dateObj = parseDate(cells[iDate], dateOrder);\n      desc = cleanDesc(cells[iDesc]);\n\n      if (iDebit >= 0 || iCredit >= 0) {\n        const dv = iDebit >= 0 ? toNumber(cells[iDebit]) : null;\n        const cv = iCredit >= 0 ? toNumber(cells[iCredit]) : null;\n        if (dv !== null && Math.abs(dv) > 0) amount = -Math.abs(dv);\n        else if (cv !== null && Math.abs(cv) > 0) amount = Math.abs(cv);\n      }\n      if (amount === null && iAmt >= 0) {\n        amount = toNumber(cells[iAmt]);\n        if (amount !== null && iType >= 0) {\n          const t = String(cells[iType] || '').toLowerCase();\n          if (/^dr|debit|withdraw/.test(t)) amount = -Math.abs(amount);\n          else if (/^cr|credit|deposit/.test(t)) amount = Math.abs(amount);\n        }\n      }\n    }\n\n    // fallback: scan the row positionally\n    if (!dateObj || amount === null) {\n      for (let c = 0; c < cells.length && !dateObj; c++) dateObj = parseDate(cells[c], dateOrder);\n      if (amount === null) {\n        const nums = [];\n        cells.forEach((c, idx) => {\n          const n = toNumber(c);\n          if (n !== null && /\\d/.test(c)) nums.push({ n, idx });\n        });\n        if (nums.length) amount = nums[nums.length > 1 ? nums.length - 2 : 0].n;\n      }\n      if (!desc) {\n        const textCells = cells.filter(c => c && !parseDate(c, dateOrder) && toNumber(c) === null);\n        desc = cleanDesc(textCells.sort((a, b) => b.length - a.length)[0] || '');\n      }\n    }\n\n    if (!dateObj || amount === null || amount === 0) continue;\n    if (!desc) desc = 'Unlabelled transaction';\n\n    transactions.push({ date: fmtDate(dateObj), y: dateObj.y, mo: dateObj.mo, d: dateObj.d, description: desc, amount });\n  }\n}\n\n// ---------- PDF / plain text path ----------\nif (!transactions.length) {\n  parseMode = 'text';\n  const preDateRe = /(\\d{1,2}[-/.]\\d{1,2}[-/.]\\d{2,4})/;\n  const textDateSamples = [];\n  text.split('\\n').forEach(l => { const m = l.match(preDateRe); if (m) textDateSamples.push(m[1]); });\n  dateOrder = detectDateOrder(textDateSamples);\n\n  const dateRe = /(\\d{1,2}[-/.][A-Za-z]{3,}[-/.]\\d{2,4}|\\d{4}-\\d{2}-\\d{2}|\\d{1,2}[-/.]\\d{1,2}[-/.]\\d{2,4}|[A-Za-z]{3,}\\s+\\d{1,2},?\\s+\\d{4})/;\n  const amtRe = /(\\(?-?[\u20b9$\u00a3\u20ac]?\\s?\\d[\\d,]*\\.?\\d{0,2}\\)?\\s?(?:dr|cr)?)\\s*$/i;\n\n  text.split('\\n').forEach(line => {\n    const l = line.trim();\n    if (!l || l.length < 8) return;\n    const dm = l.match(dateRe);\n    if (!dm) return;\n    const dateObj = parseDate(dm[1], dateOrder);\n    if (!dateObj) return;\n\n    const rest = l.slice(dm.index + dm[1].length).trim();\n    const allNums = rest.match(/\\(?-?[\u20b9$\u00a3\u20ac]?\\s?\\d[\\d,]*\\.?\\d{0,2}\\)?\\s?(?:dr|cr)?/gi) || [];\n    if (!allNums.length) return;\n\n    // last number on a statement line is usually the running balance\n    const amtRaw = allNums.length > 1 ? allNums[allNums.length - 2] : allNums[0];\n    let amount = toNumber(amtRaw);\n    if (amount === null || amount === 0) return;\n\n    if (/\\bcr\\b|credit|deposit/i.test(rest) && amount < 0) amount = Math.abs(amount);\n    else if (/\\bdr\\b|debit|withdraw/i.test(rest)) amount = -Math.abs(amount);\n    else if (amount > 0 && allNums.length > 1) amount = -amount; // most statement rows are spends\n\n    let desc = rest;\n    allNums.forEach(n => { desc = desc.replace(n, ' '); });\n    desc = cleanDesc(desc);\n    if (!desc || desc.length < 2) desc = 'Unlabelled transaction';\n\n    transactions.push({ date: fmtDate(dateObj), y: dateObj.y, mo: dateObj.mo, d: dateObj.d, description: desc, amount });\n  });\n}\n\nif (!transactions.length) {\n  throw new Error('Could not read any transactions from this file. Supported: a CSV exported from your bank, or a text based PDF statement. Scanned or image PDFs will not work. Check that the file has a date column and an amount column.');\n}\n\n// ---------- normalize merchant names ----------\nconst normalize = (d) => {\n  let s = ' ' + d.toLowerCase() + ' ';\n  s = s.replace(/\\b(upi|neft|imps|rtgs|ach|pos|atm|ecs|nach|vps|ref|txn|trf|tfr|pmt|payment to|paid to|purchase at|card ending|xx+\\d*)\\b/g, ' ');\n  s = s.replace(/\\b\\d{4,}\\b/g, ' ');\n  s = s.replace(/[^a-z\\s&.]/g, ' ');\n  s = s.replace(/\\s+/g, ' ').trim();\n  const words = s.split(' ').filter(w => w.length > 1).slice(0, 4);\n  return words.join(' ') || d.toLowerCase().slice(0, 30);\n};\n\ntransactions.forEach(t => { t.merchant = normalize(t.description); });\n\n// unique merchants only, so the LLM classifies a small list instead of every row\nconst uniqueMap = {};\ntransactions.forEach(t => {\n  if (!uniqueMap[t.merchant]) uniqueMap[t.merchant] = { merchant: t.merchant, sample: t.description, count: 0 };\n  uniqueMap[t.merchant].count++;\n});\nconst uniqueMerchants = Object.values(uniqueMap).sort((a, b) => b.count - a.count);\n\nconst spendTotal = transactions.filter(t => t.amount < 0).reduce((s, t) => s + Math.abs(t.amount), 0);\nconst incomeTotal = transactions.filter(t => t.amount > 0).reduce((s, t) => s + t.amount, 0);\n\nconst dates = transactions.map(t => t.date).filter(Boolean).sort();\n\nreturn [{\n  json: {\n    parse_mode: parseMode,\n    date_order: dateOrder === 'mdy' ? 'month/day/year' : 'day/month/year',\n    currency: currencySymbol,\n    transaction_count: transactions.length,\n    unique_merchant_count: uniqueMerchants.length,\n    period_start: dates[0] || '',\n    period_end: dates[dates.length - 1] || '',\n    spend_total: Number(spendTotal.toFixed(2)),\n    income_total: Number(incomeTotal.toFixed(2)),\n    transactions,\n    unique_merchants: uniqueMerchants\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "07ce6baa-e8cb-4c35-85a4-7a878c0a18f9",
      "name": "Build Category Prompt",
      "type": "n8n-nodes-base.code",
      "position": [
        2016,
        752
      ],
      "parameters": {
        "jsCode": "const d = $input.first().json;\n\nconst list = d.unique_merchants\n  .map((m, i) => (i + 1) + '. \"' + m.merchant + '\"  (example on statement: ' + m.sample.slice(0, 60) + ', appears ' + m.count + 'x)')\n  .join('\\n');\n\nconst prompt = [\n  'Below is a list of merchant names taken from a bank statement. Assign each one a spending category.',\n  '',\n  'Allowed categories, use these exact strings:',\n  'Housing, Groceries, Dining & Takeaway, Transport, Fuel, Utilities, Phone & Internet, Subscriptions, Shopping, Health & Medical, Insurance, Education, Entertainment, Travel, Fees & Charges, Cash Withdrawal, Transfers, Income, Business Expense, Other',\n  '',\n  'Guidance:',\n  '- Income is only for money coming in such as salary, client payment, refund, interest.',\n  '- Subscriptions means recurring digital or membership services such as streaming, software, gym.',\n  '- Transfers means moving money between own accounts, or person to person transfers with no clear purpose.',\n  '- Fees & Charges means bank fees, interest charged, penalties, late fees.',\n  '- Use Other only when the name genuinely gives no clue. Do not overuse it.',\n  '- The merchant names are messy and abbreviated. Infer from partial names where a reasonable person could.',\n  '',\n  'Merchants:',\n  list,\n  '',\n  'Return strictly this JSON object and nothing else:',\n  '{',\n  '  \"categories\": [',\n  '    { \"merchant\": \"the exact merchant string given above\", \"category\": \"one of the allowed categories\", \"clean_name\": \"a readable brand or payee name, title case, or the merchant string cleaned up if unknown\" }',\n  '  ]',\n  '}',\n  '',\n  'Rules:',\n  '- Return one entry for every merchant listed, using the exact merchant string as given.',\n  '- Never invent merchants that were not listed.',\n  '- Do not use em dashes anywhere in the output.',\n  '- No markdown code fences, no explanation before or after the JSON.'\n].join('\\n');\n\nreturn [{ json: { prompt, payload: d } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "10bc8f40-d3e0-497e-97f5-be601dedce28",
      "name": "Categorize Payees",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        2240,
        752
      ],
      "parameters": {
        "text": "={{ $json.prompt }}",
        "options": {
          "systemMessage": "You categorise bank statement payees. You are given messy abbreviated merchant strings and you assign each one a category from a fixed list, plus a readable name.\n\nYou never perform arithmetic, never invent merchants, and never return a merchant that was not in the input list.\n\nYou always respond with a single valid JSON object and nothing else. No markdown code fences, no preamble, no explanation."
        },
        "promptType": "define"
      },
      "typeVersion": 2
    },
    {
      "id": "22ec7ac1-60a6-4f95-9da8-7f91e98c775e",
      "name": "OpenAI Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        2624,
        1200
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4o-mini"
        },
        "options": {
          "temperature": 0.1,
          "responseFormat": "json_object"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "bfe10ec1-4f94-46f4-b269-530defbe7d5a",
      "name": "Calculate Totals",
      "type": "n8n-nodes-base.code",
      "position": [
        2560,
        752
      ],
      "parameters": {
        "jsCode": "const res = $input.first().json;\n\nlet raw = res.output ?? res.text ?? res.content ?? '';\nif (typeof raw === 'object' && raw !== null) raw = JSON.stringify(raw);\nraw = String(raw).replace(/```json/gi, '').replace(/```/g, '').trim();\n\nlet parsed = null;\ntry { parsed = JSON.parse(raw); } catch (e) { parsed = null; }\n\nconst d = $('Build Category Prompt').first().json.payload;\n\n// map merchant -> {category, clean_name}\nconst map = {};\nif (parsed && Array.isArray(parsed.categories)) {\n  parsed.categories.forEach(c => {\n    if (c && c.merchant) {\n      map[String(c.merchant).toLowerCase()] = {\n        category: c.category || 'Other',\n        clean_name: c.clean_name || c.merchant\n      };\n    }\n  });\n}\n\nconst titleCase = (s) => String(s).replace(/\\b\\w/g, ch => ch.toUpperCase());\n\nlet uncategorized = 0;\nconst txns = d.transactions.map(t => {\n  const hit = map[t.merchant.toLowerCase()];\n  if (!hit) uncategorized++;\n  let category = hit ? hit.category : (t.amount > 0 ? 'Income' : 'Other');\n  // trust the sign over the label: money in cannot be an expense category\n  if (t.amount > 0 && category !== 'Income' && category !== 'Transfers') category = 'Income';\n  if (t.amount < 0 && category === 'Income') category = 'Other';\n  return {\n    date: t.date,\n    y: t.y,\n    mo: t.mo,\n    d: t.d,\n    description: t.description,\n    merchant: t.merchant,\n    clean_name: hit ? hit.clean_name : titleCase(t.merchant),\n    category,\n    amount: t.amount\n  };\n});\n\nconst round = (n) => Number(n.toFixed(2));\n\n// ---------- category totals (spend only) ----------\nconst catMap = {};\ntxns.filter(t => t.amount < 0).forEach(t => {\n  if (!catMap[t.category]) catMap[t.category] = { category: t.category, total: 0, count: 0, merchants: {} };\n  const c = catMap[t.category];\n  const amt = Math.abs(t.amount);\n  c.total += amt;\n  c.count++;\n  if (!c.merchants[t.clean_name]) c.merchants[t.clean_name] = 0;\n  c.merchants[t.clean_name] += amt;\n});\n\nconst spendTotal = Object.values(catMap).reduce((s, c) => s + c.total, 0);\n\nconst categories = Object.values(catMap).map(c => ({\n  category: c.category,\n  total: round(c.total),\n  count: c.count,\n  pct: spendTotal ? round((c.total / spendTotal) * 100) : 0,\n  top_merchants: Object.keys(c.merchants)\n    .map(k => ({ name: k, total: round(c.merchants[k]) }))\n    .sort((a, b) => b.total - a.total)\n    .slice(0, 4)\n})).sort((a, b) => b.total - a.total);\n\n// ---------- monthly totals ----------\nconst monthMap = {};\ntxns.forEach(t => {\n  const key = t.y + '-' + String(t.mo).padStart(2, '0');\n  if (!monthMap[key]) monthMap[key] = { month: key, spend: 0, income: 0, count: 0 };\n  if (t.amount < 0) monthMap[key].spend += Math.abs(t.amount);\n  else monthMap[key].income += t.amount;\n  monthMap[key].count++;\n});\nconst months = Object.values(monthMap)\n  .map(m => ({ month: m.month, spend: round(m.spend), income: round(m.income), net: round(m.income - m.spend), count: m.count }))\n  .sort((a, b) => a.month.localeCompare(b.month));\n\n// ---------- recurring detection (measured, not guessed) ----------\nconst byMerchant = {};\ntxns.filter(t => t.amount < 0).forEach(t => {\n  if (!byMerchant[t.merchant]) byMerchant[t.merchant] = [];\n  byMerchant[t.merchant].push(t);\n});\n\nconst recurring = [];\n// categories where spend naturally varies, so a stable-looking run is coincidence\nconst VARIABLE_CATS = /^(Groceries|Dining & Takeaway|Fuel|Shopping|Transport|Cash Withdrawal|Transfers|Travel|Health & Medical|Other)$/;\n\nObject.keys(byMerchant).forEach(m => {\n  const list = byMerchant[m];\n  const distinctMonths = Array.from(new Set(list.map(t => t.y + '-' + String(t.mo).padStart(2, '0')))).sort();\n  if (distinctMonths.length < 2) return;\n\n  // roughly one charge per month, not a merchant visited many times\n  if (list.length > distinctMonths.length * 1.5) return;\n\n  const cat = list[0].category;\n  const amounts = list.map(t => Math.abs(t.amount));\n  const avg = amounts.reduce((a, b) => a + b, 0) / amounts.length;\n  if (avg <= 0) return;\n\n  const maxDev = Math.max.apply(null, amounts.map(a => Math.abs(a - avg)));\n  const variance = maxDev / avg;\n\n  // a real subscription bills a near identical amount each time\n  const tolerance = VARIABLE_CATS.test(cat) ? 0.02 : 0.1;\n  if (variance > tolerance) return;\n\n  // charged on a similar day of the month\n  const days = list.map(t => t.d || Number(String(t.date).slice(8, 10))).filter(n => n > 0);\n  if (days.length >= 2) {\n    const avgDay = days.reduce((a, b) => a + b, 0) / days.length;\n    const dayDev = Math.max.apply(null, days.map(x => Math.min(Math.abs(x - avgDay), 31 - Math.abs(x - avgDay))));\n    if (dayDev > 7) return;\n  }\n\n  recurring.push({\n    name: list[0].clean_name,\n    category: cat,\n    typical_amount: round(avg),\n    charges: list.length,\n    months_seen: distinctMonths.length,\n    first_seen: distinctMonths[0],\n    last_seen: distinctMonths[distinctMonths.length - 1],\n    total_paid: round(amounts.reduce((a, b) => a + b, 0)),\n    annualized: round(avg * 12)\n  });\n});\nrecurring.sort((a, b) => b.annualized - a.annualized);\n\nconst lastMonth = months.length ? months[months.length - 1].month : '';\nconst dormant = recurring.filter(r => lastMonth && r.last_seen !== lastMonth);\n\n// ---------- top single transactions ----------\nconst biggest = txns.filter(t => t.amount < 0)\n  .sort((a, b) => Math.abs(b.amount) - Math.abs(a.amount))\n  .slice(0, 10)\n  .map(t => ({ date: t.date, name: t.clean_name, description: t.description, category: t.category, amount: round(Math.abs(t.amount)) }));\n\nconst incomeTotal = txns.filter(t => t.amount > 0).reduce((s, t) => s + t.amount, 0);\nconst monthCount = months.length || 1;\n\nreturn [{\n  json: {\n    currency: d.currency,\n    parse_mode: d.parse_mode,\n    llm_ok: !!parsed,\n    uncategorized_count: uncategorized,\n    period_start: d.period_start,\n    period_end: d.period_end,\n    transaction_count: txns.length,\n    month_count: monthCount,\n    spend_total: round(spendTotal),\n    income_total: round(incomeTotal),\n    net_total: round(incomeTotal - spendTotal),\n    avg_monthly_spend: round(spendTotal / monthCount),\n    categories,\n    months,\n    recurring,\n    dormant_subscriptions: dormant,\n    recurring_annual_total: round(recurring.reduce((s, r) => s + r.annualized, 0)),\n    biggest_transactions: biggest,\n    transactions: txns\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "8a42e0e8-aac0-4905-8647-fc236b22edd5",
      "name": "Build Insights Prompt",
      "type": "n8n-nodes-base.code",
      "position": [
        2768,
        752
      ],
      "parameters": {
        "jsCode": "const d = $input.first().json;\nconst cur = d.currency || '';\n\nconst fmt = (n) => cur + Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });\n\nconst catLines = d.categories.map(c =>\n  '- ' + c.category + ': ' + fmt(c.total) + ' (' + c.pct + '% of spend, ' + c.count + ' transactions, top: '\n  + c.top_merchants.map(m => m.name + ' ' + fmt(m.total)).join(', ') + ')'\n).join('\\n');\n\nconst monthLines = d.months.map(m =>\n  '- ' + m.month + ': spent ' + fmt(m.spend) + ', received ' + fmt(m.income) + ', net ' + fmt(m.net)\n).join('\\n');\n\nconst recLines = d.recurring.length\n  ? d.recurring.map(r =>\n      '- ' + r.name + ' (' + r.category + '): ' + fmt(r.typical_amount) + ' per charge, seen in '\n      + r.months_seen + ' months, last charged ' + r.last_seen + ', annualised ' + fmt(r.annualized)\n    ).join('\\n')\n  : '- none detected';\n\nconst dormantLines = d.dormant_subscriptions.length\n  ? d.dormant_subscriptions.map(r => '- ' + r.name + ', last charged ' + r.last_seen + ', was ' + fmt(r.typical_amount) + ' per charge').join('\\n')\n  : '- none';\n\nconst bigLines = d.biggest_transactions.map(t =>\n  '- ' + t.date + ' ' + t.name + ' (' + t.category + '): ' + fmt(t.amount)\n).join('\\n');\n\nconst prompt = [\n  'Here is a summary of one bank statement. Every figure below was calculated arithmetically from the statement, so treat all numbers as exact and never recalculate or contradict them.',\n  '',\n  'Period: ' + d.period_start + ' to ' + d.period_end + ' (' + d.month_count + ' month(s), ' + d.transaction_count + ' transactions)',\n  'Total spent: ' + fmt(d.spend_total),\n  'Total received: ' + fmt(d.income_total),\n  'Net: ' + fmt(d.net_total),\n  'Average monthly spend: ' + fmt(d.avg_monthly_spend),\n  '',\n  '## Spend by category',\n  catLines,\n  '',\n  '## Month by month',\n  monthLines,\n  '',\n  '## Repeating charges detected (same payee, near identical amount, similar day each month)',\n  recLines,\n  '',\n  '## Repeating charges that stopped before the final month',\n  dormantLines,\n  '',\n  '## Largest single transactions',\n  bigLines,\n  '',\n  'Write a short, useful read of this statement. Return strictly this JSON object:',\n  '{',\n  '  \"summary\": \"3 to 4 sentences describing where the money actually went this period, in plain language, quoting the real figures\",',\n  '  \"observations\": [ { \"title\": \"short headline\", \"detail\": \"2 to 3 sentences citing the exact figures from above\", \"tag\": \"Watch|Notable|Positive\" } ],',\n  '  \"subscription_note\": \"2 to 3 sentences about the repeating charges, including the total annualised cost, and specifically calling out any that stopped mid period since those may be forgotten or cancelled. If none were detected, say so plainly.\",',\n  '  \"questions_to_check\": [ \"a specific thing the account holder should verify on their own statement, phrased as a question\" ]',\n  '}',\n  '',\n  'Rules:',\n  '- Give 4 to 6 observations and 3 to 5 questions_to_check.',\n  '- Only use the figures given above. Never invent a number, a merchant, or a trend you cannot see in this data.',\n  '- If the period is a single month, do not describe trends over time.',\n  '- Describe what the data shows. Do not give investment advice, do not recommend financial products, and do not tell the person what they should or should not buy.',\n  '- Categories were assigned automatically from payee names and can be wrong, so do not treat any single categorisation as certain.',\n  '- Do not use em dashes anywhere in the output.',\n  '- No markdown code fences, no explanation before or after the JSON.'\n].join('\\n');\n\nreturn [{ json: { prompt, payload: d } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "bbbf22da-cacb-4d27-abde-aee4fb8f5c64",
      "name": "Insights Agent",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        2976,
        752
      ],
      "parameters": {
        "text": "={{ $json.prompt }}",
        "options": {
          "systemMessage": "You read a pre-calculated bank statement summary and describe what it shows, in plain language.\n\nEvery figure you are given was computed arithmetically. You treat those figures as exact, quote them as given, and never recalculate or contradict them. You never invent a number, merchant or trend that is not in the data.\n\nYou describe what the data shows. You do not give investment advice, do not recommend financial products, and do not tell the person what they should or should not buy.\n\nYou always respond with a single valid JSON object and nothing else. No markdown code fences, no preamble, no explanation."
        },
        "promptType": "define"
      },
      "typeVersion": 2
    },
    {
      "id": "907632ab-aa08-470b-ada5-903883020d21",
      "name": "Build Report Page",
      "type": "n8n-nodes-base.code",
      "position": [
        3456,
        752
      ],
      "parameters": {
        "jsCode": "const res = $input.first().json;\n\nlet raw = res.output ?? res.text ?? res.content ?? '';\nif (typeof raw === 'object' && raw !== null) raw = JSON.stringify(raw);\nraw = String(raw).replace(/```json/gi, '').replace(/```/g, '').trim();\n\nconst esc = (s) => String(s == null ? '' : s)\n  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\n\nlet a = null;\ntry { a = JSON.parse(raw); } catch (e) { a = null; }\n\nconst d = $('Build Insights Prompt').first().json.payload;\nconst cur = d.currency || '';\nconst fmt = (n) => cur + Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });\n\n// ---------- summary tiles ----------\nconst tiles = '<div class=\"tiles\">'\n  + '<div class=\"tile\"><div class=\"tlabel\">Total spent</div><div class=\"tval neg\">' + esc(fmt(d.spend_total)) + '</div></div>'\n  + '<div class=\"tile\"><div class=\"tlabel\">Total received</div><div class=\"tval pos\">' + esc(fmt(d.income_total)) + '</div></div>'\n  + '<div class=\"tile\"><div class=\"tlabel\">Net</div><div class=\"tval ' + (d.net_total >= 0 ? 'pos' : 'neg') + '\">' + esc(fmt(d.net_total)) + '</div></div>'\n  + '<div class=\"tile\"><div class=\"tlabel\">Avg monthly spend</div><div class=\"tval\">' + esc(fmt(d.avg_monthly_spend)) + '</div></div>'\n  + '</div>';\n\n// ---------- category bars ----------\nconst maxCat = d.categories.length ? d.categories[0].total : 1;\nconst catRows = d.categories.map(c =>\n  '<div class=\"crow\">'\n  + '<div class=\"cname\">' + esc(c.category) + '<span class=\"ccount\">' + c.count + ' txn</span></div>'\n  + '<div class=\"cbarwrap\"><div class=\"cbar\" style=\"width:' + Math.max(1, (c.total / maxCat) * 100) + '%\"></div></div>'\n  + '<div class=\"camt\">' + esc(fmt(c.total)) + '<span class=\"cpct\">' + c.pct + '%</span></div>'\n  + '<div class=\"cmerch\">' + esc(c.top_merchants.map(m => m.name).join(', ')) + '</div>'\n  + '</div>'\n).join('');\n\n// ---------- monthly table ----------\nlet monthTable = '';\nif (d.months.length > 1) {\n  monthTable = '<h2>Month by month</h2><div class=\"tablewrap\"><table class=\"tbl\"><thead><tr>'\n    + '<th>Month</th><th class=\"r\">Spent</th><th class=\"r\">Received</th><th class=\"r\">Net</th><th class=\"r\">Transactions</th>'\n    + '</tr></thead><tbody>'\n    + d.months.map(m => '<tr><td>' + esc(m.month) + '</td>'\n        + '<td class=\"r neg\">' + esc(fmt(m.spend)) + '</td>'\n        + '<td class=\"r pos\">' + esc(fmt(m.income)) + '</td>'\n        + '<td class=\"r ' + (m.net >= 0 ? 'pos' : 'neg') + '\">' + esc(fmt(m.net)) + '</td>'\n        + '<td class=\"r\">' + m.count + '</td></tr>').join('')\n    + '</tbody></table></div>';\n}\n\n// ---------- recurring ----------\nlet recSection = '';\nif (d.recurring.length) {\n  const dormantNames = {};\n  d.dormant_subscriptions.forEach(x => { dormantNames[x.name] = true; });\n  recSection = '<h2>Repeating charges</h2>'\n    + '<div class=\"hint\">Detected in code: same payee, near identical amount, similar day each month. Annualised means the typical charge multiplied by 12, not a figure taken from the statement.</div>'\n    + '<div class=\"tablewrap\"><table class=\"tbl\"><thead><tr>'\n    + '<th>Payee</th><th>Category</th><th class=\"r\">Typical</th><th class=\"r\">Charges</th><th>Last charged</th><th class=\"r\">Annualised</th>'\n    + '</tr></thead><tbody>'\n    + d.recurring.map(r => '<tr' + (dormantNames[r.name] ? ' class=\"dormant\"' : '') + '>'\n        + '<td>' + esc(r.name) + (dormantNames[r.name] ? ' <span class=\"pill\">stopped</span>' : '') + '</td>'\n        + '<td>' + esc(r.category) + '</td>'\n        + '<td class=\"r\">' + esc(fmt(r.typical_amount)) + '</td>'\n        + '<td class=\"r\">' + r.charges + '</td>'\n        + '<td>' + esc(r.last_seen) + '</td>'\n        + '<td class=\"r\">' + esc(fmt(r.annualized)) + '</td></tr>').join('')\n    + '</tbody><tfoot><tr><td colspan=\"5\"><b>Total if all continue for a year</b></td>'\n    + '<td class=\"r\"><b>' + esc(fmt(d.recurring_annual_total)) + '</b></td></tr></tfoot></table></div>';\n}\n\n// ---------- biggest ----------\nconst bigSection = '<h2>Largest transactions</h2><div class=\"tablewrap\"><table class=\"tbl\"><thead><tr>'\n  + '<th>Date</th><th>Payee</th><th>Category</th><th class=\"r\">Amount</th></tr></thead><tbody>'\n  + d.biggest_transactions.map(t => '<tr><td>' + esc(t.date) + '</td><td>' + esc(t.name) + '</td>'\n      + '<td>' + esc(t.category) + '</td><td class=\"r neg\">' + esc(fmt(t.amount)) + '</td></tr>').join('')\n  + '</tbody></table></div>';\n\n// ---------- AI narrative ----------\nlet aiSection = '';\nif (a) {\n  const tagClass = (t) => t === 'Watch' ? 'hi' : (t === 'Positive' ? 'ok' : 'md');\n  aiSection = '<h2>What the numbers say</h2>'\n    + '<div class=\"verdict\">' + esc(a.summary) + '</div>';\n\n  if (Array.isArray(a.observations)) {\n    aiSection += a.observations.map(o =>\n      '<div class=\"obs\"><div class=\"obshead\">' + esc(o.title)\n      + ' <span class=\"badge ' + tagClass(o.tag) + '\">' + esc(o.tag) + '</span></div>'\n      + '<div class=\"obsdetail\">' + esc(o.detail) + '</div></div>'\n    ).join('');\n  }\n  if (a.subscription_note) {\n    aiSection += '<div class=\"obs\"><div class=\"obshead\">On the repeating charges</div>'\n      + '<div class=\"obsdetail\">' + esc(a.subscription_note) + '</div></div>';\n  }\n  if (Array.isArray(a.questions_to_check) && a.questions_to_check.length) {\n    aiSection += '<h2>Worth checking yourself</h2><ul class=\"checks\">'\n      + a.questions_to_check.map(q => '<li>' + esc(q) + '</li>').join('') + '</ul>';\n  }\n} else {\n  aiSection = '<h2>What the numbers say</h2><div class=\"warn\">The written summary could not be generated this time, but every figure and table above was calculated directly from your statement and is unaffected.</div>';\n}\n\n// ---------- full transaction table + CSV export ----------\nconst txnRows = d.transactions.map(t =>\n  '<tr data-cat=\"' + esc(t.category) + '\"><td>' + esc(t.date) + '</td>'\n  + '<td>' + esc(t.clean_name) + '<div class=\"rawdesc\">' + esc(t.description) + '</div></td>'\n  + '<td>' + esc(t.category) + '</td>'\n  + '<td class=\"r ' + (t.amount < 0 ? 'neg' : 'pos') + '\">' + esc(fmt(Math.abs(t.amount))) + '</td></tr>'\n).join('');\n\nconst csvRows = [['Date', 'Payee', 'Description', 'Category', 'Amount']]\n  .concat(d.transactions.map(t => [t.date, t.clean_name, t.description, t.category, t.amount]));\nconst csvText = csvRows.map(r => r.map(c => '\"' + String(c).replace(/\"/g, '\"\"') + '\"').join(',')).join('\\n');\nconst csvB64 = Buffer.from(csvText, 'utf8').toString('base64');\n\nconst catOptions = ['<option value=\"\">All categories</option>']\n  .concat(d.categories.map(c => '<option value=\"' + esc(c.category) + '\">' + esc(c.category) + '</option>')).join('');\n\nconst txnSection = '<h2>All transactions</h2>'\n  + '<div class=\"controls\"><select id=\"catfilter\">' + catOptions + '</select>'\n  + '<a class=\"dl\" download=\"categorized-transactions.csv\" href=\"data:text/csv;base64,' + csvB64 + '\">Download CSV</a></div>'\n  + '<div class=\"tablewrap\"><table class=\"tbl\" id=\"txntable\"><thead><tr>'\n  + '<th>Date</th><th>Payee</th><th>Category</th><th class=\"r\">Amount</th></tr></thead><tbody>'\n  + txnRows + '</tbody></table></div>';\n\nconst parseNote = '<div class=\"hint\">Read ' + d.transaction_count + ' transactions from '\n  + esc(d.period_start) + ' to ' + esc(d.period_end)\n  + ' (' + esc(d.parse_mode === 'csv' ? 'CSV' : 'text') + ' mode, dates read as ' + esc(d.date_order || 'day/month/year') + ')'\n  + (d.uncategorized_count ? '. ' + d.uncategorized_count + ' transaction(s) could not be categorised and were placed in Other.' : '')\n  + '</div>';\n\nconst styles = '*{box-sizing:border-box}'\n  + 'body{font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,sans-serif;background:#f4f5f7;color:#1f2328;margin:0;padding:32px 16px;line-height:1.55}'\n  + '.wrap{max-width:960px;margin:0 auto}'\n  + 'h1{font-size:26px;margin:0 0 4px}h2{font-size:19px;margin:34px 0 12px;padding-bottom:6px;border-bottom:2px solid #d8dee4}'\n  + '.hint{font-size:12.5px;color:#57606a;margin-bottom:12px}'\n  + '.tiles{display:flex;gap:12px;flex-wrap:wrap;margin:18px 0 8px}'\n  + '.tile{flex:1;min-width:160px;background:#fff;border:1px solid #d8dee4;border-radius:10px;padding:14px}'\n  + '.tlabel{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:#57606a;margin-bottom:6px}'\n  + '.tval{font-size:22px;font-weight:700}'\n  + '.pos{color:#1a7f37}.neg{color:#cf222e}'\n  + '.crow{display:grid;grid-template-columns:150px 1fr 120px;gap:10px;align-items:center;background:#fff;border:1px solid #d8dee4;border-radius:10px;padding:10px 14px;margin-bottom:8px}'\n  + '.cname{font-weight:600;font-size:14px}'\n  + '.ccount{display:block;font-weight:400;font-size:11px;color:#57606a}'\n  + '.cbarwrap{background:#eaeef2;border-radius:6px;height:14px;overflow:hidden}'\n  + '.cbar{background:#1f2328;height:100%}'\n  + '.camt{text-align:right;font-weight:600;font-size:14px}'\n  + '.cpct{display:block;font-weight:400;font-size:11px;color:#57606a}'\n  + '.cmerch{grid-column:1/-1;font-size:12px;color:#57606a}'\n  + '.tablewrap{overflow-x:auto}'\n  + '.tbl{width:100%;border-collapse:collapse;background:#fff;border:1px solid #d8dee4;border-radius:10px;overflow:hidden;font-size:13px}'\n  + '.tbl th{background:#f6f8fa;text-align:left;padding:10px;border-bottom:1px solid #d8dee4;font-size:12px}'\n  + '.tbl td{padding:9px 10px;border-bottom:1px solid #eaeef2;vertical-align:top}'\n  + '.tbl tfoot td{background:#f6f8fa;border-top:1px solid #d8dee4}'\n  + '.tbl .r{text-align:right}'\n  + '.tbl tr.dormant{background:#fff8c5}'\n  + '.pill{font-size:10px;background:#eac54f;color:#4d2d00;padding:1px 7px;border-radius:20px;vertical-align:middle}'\n  + '.rawdesc{font-size:11px;color:#8c959f;margin-top:2px}'\n  + '.verdict{background:#fff;border-left:4px solid #1f2328;border-radius:8px;padding:16px;font-size:15px;margin-bottom:14px}'\n  + '.obs{background:#fff;border:1px solid #d8dee4;border-radius:10px;padding:14px;margin-bottom:10px}'\n  + '.obshead{font-weight:600;margin-bottom:5px}'\n  + '.obsdetail{font-size:13.5px;color:#424a53}'\n  + '.badge{display:inline-block;font-size:11px;padding:2px 9px;border-radius:20px;vertical-align:middle;margin-left:6px}'\n  + '.badge.hi{background:#ffebe9;color:#cf222e}.badge.md{background:#ddf4ff;color:#0a3069}.badge.ok{background:#dafbe1;color:#1a7f37}'\n  + '.checks{background:#fff;border:1px solid #d8dee4;border-radius:10px;padding:14px 14px 14px 34px;font-size:14px}'\n  + '.checks li{margin-bottom:7px}'\n  + '.warn{background:#fff8c5;border:1px solid #eac54f;color:#9a6700;padding:12px 14px;border-radius:8px;font-size:13.5px}'\n  + '.controls{display:flex;gap:10px;align-items:center;margin-bottom:10px;flex-wrap:wrap}'\n  + '#catfilter{padding:7px 10px;border:1px solid #d8dee4;border-radius:8px;font-size:13px;background:#fff}'\n  + '.dl{background:#1f2328;color:#fff;text-decoration:none;padding:8px 14px;border-radius:8px;font-size:13px}'\n  + '.dl:hover{background:#444c56}'\n  + '.foot{margin-top:28px;font-size:12px;color:#57606a;border-top:1px solid #d8dee4;padding-top:14px}';\n\nconst script = 'var f=document.getElementById(\"catfilter\");'\n  + 'if(f){f.addEventListener(\"change\",function(){var v=f.value;'\n  + 'document.querySelectorAll(\"#txntable tbody tr\").forEach(function(r){'\n  + 'r.style.display=(!v||r.getAttribute(\"data-cat\")===v)?\"\":\"none\";});});}';\n\nconst html = '<!DOCTYPE html><html><head><meta charset=\"utf-8\">'\n  + '<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">'\n  + '<title>Expense Breakdown</title><style>' + styles + '</style></head><body><div class=\"wrap\">'\n  + '<h1>Expense breakdown</h1>'\n  + parseNote\n  + tiles\n  + '<h2>Where the money went</h2>' + catRows\n  + monthTable\n  + recSection\n  + bigSection\n  + aiSection\n  + txnSection\n  + '<div class=\"foot\">All totals, percentages and repeating charge detection were calculated arithmetically from your statement. '\n  + 'Categories were assigned by an AI model from payee names and can be wrong, so check anything that looks off before relying on it. '\n  + 'This is an informational summary of your own transactions, not financial advice.</div>'\n  + '</div><script>' + script + '<\\/script></body></html>';\n\nreturn [{ json: { html } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "ae6fb945-8884-4b5a-a946-2c4301e5afcf",
      "name": "Show Report",
      "type": "n8n-nodes-base.form",
      "position": [
        3696,
        752
      ],
      "parameters": {
        "operation": "completion",
        "respondWith": "showText",
        "responseText": "={{ $json.html }}"
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "versionId": "4893b2b3-3d68-41a6-886b-a61cae66f23a",
  "nodeGroups": [],
  "connections": {
    "Is it a PDF?": {
      "main": [
        [
          {
            "node": "Extract PDF Text",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Extract CSV Text",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Insights Agent": {
      "main": [
        [
          {
            "node": "Build Report Page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calculate Totals": {
      "main": [
        [
          {
            "node": "Build Insights Prompt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract CSV Text": {
      "main": [
        [
          {
            "node": "Combine File Branches",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Extract PDF Text": {
      "main": [
        [
          {
            "node": "Combine File Branches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload Statement": {
      "main": [
        [
          {
            "node": "Is it a PDF?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Report Page": {
      "main": [
        [
          {
            "node": "Show Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Categorize Payees": {
      "main": [
        [
          {
            "node": "Calculate Totals",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "Categorize Payees",
            "type": "ai_languageModel",
            "index": 0
          },
          {
            "node": "Insights Agent",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Parse Transactions": {
      "main": [
        [
          {
            "node": "Build Category Prompt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Category Prompt": {
      "main": [
        [
          {
            "node": "Categorize Payees",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Insights Prompt": {
      "main": [
        [
          {
            "node": "Insights Agent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Combine File Branches": {
      "main": [
        [
          {
            "node": "Parse Transactions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
Pro

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

About this workflow

This workflow accepts a bank statement upload (CSV or text-based PDF), parses transactions, uses OpenAI to categorize merchants and generate narrative insights, and returns an interactive HTML expense report with category totals, monthly breakdowns, recurring-charge detection,…

Source: https://n8n.io/workflows/17753/ — 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

This workflow serves as a comprehensive "Workflow Nodes SEO & Documentation Generator". It uses AI to analyze, rename, and document n8n workflows, offering a streamlined way to optimize workflow reada

Form Trigger, n8n, Output Parser Autofixing +11
AI & RAG

22-Automate_Multi_Platform_Social_Media_Content_Creation. Uses outputParserStructured, lmChatGoogleGemini, lmChatOpenAi, httpRequest. Event-driven trigger; 57 nodes.

Output Parser Structured, Google Gemini Chat, OpenAI Chat +11
AI & RAG

📄 Documentation: Notion Guide

Telegram Trigger, HTTP Request, Agent +8
AI & RAG

Get a 360 Social media presence report for a person

Form Trigger, Mcp Client Tool, Agent +5
AI & RAG

This workflow generates comprehensive B2B leads, from a selected Business type in ANY CITY IN THE WORLD, including: Company name; Website; Email (enriched with AI Agent); Phone number; Address; Main L

Output Parser Structured, Memory Buffer Window, Agent +8