{
  "name": "Check an invoice CSV for duplicate rows — no AI, no API key",
  "nodes": [
    {
      "id": "6299154a-96f8-53de-af21-6fb2e170e691",
      "name": "Form: upload an invoice CSV",
      "type": "n8n-nodes-base.formTrigger",
      "typeVersion": 2.5,
      "position": [
        0,
        0
      ],
      "parameters": {
        "path": "check-invoice-csv-duplicates",
        "formTitle": "Check an invoice CSV for duplicate rows",
        "formDescription": "Upload a CSV invoice export. You get back the rows that repeat.",
        "formFields": {
          "values": [
            {
              "fieldLabel": "Invoice CSV",
              "fieldType": "file",
              "requiredField": true
            }
          ]
        },
        "responseMode": "responseNode",
        "options": {}
      }
    },
    {
      "id": "5a51cfc6-6a6f-50b9-b111-bcaec04a803f",
      "name": "Settings",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        240,
        0
      ],
      "parameters": {
        "mode": "manual",
        "includeOtherFields": true,
        "assignments": {
          "assignments": [
            {
              "id": "3b1b2ab0-d37f-5f5d-99f3-641610077556",
              "name": "dateOrder",
              "value": "dmy",
              "type": "string"
            },
            {
              "id": "9e36f107-3bdd-56c6-8901-3458f017a38e",
              "name": "defaultCurrency",
              "value": "EUR",
              "type": "string"
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "8e420768-6db0-5204-8138-bb461f65502c",
      "name": "Pick the file",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        480,
        0
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// The form puts the uploaded file in a binary field named after the form field.\n// Take the first file whatever it is called, and hand it on as \"data\".\nconst item = $input.first() || {};\nconst keys = Object.keys(item.binary || {});\nif (!keys.length) {\n  throw new Error('No file arrived. Submit the form again and attach a CSV.');\n}\nconst file = item.binary[keys[0]];\nreturn [{\n  json: { file_name: file.fileName || 'invoices.csv', mime_type: file.mimeType || '' },\n  binary: { data: file },\n}];"
      }
    },
    {
      "id": "61c98e93-1d2f-54cb-a449-84692089157e",
      "name": "CSV to text",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1.2,
      "position": [
        720,
        0
      ],
      "parameters": {
        "operation": "text",
        "binaryPropertyName": "data",
        "options": {},
        "destinationKey": "data"
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "265adf3b-b364-50ba-81ab-46dc9c8ea83f",
      "name": "Check for duplicates",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        960,
        0
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/* LEDGER_CORE v1.0.0\n * Pure, dependency-free invoice parsing + ledger logic.\n * Inlined into each template's Code node by tools/build_workflows.py as the\n * subset of units that node's own code calls (tools/core_subset.py); the whole\n * file is unit-tested directly from Node (see tests/js/core.test.js).\n *\n * No network, no filesystem, no credentials, no environment access.\n * Every function is deterministic: same input -> same output, always.\n */\nfunction ledgerCore() {\n  'use strict';\n\n  function normSpace(s) {\n    return String(s == null ? '' : s).replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();\n  }\n\n  function stripDiacritics(s) {\n    return String(s == null ? '' : s).normalize('NFD').replace(/[\\u0300-\\u036f]/g, '');\n  }\n\n  // ------------------------------------------------------------------- numbers\n  // Decide what \".\" and \",\" mean in a numeric token.\n  // Rules (documented, tested):\n  //  * both separators present  -> the LAST one is the decimal separator\n  //  * one separator, 2 digits after -> decimal  (\"1,50\" / \"1.50\")\n  //  * one separator, 3 digits after and 1-3 digits before -> thousands (\"1.234\" / \"1,234\")\n  //  * one separator, 1 digit after -> decimal  (\"1,5\")\n  //  * repeated separator -> thousands (\"1.234.567\")\n  function parseAmount(raw) {\n    if (raw == null) return null;\n    let s = String(raw).trim();\n    if (!s) return null;\n\n    let negative = false;\n    if (/^\\(.*\\)$/.test(s)) { negative = true; s = s.slice(1, -1); }\n    if (/-\\s*$/.test(s)) { negative = true; s = s.replace(/-\\s*$/, ''); }\n    if (/^\\s*-/.test(s)) { negative = true; s = s.replace(/^\\s*-/, ''); }\n\n    const stripped = s.replace(/[^0-9.,]/g, '');\n    if (!/[0-9]/.test(stripped)) return null;\n\n    const lastDot = stripped.lastIndexOf('.');\n    const lastComma = stripped.lastIndexOf(',');\n    let normalized;\n\n    if (lastDot !== -1 && lastComma !== -1) {\n      const decimalIsComma = lastComma > lastDot;\n      normalized = decimalIsComma\n        ? stripped.replace(/\\./g, '').replace(',', '.')\n        : stripped.replace(/,/g, '');\n    } else if (lastDot === -1 && lastComma === -1) {\n      normalized = stripped;\n    } else {\n      const sep = lastDot !== -1 ? '.' : ',';\n      const parts = stripped.split(sep);\n      const occurrences = parts.length - 1;\n      const tail = parts[parts.length - 1];\n      const head = parts[0];\n      const isThousands = occurrences > 1\n        ? parts.slice(1).every((p) => p.length === 3)\n        : (tail.length === 3 && head.length >= 1 && head.length <= 3 && /^[0-9]{3}$/.test(tail) && head !== '0');\n      normalized = isThousands ? parts.join('') : parts.slice(0, -1).join('') + '.' + tail;\n    }\n\n    const value = Number(normalized);\n    if (!Number.isFinite(value)) return null;\n    return negative ? -value : value;\n  }\n\n  function round2(n) {\n    return Math.round((Number(n) + Number.EPSILON) * 100) / 100;\n  }\n\n  /** Numbers arrive as strings from CSV; treat empty as absent, never concatenate. */\n\n  function num(v) {\n    if (v == null || v === '') return null;\n    const n = Number(v);\n    return Number.isFinite(n) ? n : null;\n  }\n\n  // ------------------------------------------------------------------ currency\n  const CURRENCY_SYMBOLS = [\n    ['€', 'EUR'], ['£', 'GBP'], ['$', 'USD'], ['₺', 'TRY'], ['¥', 'JPY'],\n    ['₽', 'RUB'], ['₹', 'INR'], ['zł', 'PLN'], ['Kč', 'CZK'], ['kr', 'SEK'],\n  ];\n\n  const CURRENCY_CODES = [\n    'EUR', 'USD', 'GBP', 'TRY', 'CHF', 'SEK', 'NOK', 'DKK', 'PLN', 'CZK', 'HUF',\n    'RON', 'BGN', 'JPY', 'CAD', 'AUD', 'NZD', 'INR', 'BRL', 'MXN', 'ZAR', 'AED', 'SAR',\n  ];\n\n  function detectCurrency(text) {\n    const s = String(text == null ? '' : text);\n    const upper = s.toUpperCase();\n    // An explicit ISO code next to an amount or a colon wins.\n    for (const code of CURRENCY_CODES) {\n      const re = new RegExp('(^|[^A-Z])' + code + '([^A-Z]|$)');\n      if (re.test(upper)) return { code, source: 'iso-code', confidence: 0.9 };\n    }\n    for (const [symbol, code] of CURRENCY_SYMBOLS) {\n      if (s.indexOf(symbol) !== -1) {\n        // \"$\" is ambiguous (USD/CAD/AUD); note it.\n        if (symbol === '$') return { code: 'USD', source: 'symbol', confidence: 0.6, ambiguous: true };\n        return { code, source: 'symbol', confidence: 0.8 };\n      }\n    }\n    return { code: null, source: 'none', confidence: 0 };\n  }\n\n  // --------------------------------------------------------------------- dates\n  const MONTHS = {\n    jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, sept: 9,\n    oct: 10, nov: 11, dec: 12,\n    january: 1, february: 2, march: 3, april: 4, june: 6, july: 7, august: 8,\n    september: 9, october: 10, november: 11, december: 12,\n    januar: 1, februar: 2, maerz: 3, marz: 3, mai: 5, juni: 6, juli: 7,\n    oktober: 10, dezember: 12,\n    janvier: 1, fevrier: 2, mars: 3, avril: 4, mai_fr: 5, juin: 6, juillet: 7, aout: 8,\n    septembre: 9, octobre: 10, decembre: 12,\n  };\n\n  function pad2(n) { return String(n).padStart(2, '0'); }\n\n  function isoFromParts(y, m, d) {\n    if (!(y >= 1900 && y <= 2200) || !(m >= 1 && m <= 12) || !(d >= 1 && d <= 31)) return null;\n    const dt = new Date(Date.UTC(y, m - 1, d));\n    if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== m - 1 || dt.getUTCDate() !== d) return null;\n    return `${y}-${pad2(m)}-${pad2(d)}`;\n  }\n\n  // order: 'auto' | 'dmy' | 'mdy'\n  function parseDate(raw, order) {\n    if (raw == null) return null;\n    const s = normSpace(raw).replace(/^[^\\dA-Za-z]+|[^\\dA-Za-z]+$/g, '');\n    if (!s) return null;\n    const mode = order === 'mdy' ? 'mdy' : order === 'dmy' ? 'dmy' : 'auto';\n\n    let m = s.match(/^(\\d{4})[-/.](\\d{1,2})[-/.](\\d{1,2})$/);          // 2026-10-21\n    if (m) return isoFromParts(+m[1], +m[2], +m[3]);\n\n    m = s.match(/^(\\d{1,2})[-/.](\\d{1,2})[-/.](\\d{2,4})$/);            // 21.10.2026\n    if (m) {\n      let [, a, b, y] = m;\n      if (y.length === 2) y = String(2000 + Number(y));\n      const na = +a, nb = +b;\n      let d, mo;\n      if (mode === 'mdy') { mo = na; d = nb; }\n      else if (mode === 'dmy') { d = na; mo = nb; }\n      else if (na > 12 && nb <= 12) { d = na; mo = nb; }\n      else if (nb > 12 && na <= 12) { mo = na; d = nb; }\n      else { d = na; mo = nb; }  // ambiguous -> day-first, the common case outside the US\n      return isoFromParts(+y, mo, d);\n    }\n\n    m = s.match(/^(\\d{1,2})\\.?\\s+([A-Za-z]{3,9})\\.?\\s+(\\d{4})$/);          // 5 October 2026 / 5. Oktober 2026\n    if (m) {\n      const mo = MONTHS[m[2].toLowerCase()];\n      if (mo) return isoFromParts(+m[3], mo, +m[1]);\n    }\n\n    m = s.match(/^([A-Za-z]{3,9})\\.?\\s+(\\d{1,2}),?\\s+(\\d{4})$/);        // October 5, 2026\n    if (m) {\n      const mo = MONTHS[m[1].toLowerCase()];\n      if (mo) return isoFromParts(+m[3], mo, +m[2]);\n    }\n\n    m = s.match(/^(\\d{1,2})\\s+([A-Za-z]{3,9})\\.?\\s+(\\d{2})$/);          // 5 Oct 26\n    if (m) {\n      const mo = MONTHS[m[2].toLowerCase()];\n      if (mo) return isoFromParts(2000 + Number(m[3]), mo, +m[1]);\n    }\n    return null;\n  }\n\n  // ------------------------------------------------------------------- supplier\n  const LEGAL_SUFFIXES = [\n    'ltd', 'limited', 'llc', 'inc', 'incorporated', 'corp', 'corporation', 'co',\n    'gmbh', 'gmbh & co kg', 'ag', 'ug', 'ohg', 'kg', 'se',\n    'bv', 'b.v.', 'nv', 'n.v.', 'sa', 's.a.', 'sas', 'sarl', 'srl', 's.r.l.', 'spa', 's.p.a.',\n    'ab', 'as', 'a.s.', 'oy', 'aps', 'plc', 'pty', 'pte', 'sl', 's.l.', 'lda', 'kft', 'doo', 'd.o.o.',\n  ];\n\n  function normaliseSupplierKey(name) {\n    let s = stripDiacritics(name || '').toLowerCase();\n    s = s.replace(/&/g, ' and ');\n    s = s.replace(/[^a-z0-9+ ]+/g, ' ');\n    s = normSpace(s);\n    // Match suffixes in the same canonical form as the string (\"S.r.l.\" -> \"s r l\").\n    const canonicalSuffixes = LEGAL_SUFFIXES\n      .map((x) => normSpace(x.replace(/[^a-z0-9 ]+/g, ' ')))\n      .sort((a, b) => b.length - a.length);\n    let changed = true;\n    while (changed) {\n      changed = false;\n      for (const suffix of canonicalSuffixes) {\n        if (!suffix) continue;\n        const re = new RegExp('(^| )' + escapeForSuffix(suffix) + '$');\n        if (re.test(s) && s.length > suffix.length) {\n          s = normSpace(s.replace(re, ''));\n          changed = true;\n        }\n      }\n    }\n    s = normSpace(s.replace(/(^| )(and|und|et|e)$/, ''));\n    return s.replace(/ +/g, '-');\n  }\n\n  function escapeForSuffix(s) {\n    return s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n  }\n\n  // RFC4180 reader: handles quoted fields, embedded commas/quotes/newlines, CRLF.\n  function parseCsv(text) {\n    const src = String(text == null ? '' : text).replace(/^\\uFEFF/, '');\n    const rows = [];\n    let row = [];\n    let field = '';\n    let inQuotes = false;\n    for (let i = 0; i < src.length; i++) {\n      const ch = src[i];\n      if (inQuotes) {\n        if (ch === '\"') {\n          if (src[i + 1] === '\"') { field += '\"'; i++; } else { inQuotes = false; }\n        } else { field += ch; }\n        continue;\n      }\n      if (ch === '\"') { inQuotes = true; continue; }\n      if (ch === ',') { row.push(field); field = ''; continue; }\n      if (ch === '\\r') continue;\n      if (ch === '\\n') { row.push(field); rows.push(row); row = []; field = ''; continue; }\n      field += ch;\n    }\n    if (field !== '' || row.length) { row.push(field); rows.push(row); }\n    if (!rows.length) return [];\n    const headers = rows[0].map((h) => String(h).trim());\n    const out = [];\n    for (let r = 1; r < rows.length; r++) {\n      const cells = rows[r];\n      if (cells.length === 1 && cells[0] === '') continue;\n      const obj = {};\n      headers.forEach((h, i) => { obj[h] = cells[i] == null ? '' : cells[i]; });\n      out.push(obj);\n    }\n    return out;\n  }\n\n  // ------------------------------------------------------- csv column mapping (free-03)\n  // A CSV a user already has carries THEIR header names. These are the spellings this\n  // module recognises, per field. Nothing here is a locale guess: a header that is not\n  // listed stays unmapped, and the caller reports what it could not find rather than\n  // picking a column that might be wrong.\n  const CSV_FIELDS = ['supplier', 'invoice_number', 'invoice_date', 'gross', 'currency'];\n\n  const CSV_REQUIRED = ['supplier', 'invoice_number', 'gross'];\n\n  const CSV_ALIASES = {\n    supplier: ['supplier', 'vendor', 'seller', 'merchant', 'payee', 'company', 'counterparty',\n      'supplier name', 'vendor name', 'company name', 'name', 'from'],\n    invoice_number: ['invoice number', 'invoice no', 'invoice nr', 'invoice', 'invoice #',\n      'number', 'no', 'num', 'reference', 'ref', 'invoice reference', 'invoice ref',\n      'document number', 'doc number', 'bill number', 'bill no', 'inv no', 'inv number'],\n    invoice_date: ['invoice date', 'date', 'bill date', 'document date', 'issue date',\n      'issued date', 'invoice dt'],\n    gross: ['total', 'gross', 'amount', 'total amount', 'invoice total', 'gross total',\n      'grand total', 'sum', 'total gross', 'amount total'],\n    currency: ['currency', 'ccy', 'cur', 'currency code'],\n  };\n\n  // A header containing one of these is NOT the field: \"Net Total\" is the net amount and\n  // must never be used as the gross, or every total comparison silently shifts.\n  const CSV_EXCLUDE = { gross: ['net', 'tax', 'vat', 'sub', 'without'] };\n\n  function headerTokens(header) {\n    return stripDiacritics(String(header == null ? '' : header)).toLowerCase()\n      .replace(/[^a-z0-9]+/g, ' ').trim().split(' ').filter(Boolean);\n  }\n\n  function tokensMatch(header, alias, exclude) {\n    const h = headerTokens(header);\n    const a = alias.split(' ');\n    if (exclude && exclude.some((bad) => h.indexOf(bad) !== -1)) return false;\n    for (let i = 0; i + a.length <= h.length; i++) {\n      let ok = true;\n      for (let j = 0; j < a.length; j++) if (h[i + j] !== a[j]) { ok = false; break; }\n      if (ok) return true;\n    }\n    return false;\n  }\n\n  /**\n   * Decide which column holds which field. Exact alias matches first, then a contiguous\n   * token match (\"Sum (EUR)\" -> sum). Each header is used at most once.\n   * Returns {map, missingRequired, missingOptional, unmappedHeaders}\n   */\n\n  function guessCsvColumns(headers) {\n    const cols = (headers || []).map((h) => String(h == null ? '' : h));\n    const map = {};\n    const used = {};\n    for (const exact of [true, false]) {\n      for (const field of CSV_FIELDS) {\n        if (map[field]) continue;\n        const exclude = CSV_EXCLUDE[field];\n        let best = null;\n        for (const header of cols) {\n          if (used[header]) continue;\n          let longest = 0;\n          for (const alias of (CSV_ALIASES[field] || [])) {\n            const hit = exact\n              ? headerTokens(header).join(' ') === alias\n              : tokensMatch(header, alias, exclude);\n            if (hit && alias.length > longest) longest = alias.length;\n          }\n          // More specific alias wins; an earlier header only wins a tie. \"Vendor\" beats\n          // \"Name\" for the supplier even when \"Name\" comes first in the file.\n          if (longest > 0 && (best === null || longest > best.length)) best = { header, length: longest };\n        }\n        if (best) { map[field] = best.header; used[best.header] = true; }\n      }\n    }\n    return {\n      map,\n      missingRequired: CSV_REQUIRED.filter((f) => !map[f]),\n      missingOptional: CSV_FIELDS.filter((f) => !CSV_REQUIRED.includes(f) && !map[f]),\n      unmappedHeaders: cols.filter((h) => !used[h]),\n    };\n  }\n\n  /**\n   * Turn parsed CSV rows into the five fields the audit compares, keeping the row number a\n   * spreadsheet would show (header = row 1) so the user can find the row in their own file.\n   * Every value it could not read becomes a `problems` entry instead of a silent blank.\n   */\n\n  function normaliseCsvRows(rows, columnMap, opts) {\n    const o = opts || {};\n    const map = columnMap || {};\n    const out = [];\n    (rows || []).forEach((raw, i) => {\n      const cell = (field) => (map[field] ? (raw || {})[map[field]] : undefined);\n      const supplier = normSpace(cell('supplier'));\n      const supplierKey = supplier ? normaliseSupplierKey(supplier) : '';\n      const invoiceNumber = normSpace(cell('invoice_number')).toUpperCase();\n      const dateRaw = normSpace(cell('invoice_date'));\n      const grossRaw = normSpace(cell('gross'));\n      const currencyRaw = normSpace(cell('currency'));\n\n      const invoiceDate = dateRaw ? parseDate(dateRaw, o.dateOrder || 'auto') : null;\n      const gross = grossRaw ? parseAmount(grossRaw) : null;\n      const detected = currencyRaw ? detectCurrency(currencyRaw).code : null;\n      const currency = detected || o.defaultCurrency || null;\n\n      const problems = [];\n      if (!supplierKey) problems.push('no supplier name');\n      if (!invoiceNumber) problems.push('no invoice number');\n      if (grossRaw && gross == null) problems.push('total not readable');\n      if (dateRaw && invoiceDate == null) problems.push('date not readable');\n\n      // A row is checkable when it has a supplier and an invoice number. A row without a\n      // number is reported, never grouped: two identical rows with no number could be two\n      // identical purchases, and calling them duplicates would be a guess.\n      const checkable = Boolean(supplierKey && invoiceNumber);\n\n      out.push({\n        row: i + 2, supplier, supplier_key: supplierKey, invoice_number: invoiceNumber,\n        invoice_date: invoiceDate, gross, currency, problems, checkable,\n      });\n    });\n    return out;\n  }\n\n  /**\n   * Find rows that are the same bill twice. Two exact checks only:\n   *   same_invoice_number        — one supplier, one invoice number, two lines\n   *   same_supplier_date_and_total — one supplier, same date and total, two invoice numbers\n   * No window and no amount tolerance: two bills 9 days apart with totals 1% apart are not\n   * called duplicates here (that is the ledger's near-duplicate rule, not this audit's job).\n   */\n\n  function findExactDuplicates(rows) {\n    const list = (rows || []).filter(Boolean);\n    const groups = [];\n    const flagged = {};\n    const uncheckable = [];\n\n    for (const r of list) {\n      if (!r.checkable) { uncheckable.push({ row: r.row, problems: r.problems || [] }); continue; }\n      if (!r.supplier_key || !r.invoice_number) continue;\n      const key = `${r.supplier_key}|${String(r.invoice_number).toUpperCase().replace(/[^A-Z0-9]/g, '')}`;\n      r.__key = key;\n    }\n\n    const byNumber = {};\n    for (const r of list) {\n      if (!r.__key) continue;\n      (byNumber[r.__key] = byNumber[r.__key] || []).push(r);\n    }\n    for (const key of Object.keys(byNumber)) {\n      const bucket = byNumber[key];\n      if (bucket.length < 2) continue;\n      bucket.forEach((r) => { flagged[r.row] = true; });\n      groups.push({\n        kind: 'same_invoice_number',\n        key,\n        rows: bucket.map((r) => r.row),\n        supplier: bucket[0].supplier,\n        invoice_numbers: bucket.map((r) => r.invoice_number),\n      });\n    }\n\n    const byBill = {};\n    for (const r of list) {\n      if (!r.checkable || flagged[r.row]) continue;\n      if (!r.invoice_date || r.gross == null) continue;\n      const key = `${r.supplier_key}|${r.invoice_date}|${round2(r.gross)}|${r.currency || ''}`;\n      (byBill[key] = byBill[key] || []).push(r);\n    }\n    for (const key of Object.keys(byBill)) {\n      const bucket = byBill[key];\n      if (bucket.length < 2) continue;\n      const numbers = bucket.map((r) => r.invoice_number).filter(Boolean);\n      bucket.forEach((r) => { flagged[r.row] = true; });\n      groups.push({\n        kind: 'same_supplier_date_and_total',\n        key,\n        rows: bucket.map((r) => r.row),\n        supplier: bucket[0].supplier,\n        invoice_numbers: numbers,\n      });\n    }\n\n    // __key is internal: strip it so a caller cannot mistake it for output data.\n    for (const r of list) delete r.__key;\n\n    return {\n      groups,\n      flaggedRows: Object.keys(flagged).map(Number).sort((a, b) => a - b),\n      checkedRows: list.filter((r) => r.checkable).length,\n      uncheckable,\n    };\n  }\n\n  return {\n    // text\n    normSpace, stripDiacritics,\n    // numbers + dates + currency\n    parseAmount, round2, detectCurrency, parseDate, isoFromParts, pad2,\n    // suppliers\n    normaliseSupplierKey,\n    // output\n    parseCsv,\n    // csv audit (free-03)\n    guessCsvColumns, normaliseCsvRows, findExactDuplicates,\n    // version\n    VERSION: '1.0.0',\n  };\n}\n// Read the CSV, say which column is which, then look for the same bill twice.\n// Every branch returns data — none of them throws at the user in the browser.\nconst C = ledgerCore();\nconst settings = $('Settings').first().json || {};\nconst text = $input.first().json.data || $input.first().json.text || '';\nconst file = $('Pick the file').first().json;\nconst base = {\n  file_name: (file && file.file_name) || 'invoices.csv',\n  rows_read: 0,\n  headers: [],\n  columns: { map: {}, missingRequired: [], missingOptional: [], unmappedHeaders: [] },\n  checked_rows: 0,\n  duplicate_groups: 0,\n  duplicate_rows: 0,\n  groups: [],\n  flagged: {},\n  skipped: [],\n  warnings: [],\n};\n\nif (!String(text).trim()) {\n  return [{ json: Object.assign({}, base, { status: 'no_text' }) }];\n}\n\nconst rows = C.parseCsv(text);\nif (!rows.length) {\n  return [{ json: Object.assign({}, base, { status: 'no_rows' }) }];\n}\n\nconst headers = Object.keys(rows[0]);\nconst columns = C.guessCsvColumns(headers);\nif (columns.missingRequired.length) {\n  return [{ json: Object.assign({}, base, {\n    status: 'columns_missing', rows_read: rows.length, headers, columns,\n  }) }];\n}\n\nconst normalised = C.normaliseCsvRows(rows, columns.map, {\n  dateOrder: settings.dateOrder || 'auto',\n  defaultCurrency: settings.defaultCurrency || null,\n});\nconst audit = C.findExactDuplicates(normalised);\n\nconst byRow = {};\nfor (const r of normalised) byRow[r.row] = r;\nconst flagged = {};\nfor (const n of audit.flaggedRows) {\n  const r = byRow[n] || {};\n  flagged[n] = {\n    invoice_number: r.invoice_number || '',\n    invoice_date: r.invoice_date || null,\n    gross: r.gross == null ? null : r.gross,\n    currency: r.currency || '',\n  };\n}\n\nreturn [{ json: Object.assign({}, base, {\n  status: 'ok',\n  rows_read: rows.length,\n  headers,\n  columns,\n  checked_rows: audit.checkedRows,\n  duplicate_groups: audit.groups.length,\n  duplicate_rows: audit.flaggedRows.length,\n  groups: audit.groups,\n  flagged,\n  skipped: audit.uncheckable,\n  warnings: normalised.filter((r) => r.checkable && r.problems.length)\n    .map((r) => ({ row: r.row, invoice_number: r.invoice_number, problems: r.problems })),\n}) }];"
      }
    },
    {
      "id": "7734b06d-b978-57c0-bff4-1ce74266d6fc",
      "name": "Result",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1200,
        0
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// One readable answer: what was checked, what repeats, what could not be checked.\nconst r = $input.first().json;\nconst name = r.file_name || 'the CSV';\nconst lines = [];\n\nif (r.status === 'no_text') {\n  lines.push('No text could be read from ' + name + '.');\n  lines.push('');\n  lines.push('Save the export as CSV (comma separated, UTF-8) and upload it again.');\n} else if (r.status === 'no_rows') {\n  lines.push('The CSV has a header row and no data rows under it.');\n  lines.push('Export at least one invoice and upload it again.');\n} else if (r.status === 'columns_missing') {\n  // Human words, not the internal field names. This refusal is read by the person who\n  // uploaded the file, and \"no invoice_number column\" tells them nothing.\n  const NAMES = { supplier: 'supplier', invoice_number: 'invoice number', gross: 'total' };\n  const missing = r.columns.missingRequired.map((f) => NAMES[f] || f);\n  lines.push('This CSV was not checked: no ' + missing.join(' and no ') + ' column.');\n  lines.push('');\n  lines.push('Columns found: ' + (r.headers.join(', ') || '(none)'));\n  if (r.columns.unmappedHeaders.length) {\n    lines.push('Columns it did not need: ' + r.columns.unmappedHeaders.join(', '));\n  }\n  lines.push('');\n  lines.push('Name one column for the supplier, one for the invoice number and one for the');\n  lines.push('total — for example \"Supplier\", \"Invoice No\", \"Total\" — and upload it again.');\n  lines.push('A PDF, an XLSX file or a semicolon-separated export is not read: save it as');\n  lines.push('comma-separated CSV first.');\n} else {\n  const c = r.columns.map;\n  const used = ['supplier = \"' + c.supplier + '\"', 'invoice number = \"' + c.invoice_number + '\"'];\n  if (c.invoice_date) used.push('date = \"' + c.invoice_date + '\"');\n  if (c.gross) used.push('total = \"' + c.gross + '\"');\n  if (c.currency) used.push('currency = \"' + c.currency + '\"');\n  lines.push('Checked ' + name + ': ' + r.rows_read + ' data row(s).');\n  lines.push('Columns used: ' + used.join(', '));\n  lines.push('Rows checked: ' + r.checked_rows + '. Rows skipped: ' + r.skipped.length + '.');\n  lines.push('');\n  if (!r.duplicate_groups) {\n    lines.push('No duplicate rows found.');\n  } else {\n    lines.push(r.duplicate_groups + ' duplicate group(s), covering ' + r.duplicate_rows + ' row(s):');\n    for (const g of r.groups) {\n      const what = g.kind === 'same_invoice_number'\n        ? 'the same invoice number ' + (g.invoice_numbers[0] || '')\n        : 'the same supplier, date and total under ' + g.invoice_numbers.join(' / ');\n      const amounts = [];\n      for (const n of g.rows) {\n        const d = r.flagged[n] || {};\n        if (d.gross != null) amounts.push(d.gross + (d.currency ? ' ' + d.currency : ''));\n      }\n      lines.push('- rows ' + g.rows.join(', ') + ': ' + g.supplier + ' — ' + what +\n        (amounts.length ? ' (' + amounts.join(' / ') + ')' : ''));\n    }\n  }\n  if (r.warnings.length) {\n    lines.push('');\n    lines.push('Read, but not compared on every field:');\n    for (const w of r.warnings) {\n      lines.push('- row ' + w.row + ' (' + (w.invoice_number || 'no number') + '): ' + w.problems.join('; '));\n    }\n  }\n  if (r.skipped.length) {\n    lines.push('');\n    lines.push('Skipped, so not checked at all:');\n    for (const s of r.skipped) lines.push('- row ' + s.row + ': ' + s.problems.join('; '));\n  }\n  lines.push('');\n  lines.push('A row needs a supplier and an invoice number to be checked. Two rows with the same');\n  lines.push('number, or the same supplier, date and total under different numbers, are reported.');\n  lines.push('Totals 1% apart and bills days apart are NOT compared: that near-duplicate rule, and');\n  lines.push('anything written to a ledger, is what the paid pack does.');\n}\n\nlines.push('');\nlines.push('Nothing was written to a ledger, and no invoice left this machine. n8n still');\nlines.push('keeps this run in its own execution log until it prunes old executions.');\n\nreturn [{ json: {\n  status: r.status,\n  rows_read: r.rows_read,\n  checked_rows: r.checked_rows,\n  duplicate_groups: r.duplicate_groups,\n  duplicate_rows: r.duplicate_rows,\n  skipped_rows: r.skipped.length,\n  warning_rows: r.warnings.length,\n  summary: lines.join('\\n'),\n} }];"
      }
    },
    {
      "id": "eac2dbec-35c4-5fdd-9f83-32f01d87896c",
      "name": "Answer the form",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.5,
      "position": [
        1440,
        0
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify($json) }}",
        "options": {}
      }
    },
    {
      "id": "6e237352-61e3-5b26-9546-d8e02ccdf7ae",
      "name": "Note: Upload an invoice CSV you already have a",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -700
      ],
      "parameters": {
        "content": "Upload an invoice CSV you already have and get a read-only report of the rows that\nrepeat: the same invoice number twice, or the same supplier, date and total booked\nunder two numbers. It needs no account, no credential and no AI model, and it writes\nnothing: the CSV stays on the machine that runs n8n.\n\n### How it works\n- *Form: upload an invoice CSV* takes one CSV export.\n- *Pick the file* passes the upload on as file data.\n- *CSV to text* reads the file as text.\n- *Check for duplicates* decides which column is the supplier, the invoice number,\n  the date and the total, then compares rows. A row with no supplier or no invoice\n  number is reported, never grouped.\n- *Result* prints the columns it used, every duplicate group with its row numbers,\n  and every row it could not check, plus the reason.\n\n### Setup\nNo credential and no install. Click *Test workflow*, open the form URL n8n shows,\nand upload a CSV export from your bookkeeping or spreadsheet.\n\n### Customization\n`dateOrder` (auto / dmy / mdy) and `defaultCurrency` are in *Settings*. If a column\nname is not recognised, rename it in the CSV or add the spelling to the alias lists\nin the *Check for duplicates* code.",
        "width": 680,
        "height": 560,
        "color": 1
      }
    },
    {
      "id": "f94b6036-753f-53a6-9218-0f48ede25041",
      "name": "Note: Upload a CSV, see which rows repeat, wri",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -80
      ],
      "parameters": {
        "content": "### Upload a CSV, see which rows repeat, write nothing",
        "width": 1700,
        "height": 60,
        "color": 7
      }
    }
  ],
  "connections": {
    "Form: upload an invoice CSV": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings": {
      "main": [
        [
          {
            "node": "Pick the file",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick the file": {
      "main": [
        [
          {
            "node": "CSV to text",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "CSV to text": {
      "main": [
        [
          {
            "node": "Check for duplicates",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check for duplicates": {
      "main": [
        [
          {
            "node": "Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Result": {
      "main": [
        [
          {
            "node": "Answer the form",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "saveDataSuccessExecution": "all"
  },
  "active": false
}
