{
  "name": "Preview what an invoice mailbox contains — no writing, no AI",
  "nodes": [
    {
      "id": "1d392ac4-f410-557d-a819-d88991cf8661",
      "name": "Inbox (IMAP)",
      "type": "n8n-nodes-base.emailReadImap",
      "typeVersion": 2.2,
      "position": [
        0,
        0
      ],
      "parameters": {
        "mailbox": "INBOX",
        "postProcessAction": "read",
        "downloadAttachments": true,
        "format": "simple",
        "options": {}
      }
    },
    {
      "id": "1d21fe42-7fe6-5878-95db-e4d9166c21d3",
      "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"
            },
            {
              "id": "5cf2c522-50e8-5527-aea7-cad63e16468d",
              "name": "chatId",
              "value": "",
              "type": "string"
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "a057a2ba-b0fa-59b4-b6ee-3024a19ab59a",
      "name": "Split PDF attachments",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        480,
        0
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const out = [];\nfor (const item of $input.all()) {\n  const files = item.binary || {};\n  const meta = item.json || {};\n  for (const key of Object.keys(files)) {\n    const file = files[key];\n    const name = file.fileName || key;\n    const mime = String(file.mimeType || '').toLowerCase();\n    if (mime !== 'application/pdf' && !/\\.pdf$/i.test(name)) continue;\n    out.push({\n      json: {\n        message_id: meta.messageId || meta.id || '',\n        subject: meta.subject || '',\n        from: meta.from || '',\n        attachment_name: name,\n      },\n      binary: { data: file },\n    });\n  }\n}\nreturn out;"
      }
    },
    {
      "id": "3b86e292-41cd-5dd6-9d20-c0e3b83ef4c1",
      "name": "PDF to text",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1.2,
      "position": [
        720,
        0
      ],
      "parameters": {
        "operation": "pdf",
        "binaryPropertyName": "data",
        "options": {}
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "12e4f626-01e3-5b84-9bfc-288e53047902",
      "name": "Parse invoice",
      "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  // ---------------------------------------------------------------- text utils\n  const NAMED_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '\"', apos: \"'\", nbsp: ' ' };\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  function decodeEntities(s) {\n    return String(s == null ? '' : s).replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (m, body) => {\n      if (body[0] === '#') {\n        const code = body[1] === 'x' || body[1] === 'X'\n          ? parseInt(body.slice(2), 16)\n          : parseInt(body.slice(1), 10);\n        return Number.isFinite(code) && code > 0 ? String.fromCodePoint(code) : m;\n      }\n      return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : m;\n    });\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  function cleanSupplierName(name) {\n    let s = normSpace(decodeEntities(name || ''));\n    s = s.replace(/^(from|supplier|vendor|seller|bill from|rechnungssteller|fournisseur)\\s*[:\\-]\\s*/i, '');\n    s = s.replace(/\\s*[|,;]\\s*$/, '');\n    return s;\n  }\n\n  function supplierFromEmail(email) {\n    if (!email || String(email).indexOf('@') === -1) return null;\n    const domain = String(email).split('@').pop().toLowerCase().replace(/^www\\./, '');\n    const generic = ['gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'yahoo.com',\n      'icloud.com', 'proton.me', 'protonmail.com', 'gmx.de', 'web.de', 'yandex.com', 'mail.ru'];\n    if (generic.indexOf(domain) !== -1) return null;\n    const label = domain.split('.').slice(0, -1).pop() || domain;\n    return label.replace(/[-_]+/g, ' ').replace(/\\b\\w/g, (c) => c.toUpperCase());\n  }\n\n  // ----------------------------------------------------------------- extraction\n  const LABELS = {\n    invoiceNumber: [\n      'invoice number', 'invoice no', 'invoice nr', 'invoice #', 'invoice id', 'invoice ref',\n      'invoice reference', 'document number', 'document no', 'factura no', 'facture n', 'facture no',\n      'rechnung nr', 'rechnungsnummer', 'fattura n', 'fattura no', 'factuur nummer', 'faktura nr',\n      'fakturanummer', 'invoice', 'inv no', 'inv#', 'bill number', 'bill no',\n    ],\n    invoiceDate: ['invoice date', 'date of invoice', 'issue date', 'issued on', 'date issued',\n      'rechnungsdatum', 'facture date', 'data fattura', 'fakturadatum', 'bill date', 'date'],\n    dueDate: ['due date', 'payment due', 'due on', 'pay by', 'payment terms', 'faellig am',\n      'fällig am', 'zahlbar bis', 'echeance', 'scadenza', 'forfallsdatum', 'due'],\n    supplier: ['supplier', 'vendor', 'seller', 'bill from', 'from', 'issued by', 'rechnungssteller',\n      'fournisseur', 'fornitore', 'leverancier', 'lieferant'],\n    vatId: ['vat id', 'vat no', 'vat number', 'vat reg', 'vat registration', 'tax id', 'tax number',\n      'ust-id', 'ust idnr', 'uid', 'tva', 'partita iva', 'btw nummer', 'org nr', 'company no',\n      'company number', 'kvk'],\n    net: ['subtotal', 'sub total', 'net amount', 'net total', 'total net', 'amount net', 'netto',\n      'nettobetrag', 'zwischensumme', 'sous total', 'imponibile', 'total excl vat', 'excl vat', 'ex. vat'],\n    vat: ['vat', 'vat amount', 'tax', 'tax amount', 'sales tax', 'mwst', 'ust', 'tva', 'iva', 'btw',\n      'total vat', 'vat total', 'gst', 'gst amount'],\n    gross: ['total', 'grand total', 'total due', 'amount due', 'balance due', 'total incl vat',\n      'total including vat', 'total amount', 'invoice total', 'gesamtbetrag', 'gesamt', 'summe',\n      'total ttc', 'montant total', 'totale', 'totaal', 'amount payable', 'total payable'],\n  };\n\n  function escapeRe(s) { return s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'); }\n\n  // Find \"Label ... value\" on one line. Returns {value, label, confidence} or null.\n  function labelLookup(lines, labels, extractor) {\n    for (const label of labels) {\n      const re = new RegExp('(^|[^a-z])' + escapeRe(label) + '\\\\s*[:#]?\\\\s*(.*)$', 'i');\n      for (const line of lines) {\n        const flat = normSpace(line);\n        const m = flat.match(re);\n        if (!m) continue;\n        const rest = m[2] == null ? '' : m[2].trim();\n        if (!rest) continue;\n        const value = extractor(rest);\n        if (value != null && value !== '') return { value, label, confidence: 0.9 };\n      }\n    }\n    return null;\n  }\n\n  const INVOICE_NO_SHAPE = /([A-Z0-9][A-Z0-9./_-]{1,29})/;\n\n  function looksLikeNumberToken(tok) {\n    const t = String(tok || '').replace(/[.,;:)]+$/, '');\n    if (!t) return false;\n    if (/^\\d{1,4}[-/.]\\d{1,2}[-/.]\\d{2,4}$/.test(t)) return false;   // a date\n    if (/^[0-9][0-9.,]*$/.test(t) && parseAmount(t) != null) return false; // a bare amount\n    return INVOICE_NO_SHAPE.test(t.replace(/^[#:]/, ''));\n  }\n\n  function extractInvoiceNumber(text) {\n    const lines = String(text || '').split(/\\r?\\n/);\n    const hit = labelLookup(lines, LABELS.invoiceNumber, (rest) => {\n      const tokens = normSpace(rest).split(/\\s+/);\n      for (const tok of tokens) {\n        const clean = tok.replace(/^[#:]/, '').replace(/[.,;:]+$/, '');\n        if (looksLikeNumberToken(clean) && clean.length >= 2) return clean;\n      }\n      return null;\n    });\n    if (hit) return { value: hit.value.toUpperCase(), confidence: hit.confidence, source: 'label:' + hit.label };\n\n    // Fallback: a token that looks like INV-1234 anywhere in the document.\n    const m = String(text || '').match(/\\b((?:INV|FA|RE|FAC|REC)[-_/]?[A-Z0-9]{2,12})\\b/i);\n    if (m) return { value: m[1].toUpperCase(), confidence: 0.5, source: 'pattern' };\n    return { value: null, confidence: 0, source: 'none' };\n  }\n\n  function extractDateByLabels(text, labels, order) {\n    const lines = String(text || '').split(/\\r?\\n/);\n    const hit = labelLookup(lines, labels, (rest) => {\n      const dateish = rest.match(/(\\d{4}[-/.]\\d{1,2}[-/.]\\d{1,2}|\\d{1,2}[-/.]\\d{1,2}[-/.]\\d{2,4}|\\d{1,2}\\s+[A-Za-z]{3,9}\\.?\\s+\\d{2,4}|[A-Za-z]{3,9}\\.?\\s+\\d{1,2},?\\s+\\d{4})/);\n      if (!dateish) return null;\n      return parseDate(dateish[1], order);\n    });\n    if (hit) return { value: hit.value, confidence: hit.confidence, source: 'label:' + hit.label };\n    return { value: null, confidence: 0, source: 'none' };\n  }\n\n  // forbid: a regex; lines matching it are ignored for these labels. Stops\n  // \"Total VAT 195,00\" being read as the gross total, or \"Net 30 days\" as a net amount.\n  function extractAmountByLabels(lines, labels, forbid) {\n    for (const label of labels) {\n      const re = new RegExp('(^|[^a-z])' + escapeRe(label) + '\\\\b[^\\\\n]*', 'i');\n      for (const line of lines) {\n        const flat = normSpace(line);\n        if (!re.test(flat)) continue;\n        if (forbid && forbid.test(flat)) continue;\n        // Percentages are rates, never amounts: drop them before reading numbers.\n        const withoutPct = flat.replace(/\\d{1,3}(?:[.,]\\d+)?\\s*%/g, ' ');\n        // Take the LAST numeric token on the line: totals tables put the amount on the right.\n        const tokens = withoutPct.match(/-?\\d[\\d.,\\s]*\\d|-?\\d/g) || [];\n        for (let i = tokens.length - 1; i >= 0; i--) {\n          const v = parseAmount(tokens[i]);\n          if (v != null) return { value: round2(v), confidence: 0.85, source: 'label:' + label };\n        }\n      }\n    }\n    return { value: null, confidence: 0, source: 'none' };\n  }\n\n  function extractSupplier(text, senderEmail) {\n    const lines = String(text || '').split(/\\r?\\n/).map(normSpace).filter(Boolean);\n    const fromLabel = labelLookup(lines, LABELS.supplier, (rest) => {\n      const first = rest.split(/\\s{2,}|\\||;/)[0];\n      if (/@/.test(first)) return null;                       // \"From: billing@acme.com\" is not a supplier name\n      const v = cleanSupplierName(first);\n      if (/^(billing|accounts?|invoices?|noreply|no-reply)\\b/i.test(v)) return null;\n      return v.length >= 2 && v.length <= 80 && /[A-Za-z]/.test(v) ? v : null;\n    });\n    if (fromLabel) return { value: fromLabel.value, confidence: 0.85, source: 'label:' + fromLabel.label };\n\n    const fromDomain = supplierFromEmail(senderEmail);\n    if (fromDomain) return { value: fromDomain, confidence: 0.55, source: 'sender-domain' };\n\n    for (const line of lines.slice(0, 8)) {\n      if (line.length < 3 || line.length > 70) continue;\n      if (/\\d{3,}/.test(line)) continue;                       // addresses / phone numbers\n      if (/\\b(invoice|facture|rechnung|fattura|statement|page)\\b/i.test(line)) continue;\n      if (!/[A-Za-z]/.test(line)) continue;\n      return { value: cleanSupplierName(line), confidence: 0.4, source: 'first-line' };\n    }\n    return { value: null, confidence: 0, source: 'none' };\n  }\n\n  function extractVatId(text) {\n    const lines = String(text || '').split(/\\r?\\n/);\n    const hit = labelLookup(lines, LABELS.vatId, (rest) => {\n      const m = rest.match(/\\b([A-Z]{2}\\s?[0-9A-Z]{6,14}|\\d{8,14})\\b/i);\n      return m ? normSpace(m[1]).toUpperCase() : null;\n    });\n    if (hit) return { value: hit.value, confidence: hit.confidence, source: 'label:' + hit.label };\n    return { value: null, confidence: 0, source: 'none' };\n  }\n\n  function vatRateFrom(net, vat) {\n    if (net == null || vat == null || net === 0) return null;\n    return round2((vat / net) * 100);\n  }\n\n  function validateTotals(net, vat, gross) {\n    if (gross == null) return { ok: false, diff: null, reason: 'no gross total found' };\n    // A total with no net/VAT split is normal for non-VAT suppliers: nothing to check.\n    if (net == null && vat == null) return { ok: true, diff: null, reason: null, vat_split_missing: true };\n    const computed = round2((net || 0) + (vat || 0));\n    const diff = round2(Math.abs(computed - gross));\n    const tolerance = Math.max(0.02, Math.abs(gross) * 0.005);\n    if (diff <= tolerance) return { ok: true, diff, reason: null };\n    return { ok: false, diff, reason: `net+vat=${computed} but total=${gross}` };\n  }\n\n  /**\n   * Extract one invoice from PDF/email text.\n   * opts: {dateOrder:'auto'|'dmy'|'mdy', defaultCurrency, senderEmail, subject}\n   */\n\n  function extractInvoice(text, opts) {\n    const o = opts || {};\n    const body = decodeEntities(String(text || '')).replace(/\\u0000/g, ' ');\n    const lines = body.split(/\\r?\\n/).map(normSpace).filter(Boolean);\n    const flatText = normSpace(body);\n\n    const num = extractInvoiceNumber(body);\n    const invDate = extractDateByLabels(body, LABELS.invoiceDate, o.dateOrder);\n    const dueDate = extractDateByLabels(body, LABELS.dueDate, o.dateOrder);\n    const net = extractAmountByLabels(lines, LABELS.net, /\\b(days?|terms?|net\\s*\\d+|payment terms)\\b/i);\n    const vat = extractAmountByLabels(lines, LABELS.vat, /\\b(id|no|nr|number|nummer|reg|code)\\b/i);\n    const gross = extractAmountByLabels(lines, LABELS.gross, /\\b(vat|tax|mwst|ust|tva|iva|btw|gst|sub\\s?total|sous total|zwischensumme|net|netto|imponibile)\\b/i);\n    const supplier = extractSupplier(body, o.senderEmail || null);\n    const vatId = extractVatId(body);\n\n    const cur = detectCurrency(flatText);\n    const currency = cur.code || (o.defaultCurrency ? String(o.defaultCurrency).toUpperCase() : null);\n\n    const totalCheck = validateTotals(net.value, vat.value, gross.value);\n    const supplierKey = supplier.value ? normaliseSupplierKey(supplier.value) : '';\n\n    const fields = {\n      supplier: supplier.value,\n      supplier_key: supplierKey,\n      vat_id: vatId.value,\n      invoice_number: num.value,\n      invoice_date: invDate.value,\n      due_date: dueDate.value,\n      currency: currency,\n      net: net.value,\n      vat: vat.value,\n      gross: gross.value,\n      vat_rate: vatRateFrom(net.value, vat.value),\n      vat_split_missing: totalCheck.vat_split_missing === true,\n    };\n\n    const reasons = [];\n    if (!fields.supplier) reasons.push('supplier not found');\n    if (!fields.invoice_number) reasons.push('invoice number not found');\n    if (!fields.invoice_date) reasons.push('invoice date not found or ambiguous');\n    if (fields.gross == null) reasons.push('total amount not found');\n    if (!currency) reasons.push('currency not found');\n    if (!totalCheck.ok && totalCheck.reason) reasons.push(totalCheck.reason);\n\n    const confidences = [num.confidence, invDate.confidence, gross.confidence, supplier.confidence];\n    const confidence = round2(confidences.reduce((a, b) => a + b, 0) / confidences.length);\n\n    return {\n      ...fields,\n      totals_ok: totalCheck.ok,\n      totals_diff: totalCheck.diff,\n      confidence,\n      needs_review: reasons.length > 0,\n      review_reasons: reasons,\n      sources: {\n        invoice_number: num.source, invoice_date: invDate.source,\n        gross: gross.source, supplier: supplier.source, currency: cur.source,\n      },\n    };\n  }\n\n  function invoiceKey(row) {\n    const supplier = row.supplier_key || normaliseSupplierKey(row.supplier);\n    const num = String(row.invoice_number || '').toUpperCase().replace(/[^A-Z0-9]/g, '');\n    if (!num) return '';\n    return `${supplier}|${num}`;\n  }\n\n  return {\n    // text\n    normSpace, stripDiacritics, decodeEntities,\n    // numbers + dates + currency\n    parseAmount, round2, detectCurrency, parseDate, isoFromParts, pad2,\n    // suppliers\n    normaliseSupplierKey, cleanSupplierName, supplierFromEmail,\n    // extraction\n    extractInvoice, vatRateFrom, validateTotals,\n    // dedupe\n    invoiceKey,\n    // version\n    VERSION: '1.0.0',\n  };\n}\nconst C = ledgerCore();\nconst settings = $('Settings').first().json || {};\nconst meta = $('Split PDF attachments').all();\nreturn $input.all().map((item, i) => {\n  const text = item.json.text || item.json.data || '';\n  const m = (meta[i] && meta[i].json) || {};\n  const inv = C.extractInvoice(text, {\n    dateOrder: settings.dateOrder || 'auto',\n    defaultCurrency: settings.defaultCurrency || null,\n    senderEmail: m.from || null,\n  });\n  return { json: Object.assign({}, inv, {\n    attachment_name: m.attachment_name || '',\n    from: m.from || '',\n    invoice_key: C.invoiceKey(inv),\n  }) };\n});"
      }
    },
    {
      "id": "23554a4d-2268-5a71-bac9-b07b33730d46",
      "name": "Preview",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1200,
        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 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  function counted(row) {\n    const s = String(row.status || 'new').toLowerCase();\n    return s === 'new' || s === 'approved' || s === 'matched';\n  }\n\n  function digest(rows) {\n    const counts = { new: 0, duplicate: 0, possible_duplicate: 0, needs_review: 0, approved: 0, other: 0 };\n    const totals = {};\n    for (const r of rows || []) {\n      const s = String(r.status || 'new');\n      if (Object.prototype.hasOwnProperty.call(counts, s)) counts[s] += 1; else counts.other += 1;\n      if (counted(r)) {\n        const c = r.currency || 'unknown';\n        totals[c] = round2((totals[c] || 0) + (num(r.gross) || 0));\n      }\n    }\n    return { counts, totals };\n  }\n\n  return {\n    // numbers + dates + currency\n    round2,\n    // reporting\n    digest, counted,\n    // version\n    VERSION: '1.0.0',\n  };\n}\nconst C = ledgerCore();\nconst rows = $('Parse invoice').all().map((i) => i.json);\nconst d = C.digest(rows);\nconst byStatus = rows.reduce((acc, r) => {\n  acc[r.needs_review ? 'needs review' : 'readable'] = (acc[r.needs_review ? 'needs review' : 'readable'] || 0) + 1;\n  return acc;\n}, {});\nconst lines = [];\nlines.push('Inbox preview: ' + rows.length + ' PDF invoice(s)');\nfor (const r of rows) {\n  lines.push('- ' + (r.supplier || '?') + ' | ' + (r.invoice_number || 'no number') + ' | ' +\n    (r.gross == null ? 'no total' : r.gross + ' ' + (r.currency || '')) +\n    (r.needs_review ? ' | needs review: ' + r.review_reasons.join('; ') : ''));\n}\nlines.push('');\nlines.push('Nothing was written. The paid pack stores these rows, skips duplicates and reports daily.');\nreturn [{ json: { text: lines.join('\\n'), counts: d.counts, totals: d.totals, readable: byStatus.readable || 0,\n  needs_review: byStatus['needs review'] || 0 } }];"
      }
    },
    {
      "id": "15dd2ec0-47dc-5e24-a5b0-238b7f666aa0",
      "name": "Telegram preview",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        1440,
        0
      ],
      "parameters": {
        "resource": "message",
        "operation": "sendMessage",
        "chatId": "={{ $('Settings').first().json.chatId }}",
        "text": "={{ $json.text }}",
        "additionalFields": {}
      },
      "disabled": true
    },
    {
      "id": "6fbd888e-450f-50b9-85fe-231ae62637d5",
      "name": "Note: Connects to your own invoice mailbox, re",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -680
      ],
      "parameters": {
        "content": "Connects to your own invoice mailbox, reads the PDF attachments and prints what\nit understood: supplier, invoice number, total, currency and whether the totals\nadd up. It writes nothing — no ledger, no file, no database — so you can judge how\nwell it reads your suppliers before buying anything.\n\n### How it works\n- *Inbox (IMAP)* reads mail from the mailbox you connect, and marks what it reads\n  as read.\n- *Split PDF attachments* keeps the PDFs and drops everything else.\n- *PDF to text* reads the text layer; a scan has none.\n- *Parse invoice* extracts the fields and checks that net + VAT equals the total.\n- *Preview* prints one block per invoice.\n- *Telegram preview* is disabled; turn it on to get the same text on your phone.\n\n### Setup\nConnect the IMAP credential, then set `dateOrder` and `defaultCurrency` in\n*Settings*. Use a dedicated mailbox, or a folder you can afford to have marked as\nread. Click *Execute workflow* once.\n\n### Customization\nEnable *Telegram preview* for phone delivery. Read more than the first batch by\nraising the limit in *Inbox (IMAP)*.",
        "width": 680,
        "height": 560,
        "color": 1
      }
    },
    {
      "id": "c2ec3a1b-72b9-5d49-b4b8-9a9fb2f411cd",
      "name": "Note: Your own mailbox, previewed, nothing wri",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -80
      ],
      "parameters": {
        "content": "### Your own mailbox, previewed, nothing written",
        "width": 1700,
        "height": 60,
        "color": 7
      }
    }
  ],
  "connections": {
    "Inbox (IMAP)": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings": {
      "main": [
        [
          {
            "node": "Split PDF attachments",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split PDF attachments": {
      "main": [
        [
          {
            "node": "PDF to text",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "PDF to text": {
      "main": [
        [
          {
            "node": "Parse invoice",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse invoice": {
      "main": [
        [
          {
            "node": "Preview",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Preview": {
      "main": [
        [
          {
            "node": "Telegram preview",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "saveDataSuccessExecution": "all"
  },
  "active": false
}
