{
  "name": "Read one PDF invoice and see the fields — no AI, no API key",
  "nodes": [
    {
      "id": "06447357-d0ab-5c47-9ccd-ad69a6ca3a33",
      "name": "Form: upload an invoice",
      "type": "n8n-nodes-base.formTrigger",
      "typeVersion": 2.5,
      "position": [
        0,
        0
      ],
      "parameters": {
        "path": "read-one-invoice",
        "formTitle": "Read one invoice",
        "formDescription": "Upload a PDF invoice. You get back the fields this parser found, and nothing is stored.",
        "formFields": {
          "values": [
            {
              "fieldLabel": "Invoice PDF",
              "fieldType": "file",
              "requiredField": true
            }
          ]
        },
        "responseMode": "responseNode",
        "options": {}
      }
    },
    {
      "id": "f5faf925-8610-5692-bfae-1bdb1a4bc152",
      "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": "13d0d85f-c223-5540-9dd7-903b88be5ac3",
      "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 PDF invoice.');\n}\nconst file = item.binary[keys[0]];\nreturn [{\n  json: { file_name: file.fileName || 'upload.pdf', mime_type: file.mimeType || '' },\n  binary: { data: file },\n}];"
      }
    },
    {
      "id": "f452fa0c-3fdc-51a5-9015-0865ebe6c437",
      "name": "PDF to text",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1.2,
      "position": [
        720,
        0
      ],
      "parameters": {
        "operation": "pdf",
        "binaryPropertyName": "data",
        "options": {}
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "d4d78f37-2488-570c-84f6-cde2881f6620",
      "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  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    // version\n    VERSION: '1.0.0',\n  };\n}\nconst C = ledgerCore();\nconst settings = $('Settings').first().json || {};\nconst text = $input.first().json.text || $input.first().json.data || '';\nconst inv = C.extractInvoice(text, {\n  dateOrder: settings.dateOrder || 'auto',\n  defaultCurrency: settings.defaultCurrency || null,\n  subject: $('Pick the file').first().json.file_name,\n});\nreturn [{ json: inv }];"
      }
    },
    {
      "id": "65c0b9f4-8d80-5f49-9d0a-293d5d06844f",
      "name": "Result",
      "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 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 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  // ------------------------------------------------------------------- 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  // ---------------------------------------------------------------- deduplication\n  function fnv1a32(str) {\n    let h = 0x811c9dc5;\n    for (let i = 0; i < str.length; i++) {\n      h ^= str.charCodeAt(i);\n      h = Math.imul(h, 0x01000193) >>> 0;\n    }\n    return h.toString(16).padStart(8, '0');\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  // Non-cryptographic content hash: catches the same invoice re-sent with a new filename.\n  function contentHash(row) {\n    const parts = [\n      row.supplier_key || normaliseSupplierKey(row.supplier), String(row.invoice_number || '').toUpperCase(),\n      String(row.gross == null ? '' : round2(row.gross)), String(row.currency || ''), String(row.invoice_date || ''),\n    ];\n    return fnv1a32(parts.join('|'));\n  }\n\n  // ------------------------------------------------------------------------ csv\n  const LEDGER_COLUMNS = [\n    'run_at', 'source', 'message_id', 'attachment_name', 'supplier', 'supplier_key', 'vat_id',\n    'invoice_number', 'invoice_key', 'invoice_date', 'due_date', 'currency',\n    'net', 'vat', 'gross', 'vat_rate', 'status', 'confidence', 'duplicate_of', 'review_reason',\n    'content_hash', 'updated_at',\n  ];\n\n  function csvEscape(value) {\n    if (value == null) return '';\n    const s = String(value);\n    if (/[\",\\r\\n]/.test(s)) return '\"' + s.replace(/\"/g, '\"\"') + '\"';\n    return s;\n  }\n\n  function toCsv(rows, columns) {\n    const cols = columns || LEDGER_COLUMNS;\n    const head = cols.map(csvEscape).join(',');\n    const body = (rows || []).map((r) => cols.map((c) => csvEscape(r[c])).join(','));\n    return [head, ...body].join('\\r\\n') + '\\r\\n';\n  }\n\n  return {\n    // text\n    normSpace, stripDiacritics,\n    // numbers + dates + currency\n    round2,\n    // suppliers\n    normaliseSupplierKey,\n    // dedupe\n    fnv1a32, invoiceKey, contentHash,\n    // output\n    LEDGER_COLUMNS, toCsv, csvEscape,\n    // version\n    VERSION: '1.0.0',\n  };\n}\n// One readable answer: what was found, what was guessed, what was missed.\nconst C = ledgerCore();\nconst inv = $input.first().json;\nconst lines = [\n  'Supplier:      ' + (inv.supplier || '(not found)'),\n  'Invoice no:    ' + (inv.invoice_number || '(not found)'),\n  'Invoice date:  ' + (inv.invoice_date || '(not found)'),\n  'Due date:      ' + (inv.due_date || '-'),\n  'Currency:      ' + (inv.currency || '(not found)'),\n  'Net / VAT / total: ' + [inv.net, inv.vat, inv.gross].map((v) => (v == null ? '-' : v)).join(' / '),\n  'VAT rate:      ' + (inv.vat_rate == null ? '-' : inv.vat_rate + '%'),\n  'Totals check:  ' + (inv.totals_ok ? 'net + VAT = total' : 'does not add up'),\n  'Needs review:  ' + (inv.needs_review ? inv.review_reasons.join('; ') : 'no'),\n];\nconst row = Object.assign({}, inv, {\n  invoice_key: C.invoiceKey(inv),\n  content_hash: C.contentHash(inv),\n});\nreturn [{\n  json: {\n    ok: !inv.needs_review,\n    fields: row,\n    summary: lines.join('\\n'),\n    ledger_row_csv: C.toCsv([row]),\n    note: 'This free demo writes nothing. The paid pack stores these rows, skips duplicates and reports every day.',\n  },\n}];"
      }
    },
    {
      "id": "74889fee-ce5b-535c-9719-d327e0116c45",
      "name": "Answer the form",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.5,
      "position": [
        1440,
        0
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify($json) }}",
        "options": {}
      }
    },
    {
      "id": "b4650c66-34b5-5ad6-8454-c07a41c9d87a",
      "name": "Note: Upload one PDF invoice and see what the ",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -660
      ],
      "parameters": {
        "content": "Upload one PDF invoice and see what the parser understood. No account, no API\nkey, no AI model, and nothing written to disk: the invoice text stays on the\nmachine that runs n8n. This is the same parser as the paid Invoice Ledger pack,\nso a good result here is what you would get there.\n\n### How it works\n- *Form: upload an invoice* takes one PDF.\n- *Pick the file* passes the upload on as PDF data.\n- *PDF to text* reads the text layer; a scan or a photo has none.\n- *Parse invoice* finds supplier, invoice number, invoice and due dates, currency,\n  net, VAT and total, then checks that net + VAT equals the total.\n- *Result* prints every field, plus a plain reason for anything it could not find.\n- *Answer the form* returns that text to the browser.\n\n### Setup\nNo credential and no install. Click *Test workflow*, open the form URL n8n shows,\nand upload an invoice from one of your own suppliers.\n\n### Customization\n`dateOrder` (auto / dmy / mdy) and `defaultCurrency` are in *Settings*.\nAdd fields by editing the *Parse invoice* code.",
        "width": 680,
        "height": 540,
        "color": 1
      }
    },
    {
      "id": "8da5dba6-7652-52a7-bdb6-0df46dc728aa",
      "name": "Note: Upload one PDF, read the fields, answer ",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -80
      ],
      "parameters": {
        "content": "### Upload one PDF, read the fields, answer the form",
        "width": 1700,
        "height": 60,
        "color": 7
      }
    }
  ],
  "connections": {
    "Form: upload an invoice": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings": {
      "main": [
        [
          {
            "node": "Pick the file",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick the file": {
      "main": [
        [
          {
            "node": "PDF to text",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "PDF to text": {
      "main": [
        [
          {
            "node": "Parse invoice",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse invoice": {
      "main": [
        [
          {
            "node": "Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Result": {
      "main": [
        [
          {
            "node": "Answer the form",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "saveDataSuccessExecution": "all"
  },
  "active": false
}
